Published on

Giải mã VietQR: bên trong một mã QR chuyển khoản có gì?

Authors

Mỗi lần bạn giơ điện thoại lên quét mã để trả tiền cà phê, app ngân hàng chỉ mất chưa đến một giây để biết chuyển cho ai, bao nhiêu tiền, nội dung gì. Không có internet nào được gọi ở bước đó cả. Mọi thông tin đã nằm sẵn trong mã QR, dưới dạng một chuỗi chữ dài chừng 100 ký tự. Bài này mình sẽ cùng bạn đọc chuỗi đó, từng đoạn một.

Mình viết bài này sau khi tự tay làm bộ sinh VietQR cho công cụ QR trên gen9x.com. Quá trình đó dạy mình vài điều mà đọc tài liệu suông không thấy được, kể cả một lỗi nho nhỏ với chữ "đ" làm mã không quét được. Mình sẽ kể hết ở cuối bài.

Nhiều bạn nghĩ QR chuyển khoản là một URL, kiểu quét xong app mở trang web của ngân hàng. Không phải. Nếu bạn dùng một app đọc QR bất kỳ (không phải app ngân hàng) quét thử mã VietQR, bạn sẽ thấy nó chỉ là một chuỗi text trông như thế này:

00020101021138570010A00000072701270006970436011300110012345670208QRIBFTTA53037045802VN6304E8DB

Trông như mã hoá, nhưng thật ra không có gì bí mật cả. Đây là một chuỗi được ghép theo chuẩn EMVCo Merchant-Presented QR (chuẩn quốc tế mà cả Visa, Mastercard, và các hệ thống thanh toán châu Á đều dùng), với một vài quy ước riêng của NAPAS cho thị trường Việt Nam. VietQR là tên thương hiệu NAPAS đặt cho tổ hợp đó.

Chuỗi trên chính là mã nhận tiền của tài khoản Vietcombank số 0011001234567. Cuối bài bạn sẽ đọc được nó dễ như đọc số điện thoại. Hứa đấy.

Một ý tưởng duy nhất cần nắm: TLV

Toàn bộ chuỗi được xây từ một khối lặp đi lặp lại, gọi là TLV: Tag, Length, Value. Trong EMVCo người ta hay gọi là ID – Length – Value, nhưng ý là một:

  • 2 ký tự đầu: mã trường (tag/ID), cho biết đây là thông tin gì.
  • 2 ký tự tiếp: độ dài của phần giá trị, viết bằng 2 chữ số (05, 12, 57...).
  • N ký tự sau đó: giá trị thật.

Ví dụ khối đầu tiên của mọi mã VietQR:

00 02 01
│  │  └── giá trị: "01"
│  └───── độ dài: 2 ký tự
└──────── tag 00: Payload Format Indicator

Đọc xong khối này, con trỏ nhảy tiếp 6 ký tự và gặp khối tiếp theo. Cứ như vậy cho đến hết chuỗi. Không cần dấu phân cách, không cần dấu phẩy, vì độ dài đã nói cho ta biết chỗ nào kết thúc. Đó là lý do TLV được dùng khắp nơi trong thẻ chip, NFC, và cả QR thanh toán: gọn, đọc tuần tự, không nhập nhằng.

Nếu bạn viết được một vòng lặp đọc TLV, bạn đã có 80% của một trình phân tích VietQR:

function readTLV(payload: string) {
  const fields: { id: string; value: string }[] = []
  let i = 0
  while (i < payload.length) {
    const id = payload.slice(i, i + 2)
    const len = Number(payload.slice(i + 2, i + 4))
    const value = payload.slice(i + 4, i + 4 + len)
    fields.push({ id, value })
    i += 4 + len
  }
  return fields
}

Bóc một mã VietQR thật

Giờ mình chạy vòng lặp trên với chuỗi ở đầu bài. Đây là kết quả:

