Building with an AI assistant?
Download guide_for_llm.md and give it to your AI coding assistant (ChatGPT, Claude, Copilot, …) with what you want to build, for example "Using this guide, show the user's BlockchainCards in my app". It holds everything on this page in one file, with ready-to-use, tested JavaScript.
How it works
Users add and edit their cards in the BlockchainCards web app. Your app only has to read them. There is no server and no API key: the cards live on the Solana blockchain, and your app reads them directly.
- Each wallet has one vault (its list of cards) and one account per card. Their addresses are computed from the wallet address, so your app knows where to look.
- Every card is encrypted with the user's 32-byte data key. Your app needs that key to read the cards.
So there are three steps: 1. get the data key, 2. read the encrypted cards, 3. decrypt and draw them.
| Program id (mainnet) | 8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4 |
|---|---|
| Authority address | 6jmedw2T7sZ8aWHY47bG9em7guA2i91psVrn9pHpkgA9 |
| Solana RPC | any mainnet server, e.g. https://api.mainnet-beta.solana.com.
Reading is free. |
Step 1. Get the data key
There are two ways. Pick the one that fits your app.
A. Your app has a Solana wallet: ask it to sign
Ask the user's wallet to signMessage this exact text (UTF-8). Copy it character for character,
or the key will not match:
const UNLOCK_MESSAGE =
'BlockchainCards: unlock my cards (key version 2)\n\n' +
'Signing is free and sends nothing. This signature is the key to your encrypted ' +
'cards: anyone who has it can read them. Only sign it in an app you trust.';
KEK = HMAC-SHA256(key = "hwc-vault-v1", message = the 64 signature bytes) dataKey = open the vault's wrapped key with the KEK (see "decrypt" in step 3)
Keep the signature and the KEK in memory only: never store or send them.
B. No wallet (a watch, a TV, a kiosk): pair the device
The user approves your device once in the BlockchainCards web app, which leaves the data key on the chain, encrypted so that only your device can open it.
- Make a P-256 key pair and 16 random bytes (the secret). Keep both.
- Show the user this link as a QR code (or open it on their phone):
https://blockchaincards.xyz/?v=1&pub=<public key>&cluster=mainnet&program=8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4#s=<secret>
<public key>is x and y of the key, 32 bytes each, as 128 hex characters.<secret>is the 16 bytes as 32 hex characters. - The user connects their wallet on that page and taps Pair. Meanwhile your app polls its pairing
record (address in step 2, with
deviceId = sha256(public key bytes)) until it appears. - Open the grant stored in the record:
x = ECDH(your private key, grant[0..64] as a P-256 public key).x S = HMAC-SHA256(key = "hwc-device-v1", message = x) content = open grant[64..] with S → 48 bytes dataKey = content[0..32] check : content[32..48] == HMAC-SHA256(key = secret, message = record owner)[0..16]
If the check fails, the record is not for you: start again.
Keep the data key, the owner's wallet address, the key pair and the secret. The web client is a small reader that works exactly this way.
Step 2. Read the encrypted cards
Addresses
Every address is a Solana seed address (PublicKey.createWithSeed) with the authority address
as the base and the program id as the owner:
seed(parts) = first 16 bytes of sha256(parts), as 32 lowercase hex characters
vault of wallet W : createWithSeed(authority, seed("hwc:vault" + W), programId)
card N of wallet W : createWithSeed(authority, seed("hwc:card" + W + N as 4 bytes LE), programId)
pairing record : createWithSeed(authority, seed("hwc:device" + deviceId), programId)
W is the 32 raw bytes of the wallet address; + joins bytes.
What is inside
Fetch accounts with getAccountInfo or getMultipleAccounts
(encoding: "base64", commitment: "confirmed"). Before using one, check that its
owner is the program id and that it starts with the right 8-letter tag. Numbers are little-endian.
| Account | Bytes |
|---|---|
| Vault | hwcvault (8) · owner (32) · revision (8) · next card id (4) · prepaid
saves (4) · wrapped key: length (4) + bytes · card ids: count (4) + one 4-byte id each, in the
user's order · (more data your app can ignore) |
| Card | hwccard1 (8) · owner (32) · card id (4) · length L (2) · written chunks
(1) · encrypted card: L bytes from offset 47 |
| Pairing record | hwcdevic (8) · owner (32) · created (8) · grant:
length (4) + bytes · (owner's notes, ignore them) |
- Read the vault of the wallet. Take its card ids.
- Work out each card's address and fetch them all in one
getMultipleAccountscall. - A card id always means the same card (an edit gets a new id), so cache cards by id. The vault's revision changes on every save: if it did not change, nothing did.
Step 3. Decrypt and draw
Decrypt (a "box")
The wrapped key, every card and the grant use the same format, made with a 32-byte secret S:
box = 0x01 · iv (16) · AES-128-CBC ciphertext (PKCS#7 padding) · tag (32) encKey = HMAC-SHA256(S, "hwc-enc")[0..16] macKey = HMAC-SHA256(S, "hwc-mac") tag = HMAC-SHA256(macKey, 0x01 · iv · ciphertext)
To open: check the first byte is 1, check the tag, and only then decrypt with encKey and
iv. A wrong tag means a wrong key.
const te = new TextEncoder();
async function hmac(key, data) {
const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', k, data));
}
// Opens a box with the 32-byte secret; returns the decrypted bytes.
async function openBox(secret, box) {
if (box.length < 65 || box[0] !== 1) throw new Error('not a version 1 box');
const encKey = (await hmac(secret, te.encode('hwc-enc'))).slice(0, 16);
const macKey = await hmac(secret, te.encode('hwc-mac'));
const head = box.slice(0, box.length - 32); // version, iv and ciphertext
const tag = await hmac(macKey, head);
let diff = 0; // compare every byte (no early exit)
for (let i = 0; i < 32; i++) diff |= tag[i] ^ box[head.length + i];
if (diff) throw new Error('wrong key');
const aes = await crypto.subtle.importKey('raw', encKey, 'AES-CBC', false, ['decrypt']);
const iv = head.slice(1, 17);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, aes, head.slice(17)));
}
The decrypted card
Numbers here are big-endian. QR codes and barcodes are stored ready to draw, as black and white dots.
1 byte version (1 or 2)
1 + n name (UTF-8, length first)
1 + n card number (UTF-8, length first; show it under the code)
3 bytes card colour (RGB)
1 byte code kind: 0 none, 1 QR code, 2 barcode
2 + 2 code width and height (height is 1 for a barcode)
... width × height dots, 8 per byte, row by row, highest bit first, 1 = black
1 + 1 logo width and height (both 0 = no logo, and the next three fields are absent)
1 + 3n palette: number of colours n (1 to 16), then n RGB colours
1 byte logo encoding (version 2 only; version 1 is always raw)
... logo pixels, one palette index each:
raw (0): two pixels per byte, high half first
run-length (1): byte count (2), then one byte per run: (run length − 1) × 16 + index
1 + n barcode type, e.g. "QR" or "EAN13" (may be missing on old cards)
1 + n the code's text (same)
1 byte brand id (optional)
Draw the code with each dot as a square (QR) or a bar (barcode), on white with a quiet margin. A card with a
brand id uses a well-known logo instead of storing one; the list (id, name, colour, logo) is at
/lib/brand-data.js. If you don't know an id, show the card
without a logo.
Keeping the cards up to date
- Read the vault now and then (when the app opens, or every few days). If the revision changed, fetch the new card ids and drop the ones no longer listed.
- Paired devices only: remember the wrapped key's IV (its bytes 1 to 17) the first time. If it changes, the user changed their key to remove a device. Read your pairing record again: if its grant still opens for you and holds a new key, use that key. If not, your device was removed: forget the key and the cards and offer to pair again.
- Wallet apps only: a few early vaults open with an older unlock message instead. If the message
above does not open the wrapped key, try this one:
'HandWatchCards: unlock my membership cards (v1)\n\n' + 'This signature is used as your encryption key. It costs nothing. ' + 'Only sign this message on the HandWatchCards app.'
Rules
- Never store or send the wallet signature, the KEK or the pairing secret. Keep the data key on the device only.
- Only trust accounts owned by the program id that start with the right tag.
- Refuse version numbers you don't know (box, card, pairing link) instead of guessing.
- To add, edit or delete cards, send users to the BlockchainCards web app.