TagĐộ dàiGiá trịÝ nghĩa
000201Phiên bản định dạng, luôn là 01
010211Loại mã: 11 = tĩnh, 12 = động
38570010A00000072701270006970436011300110012345670208QRIBFTTAThông tin tài khoản nhận (của NAPAS)
5303704Tiền tệ: 704 là mã ISO của VND
5802VNQuốc gia
6304E8DBMã kiểm tra CRC

Chỉ có 6 trường. Bốn trong số đó gần như cố định với mọi mã VietQR (00, 53, 58, 63). Toàn bộ "chất" nằm ở tag 38, và ta sẽ mổ nó ngay bây giờ.

Tag 38: chiếc hộp lồng hộp của NAPAS

Trong chuẩn EMVCo, các tag từ 02 đến 51 dành cho "thông tin tài khoản người bán", mỗi hệ thống thanh toán được cấp một số. NAPAS dùng 38. Giá trị của tag 38 không phải một chuỗi phẳng mà lại là… một chuỗi TLV khác. Hộp lồng hộp. Ta chạy lại vòng lặp readTLV trên giá trị của nó:

00 10 A000000727            ← GUID của NAPAS
01 27 00069704360113001100123456 ← thông tin ngân hàng + tài khoản (lại là TLV!)
02 08 QRIBFTTA              ← loại dịch vụ
  • Sub-tag 00 là định danh toàn cầu của NAPAS: A000000727. App ngân hàng nhìn thấy chuỗi này là biết "à, mã của hệ thống NAPAS, mình đọc tiếp theo luật NAPAS".
  • Sub-tag 02 là loại dịch vụ. QRIBFTTA nghĩa là QR – Interbank Fund Transfer – To Account: chuyển nhanh liên ngân hàng tới số tài khoản. Nếu người nhận dùng số thẻ thay cho số tài khoản, mã sẽ là QRIBFTTC (To Card).
  • Sub-tag 01 lại lồng thêm một lớp nữa. Bóc tiếp:
00 06 970436          ← mã BIN của ngân hàng (Vietcombank)
01 13 0011001234567   ← số tài khoản người nhận

Và đây rồi, hai thông tin bạn thực sự quan tâm: ngân hàng nàosố tài khoản nào.

BIN là gì, và tại sao là 970436?

BIN (Bank Identification Number) là mã 6 số NAPAS cấp cho mỗi thành viên. Các ngân hàng Việt Nam hầu hết có dạng 9704xx: Vietcombank là 970436, BIDV 970418, VietinBank 970415, Techcombank 970407, MB 970422... Các ví điện tử và ngân hàng số tham gia sau thì mang dải khác, ví dụ MoMo là 971025, CAKE là 546034.

Khi bạn tự sinh mã, đây là chỗ dễ sai nhất: gõ nhầm một số là tiền đi sang ngân hàng khác, hoặc app báo "không tìm thấy tài khoản". Mình khuyên bạn đừng tự chép tay danh sách này từ một bài viết nào đó, kể cả bài này. Hãy lấy từ nguồn được cập nhật thường xuyên. Bản danh sách mình dùng cho công cụ của mình có ở trang API Docs, và mình đối chiếu nó với danh sách công khai của vietqr.io mỗi lần cập nhật.

Mã tĩnh và mã động: tag 01 và tag 54

Mã ở ví dụ trên có tag 01 = 11, gọi là mã tĩnh. Nó không chứa số tiền. Người trả tiền quét xong tự nhập số tiền, và một mã tĩnh dùng được mãi. Đây chính là loại mã dán trên quầy thu ngân.

Bây giờ thêm số tiền 50.000đ và nội dung "Thanh toan don hang 123" vào. Chuỗi trở thành:

00020101021238570010A00000072701270006970436011300110012345670208QRIBFTTA53037045405500005802VN62270823Thanh toan don hang 123630441DF

Ba chỗ thay đổi:

TagĐộ dàiGiá trịÝ nghĩa
010212Đổi sang mã động: dùng một lần, có số tiền sẵn
540550000Số tiền, viết liền không dấu chấm phẩy
62270823Thanh toan don hang 123Dữ liệu bổ sung, lại là một TLV lồng

Tag 62 bóc ra được sub-tag 08 (Purpose of Transaction) với giá trị Thanh toan don hang 123. Đó chính là dòng "nội dung chuyển khoản" hiện sẵn trong app ngân hàng của người trả.

Bạn có thể thêm tên người nhận ở tag 59 và thành phố ở tag 60, nhưng hai trường này chỉ để hiển thị. App ngân hàng luôn tra tên chủ tài khoản từ số tài khoản, nên bạn ghi gì ở 59 cũng không làm tiền đi nhầm được.

Còn cái đuôi 630441DF thì sao? CRC đổi từ E8DB thành 41DF. Nội dung khác thì chữ ký khác. Đến đây ta đi vào phần thú vị nhất.

CRC-16: bốn ký tự bảo vệ cả chuỗi

Camera điện thoại đọc QR trong điều kiện đủ kiểu: mờ, nghiêng, thiếu sáng, mã bị in lệch. Bản thân QR code đã có cơ chế sửa lỗi, nhưng EMVCo vẫn thêm một lớp bảo hiểm: tag 63 chứa CRC-16 của toàn bộ chuỗi. App ngân hàng tính lại CRC từ dữ liệu đọc được; không khớp là từ chối luôn, không đoán mò.

CRC là gì, nói cho người không học điện tử

Hãy tưởng tượng bạn có một số rất dài (toàn bộ chuỗi payload, xem như một dãy bit). Bạn chia số đó cho một "số chia" cố định, rồi giữ lại số dư. Số dư đó chính là CRC. Chỉ cần một bit trong dữ liệu đổi, số dư sẽ đổi. Vậy nên CRC là một loại checksum, tốt hơn nhiều so với kiểu "cộng tất cả các byte lại".

Điểm lằng nhằng duy nhất: phép chia này là chia đa thức trên trường GF(2), nghĩa là phép trừ chính là XOR. Bạn không cần hiểu lý thuyết để dùng, chỉ cần code đúng công thức.

Biến thể nào?

Có hàng chục biến thể CRC-16, khác nhau ở đa thức, giá trị khởi đầu, và có đảo bit hay không. Chọn sai biến thể là ra kết quả sai hoàn toàn, dù code "chạy không lỗi". EMVCo quy định rõ:

  • Tên: CRC-16/CCITT-FALSE
  • Đa thức: 0x1021
  • Giá trị khởi đầu: 0xFFFF
  • Không đảo bit đầu vào, không đảo bit đầu ra, không XOR cuối
  • Kết quả viết thành 4 ký tự hex in hoa

Cách nhận biết mình đã chọn đúng biến thể: cho chuỗi "123456789" vào, kết quả phải là 29B1. Đây là "giá trị kiểm tra" chuẩn của biến thể này. Mình luôn viết một test cho con số này trước khi tin bất kỳ hàm CRC nào.

Code

function crc16(bytes: Uint8Array): string {
  let crc = 0xffff // giá trị khởi đầu
  for (const byte of bytes) {
    crc ^= byte << 8 // đưa byte vào 8 bit cao
    for (let bit = 0; bit < 8; bit++) {
      // Nếu bit cao nhất là 1: dịch trái rồi XOR với đa thức. Không thì chỉ dịch trái.
      crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff
    }
  }
  return crc.toString(16).toUpperCase().padStart(4, '0')
}

crc16(new TextEncoder().encode('123456789')) // "29B1"

Cái bẫy 6304

Câu hỏi hay gặp: CRC tính trên đoạn nào? Câu trả lời hơi ngược đời: tính trên toàn bộ chuỗi, kể cả tag và độ dài của chính trường CRC, nhưng chưa có giá trị CRC. Nghĩa là bạn ghép xong mọi trường, nối thêm 6304 vào đuôi, tính CRC trên chuỗi đó, rồi dán 4 ký tự kết quả vào sau cùng.

const body = allFieldsJoined + '6304'
const payload = body + crc16(new TextEncoder().encode(body))

Quên 6304 là lỗi số một của người mới làm VietQR. Mã vẫn hiện, app vẫn nhận diện là VietQR, nhưng báo "mã không hợp lệ". Bạn sẽ ngồi soi BIN, soi số tài khoản cả buổi mà không nghĩ tới bốn ký tự này.

Ráp lại thành một bộ sinh mã hoàn chỉnh

Đến đây bạn đã có đủ nguyên liệu. Toàn bộ logic sinh VietQR gói gọn trong khoảng 40 dòng:

const tlv = (id: string, value: string) =>
  id + String(new TextEncoder().encode(value).length).padStart(2, '0') + value

type Input = {
  bankBin: string
  accountNumber: string
  amount?: number
  description?: string
}

export function buildVietQR({ bankBin, accountNumber, amount, description }: Input) {
  // Lớp trong cùng: ngân hàng + tài khoản
  const beneficiary = tlv('00', bankBin) + tlv('01', accountNumber)

  // Tag 38: GUID NAPAS + beneficiary + loại dịch vụ
  const napas = tlv('00', 'A000000727') + tlv('01', beneficiary) + tlv('02', 'QRIBFTTA')

  const body =
    tlv('00', '01') +
    tlv('01', amount ? '12' : '11') + // động nếu có số tiền, tĩnh nếu không
    tlv('38', napas) +
    tlv('53', '704') +
    (amount ? tlv('54', String(amount)) : '') +
    tlv('58', 'VN') +
    (description ? tlv('62', tlv('08', description)) : '') +
    '6304'

  return body + crc16(new TextEncoder().encode(body))
}

Chạy buildVietQR({ bankBin: '970436', accountNumber: '0011001234567' }) bạn sẽ nhận lại đúng chuỗi ở đầu bài, kết thúc bằng E8DB. Đưa chuỗi đó vào bất kỳ thư viện vẽ QR nào (mình dùng qrcode trên npm), in ra, quét bằng app ngân hàng: xong.

Những cái bẫy mình từng dính

Phần này là lý do mình viết bài. Spec thì ai cũng đọc được, nhưng những chỗ dưới đây chỉ lộ ra khi bạn thật sự ship.

1. Chữ "đ" làm hỏng CRC

Các trường văn bản trong EMVCo phải là ASCII in được. Tiếng Việt có dấu thì phải bỏ dấu trước. Cách phổ biến là dùng normalize('NFD') để tách dấu ra thành ký tự riêng, rồi xoá dải Unicode ̀-ͯ:

'Chuyển tiền'.normalize('NFD').replace(/[̀-ͯ]/g, '') // "Chuyen tien"

Chạy rất ổn với á, ầ, ư, ơ, ệ… Cho đến khi có người gõ "đơn hàng". Chữ đ (U+0111) không phải là "d + dấu". Nó là một chữ cái độc lập trong Unicode, NFD không tách được, và nó đi thẳng vào payload.

Hậu quả dây chuyền: "đ" chiếm 2 byte trong UTF-8, nhưng hàm CRC của mình lúc đó chạy trên charCodeAt (mã UTF-16, 1 đơn vị). Máy quét thì nhìn thấy byte UTF-8. Hai bên tính CRC trên hai dãy byte khác nhau, đương nhiên lệch. Mã hiển thị đẹp, app báo lỗi, và mình mất một buổi tối mới soi ra vì mọi mã không có "đ" đều quét được.

Cách sửa: xử lý đ/Đ bằng tay, rồi lọc sạch những gì còn lại ngoài ASCII:

const toAscii = (s: string) =>
  s
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .replace(/đ/g, 'd')
    .replace(/Đ/g, 'D')
    .replace(/[^\x20-\x7e]/g, '')

Bài học rộng hơn: luôn tính CRC trên byte UTF-8, và luôn tính độ dài TLV theo byte, không theo số ký tự. Khi payload đã sạch ASCII thì hai con số này trùng nhau và bạn khỏi phải lo. Nhưng nếu lỡ có một ký tự lạ lọt vào, tính theo byte vẫn cho ra mã đúng.

2. Giới hạn độ dài

EMVCo đặt giới hạn cho từng trường, và app ngân hàng có quyền từ chối mã vượt giới hạn:

TrườngTối đa
Số tài khoản (38 → 01 → 01)19 ký tự
Số tiền (54)13 ký tự
Tên người nhận (59)25 ký tự
Thành phố (60)15 ký tự
Nội dung (62 → 08)25 ký tự

Nhiều app vẫn đọc được nội dung dài hơn 25 ký tự, nhưng "nhiều" không phải "tất cả". Nếu bạn làm tool cho người khác dùng, hãy cắt ở 25 và nói rõ với họ. Nội dung chuyển khoản tốt nên ngắn kiểu DH12345 hay Hoc phi T9, vừa gọn vừa dễ đối soát.

3. Độ dài chỉ có 2 chữ số

Vì phần Length luôn là 2 chữ số, một trường không thể dài quá 99 byte. Với VietQR điều này hiếm khi thành vấn đề, nhưng đáng để biết khi bạn thấy padStart(2, '0') trong code và tự hỏi tại sao không có padStart(3, ...).

4. Số tiền là số nguyên

VND không có phần lẻ. 54 nên là chuỗi số nguyên: 50000, không phải 50000.00 hay 50.000. Mình đã thấy mã lỗi chỉ vì lập trình viên format tiền theo locale trước khi đưa vào payload.

Cách debug một mã VietQR lạ

Khi ai đó gửi bạn một mã "không quét được", đừng đoán. Làm theo thứ tự:

  1. Dùng app đọc QR thường để lấy chuỗi text ra.
  2. Chạy readTLV trên chuỗi. Nếu vòng lặp không kết thúc đúng ở cuối chuỗi, có một trường Length bị sai.
  3. Bóc tag 38, kiểm tra GUID A000000727, BIN, số tài khoản.
  4. Cắt 4 ký tự cuối, tính CRC-16/CCITT-FALSE trên phần còn lại (đã bao gồm 6304), so với 4 ký tự đó.
  5. Nếu CRC khớp mà app vẫn từ chối: soi ký tự ngoài ASCII và độ dài từng trường.

Bốn bước đầu mất chưa tới một phút nếu bạn có sẵn đoạn readTLV ở trên. Mình để cả một trang thử nghiệm tại gen9x.com/qrtool/viet-qr-generator: bạn nhập thông tin, công cụ hiện luôn payload để bạn đối chiếu với những gì vừa học.

Tóm lại

Một mã VietQR chỉ là một chuỗi TLV phẳng: vài trường cố định, một tag 38 lồng ba lớp chứa BIN và số tài khoản, tuỳ chọn số tiền và nội dung, và một CRC-16 ở cuối để bảo vệ tất cả. Không có mã hoá, không có bí mật, không có server nào được gọi lúc quét.

Nếu bạn định tự viết bộ sinh mã, ba điều mình muốn bạn nhớ:

  • Tính độ dài và CRC theo byte UTF-8, và giữ payload sạch ASCII (nhớ chữ đ).
  • Nối 6304 trước khi tính CRC, và kiểm tra hàm CRC bằng "123456789"29B1.
  • Lấy BIN từ nguồn cập nhật, đừng chép tay.

Còn nếu bạn chỉ cần mã để dán lên quầy hoặc nhúng vào hoá đơn, công cụ VietQR của mình miễn phí, chạy ngay trên trình duyệt, và có cả API để nhúng ảnh QR bằng một URL. Có gì thắc mắc hay phát hiện chỗ nào mình viết chưa chuẩn, cứ nhắn cho mình qua các kênh liên hệ ở trang About nhé.