# BlockchainCards: guide for an AI coding assistant

<!--
  Give this whole file to your AI assistant (ChatGPT, Claude, Copilot, ...) together with
  what you want to build, for example: "Using this guide, show the user's BlockchainCards
  in my app". Everything needed is in this one file.
-->

You are helping a developer show a user's **BlockchainCards** (membership and loyalty
cards with a QR code or barcode) inside the developer's own app. Users add and edit their
cards in the BlockchainCards web app (https://blockchaincards.xyz/); the developer's app
only **reads** them. Use only the facts in this file. Copy addresses and message texts
exactly; never invent offsets or texts. If the developer asks for something not covered
here (for example adding or editing cards), say that users do that in the BlockchainCards
web app.

The human-readable version is at https://blockchaincards.xyz/protocol/.

---

## 1. How it works

- The cards live on the Solana blockchain (mainnet), in one program. There is no server
  and no API key: the app reads Solana accounts through any RPC server. Reading is free.
- Each wallet has one **vault** (its list of card ids) and one **card account** per card.
  Their addresses are computed from the wallet address.
- Every card is encrypted with the user's 32-byte **data key**.

Three steps: (1) get the data key, (2) read the encrypted cards, (3) decrypt and draw.

| Constant | Value |
|---|---|
| Program id | `8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4` |
| Authority address | `6jmedw2T7sZ8aWHY47bG9em7guA2i91psVrn9pHpkgA9` |
| Public RPC example | `https://api.mainnet-beta.solana.com` |
| Account tags (first 8 bytes) | vault `hwcvault`, card `hwccard1`, pairing record `hwcdevic` |

## 2. Step 1: get the data key (pick A or B)

### A. The app has a Solana wallet

```
signature = wallet.signMessage(utf8(UNLOCK_MESSAGE))     64 bytes
KEK       = HMAC-SHA256(key = "hwc-vault-v1", message = signature)
dataKey   = openBox(KEK, vault.wrappedKey)                 32 bytes
```

UNLOCK_MESSAGE must be exactly (UTF-8, two line breaks after the first line, none at the end):

```js
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.';
```

If it does not open the wrapped key, the vault is an early one: try this older text instead.

```js
const UNLOCK_MESSAGE_V1 =
  '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.';
```

### B. No wallet (watch, TV, kiosk): pair the device

1. Make a P-256 ECDH key pair and 16 random bytes (the secret). Store both.
2. Show the user this link as a QR code:
   `https://blockchaincards.xyz/?v=1&pub=<hex of x||y, 64 bytes>&cluster=mainnet&program=8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4#s=<hex of the secret>`
3. The user opens it, connects their wallet and taps Pair. Meanwhile poll the pairing
   record at `deviceAddress(sha256(x||y))` (section 3) every few seconds.
4. Open the record's grant:
   ```
   x       = ECDH(device private key, grant[0..64] as public key x||y).x
   S       = HMAC-SHA256(key = "hwc-device-v1", message = x)
   content = openBox(S, grant[64..])                     -> 48 bytes
   dataKey = content[0..32]
   accept only if content[32..48] == HMAC-SHA256(key = secret, message = record.owner bytes)[0..16]
   ```
5. Store dataKey, the owner wallet address, the key pair and the secret.

## 3. Step 2: read the encrypted cards

Addresses (Solana `PublicKey.createWithSeed`, base = authority address, owner = program id):

```
seed(parts)         = lowercase hex of sha256(parts)[0..16]      (32 characters)
vault of wallet W   = createWithSeed(authority, seed("hwc:vault"  || W), programId)
card N of wallet W  = createWithSeed(authority, seed("hwc:card"   || W || u32 little-endian N), programId)
pairing record      = createWithSeed(authority, seed("hwc:device" || deviceId), programId)
```

Layouts (little-endian). "4 + k" = u32 length then k bytes.

- **Vault:** tag `hwcvault` (8) | owner (32) | revision u64 | next card id u32 | prepaid
  saves u32 | wrapped key (4 + k) | card ids: u32 count, then u32 each, in display order |
  more data to ignore.
- **Card:** tag `hwccard1` (8) | owner (32) | id u32 | length L u16 | chunks u8 |
  encrypted card = L bytes at offset 47.
- **Pairing record:** tag `hwcdevic` (8) | owner (32) | created i64 | grant (4 + g) |
  notes (ignore).

Rules: read with `commitment: "confirmed"`; trust an account only if its owner is the
program id AND it starts with the right tag. A card id always means the same card, so
cache by id. The vault's revision changes on every save: unchanged revision = nothing to
fetch.

## 4. Step 3: decrypt and draw

Box (wrapped key, cards, grant), made with a 32-byte secret S:

```
box    = 0x01 || iv (16) || AES-128-CBC ciphertext (PKCS#7) || tag (32)
encKey = HMAC-SHA256(S, "hwc-enc")[0..16]
macKey = HMAC-SHA256(S, "hwc-mac")
tag    = HMAC-SHA256(macKey, 0x01 || iv || ciphertext)
```
Check length >= 65, first byte 1 and the tag before decrypting.

Decrypted card (big-endian):

```
u8   version (1 or 2)
u8   name length, UTF-8 name
u8   number length, UTF-8 card number (show under the code)
u24  card colour RGB
u8   code kind: 0 none, 1 QR code, 2 barcode
u16  code width, u16 code height (1 for a barcode), then ceil(w*h/8) bytes of dots,
     row by row, most significant bit first, 1 = black
u8   logo width, u8 logo height (0 and 0 = no logo; the next 3 fields are then absent)
u8   palette size n (1..16), then n x u24 RGB
u8   logo encoding (version 2 only; version 1 is always raw):
     0 raw = ceil(w*h/2) bytes, 4-bit palette index per pixel, high nibble first
     1 run-length = u16 byte count, then one byte per run: (run length - 1) << 4 | index
optional: u8 length + barcode type text, u8 length + code text, u8 brand id
```

Draw each dot of the code as a square (QR) or a bar (barcode), black on white with a white
margin. A brand id means a well-known logo; the list (id, name, colour, logo in the same
run-length format) is at https://blockchaincards.xyz/lib/brand-data.js. Unknown id = no logo.

## 5. Keeping up to date

- Re-read the vault when the app opens (or every few days). New revision = fetch new card
  ids, drop ids no longer listed.
- Paired devices: remember `wrappedKey[1..17]` (its IV) at the first sync. If it changes,
  re-read the pairing record and open the grant again. A new key that passes the tag
  check = keep it. Otherwise the user removed this device: delete the key and cards and
  offer to pair again.

## 6. Rules

1. Never store, log or send the wallet signature, the KEK or the pairing secret. Keep the
   data key on the device only.
2. Copy UNLOCK_MESSAGE byte for byte.
3. Refuse unknown versions (box, card, pairing link) instead of guessing.
4. The app only reads. To add, edit or delete cards, send users to
   https://blockchaincards.xyz/.

## 7. Reference code (JavaScript, browser or Node 20+)

Tested. Needs `@solana/web3.js` (v1) and the built-in Web Crypto API.

```js
import { Connection, PublicKey } from '@solana/web3.js';

export const PROGRAM_ID = new PublicKey('8sDonRmJgkhq7wSrnwbEk488m2PVzAfHUyejmhssSRQ4');
const AUTHORITY = new PublicKey('6jmedw2T7sZ8aWHY47bG9em7guA2i91psVrn9pHpkgA9');
const te = new TextEncoder();
const subtle = globalThis.crypto.subtle;

export const UNLOCK_MESSAGES = [
  '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.',
  // An older text, for early vaults only.
  '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.',
];

// ---------- bytes
const concat = (...parts) => {
  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
  let at = 0;
  for (const p of parts) { out.set(p, at); at += p.length; }
  return out;
};
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const u32le = (n) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; };
const sameBytes = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);

// ---------- hashing and the box
const sha256 = async (data) => new Uint8Array(await subtle.digest('SHA-256', data));
async function hmac(key, data) {
  const k = await subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  return new Uint8Array(await subtle.sign('HMAC', k, data));
}
export 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);
  const tag = await hmac(macKey, head);
  let diff = 0;
  for (let i = 0; i < 32; i++) diff |= tag[i] ^ box[head.length + i];
  if (diff) throw new Error('wrong key');
  const aes = await subtle.importKey('raw', encKey, 'AES-CBC', false, ['decrypt']);
  return new Uint8Array(await subtle.decrypt({ name: 'AES-CBC', iv: head.slice(1, 17) }, aes, head.slice(17)));
}

// ---------- addresses
const seed = async (...parts) => toHex((await sha256(concat(...parts))).slice(0, 16));
const address = async (...parts) => PublicKey.createWithSeed(AUTHORITY, await seed(...parts), PROGRAM_ID);
export const vaultAddress = (wallet) => address(te.encode('hwc:vault'), wallet.toBytes());
export const cardAddress = (wallet, id) => address(te.encode('hwc:card'), wallet.toBytes(), u32le(id));
export const deviceAddress = (deviceId) => address(te.encode('hwc:device'), deviceId);

// ---------- reading accounts (info = a result of getAccountInfo, or null)
/** The account's bytes if the program owns it and it has the right tag, else null. */
function ours(info, tag) {
  if (!info || !info.owner.equals(PROGRAM_ID)) return null;
  const data = new Uint8Array(info.data);
  return sameBytes(data.subarray(0, 8), te.encode(tag)) ? data : null;
}
export function parseVault(info) {
  const data = ours(info, 'hwcvault');
  if (!data) return null;
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
  let at = 8;
  const owner = new PublicKey(data.slice(at, at + 32)); at += 32;
  const revision = view.getBigUint64(at, true); at += 8 + 4 + 4;   // skip next card id and prepaid saves
  const keyLength = view.getUint32(at, true); at += 4;
  const wrappedKey = data.slice(at, at + keyLength); at += keyLength;
  const count = view.getUint32(at, true); at += 4;
  const cards = [];
  for (let i = 0; i < count; i++, at += 4) cards.push(view.getUint32(at, true));
  return { owner, revision, wrappedKey, cards };
}
export function parseCard(info) {
  const data = ours(info, 'hwccard1');
  if (!data) return null;
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
  const length = view.getUint16(44, true);
  return { owner: new PublicKey(data.slice(8, 40)), id: view.getUint32(40, true), box: data.slice(47, 47 + length) };
}
export function parseDevice(info) {
  const data = ours(info, 'hwcdevic');
  if (!data) return null;
  const length = new DataView(data.buffer, data.byteOffset).getUint32(48, true);
  return { owner: new PublicKey(data.slice(8, 40)), grant: data.slice(52, 52 + length) };
}

// ---------- step 1A: unlock with a wallet
/** signMessage: (bytes) => Promise<64 signature bytes>, from the wallet adapter. */
export async function unlockWithWallet(connection, wallet, signMessage) {
  const vault = parseVault(await connection.getAccountInfo(await vaultAddress(wallet), 'confirmed'));
  if (!vault) return null;                                    // no cards saved yet
  for (const message of UNLOCK_MESSAGES) {
    const kek = await hmac(te.encode('hwc-vault-v1'), await signMessage(te.encode(message)));
    try { return await openBox(kek, vault.wrappedKey); } catch { /* try the older text */ }
  }
  throw new Error('This wallet cannot open these cards');
}

// ---------- step 1B: pair a device without a wallet
/** A new device identity and the link to show as a QR code. Store keys and secret. */
export async function startPairing() {
  const keys = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
  const pub = new Uint8Array(await subtle.exportKey('raw', keys.publicKey)).slice(1);  // x||y, 64 bytes
  const secret = crypto.getRandomValues(new Uint8Array(16));
  const link = `https://blockchaincards.xyz/?v=1&pub=${toHex(pub)}&cluster=mainnet` +
    `&program=${PROGRAM_ID.toBase58()}#s=${toHex(secret)}`;
  return { keys, secret, deviceId: await sha256(pub), link };
}
/** Polls once: returns { owner, dataKey } when the user has approved this device, else null. */
export async function checkPairing(connection, { keys, secret, deviceId }) {
  const record = parseDevice(await connection.getAccountInfo(await deviceAddress(deviceId), 'confirmed'));
  if (!record) return null;
  const dataKey = await openGrant(record, keys, secret);
  return dataKey ? { owner: record.owner, dataKey } : null;
}
async function openGrant(record, keys, secret) {
  try {
    const eph = await subtle.importKey('raw', concat([4], record.grant.slice(0, 64)),
      { name: 'ECDH', namedCurve: 'P-256' }, false, []);
    const x = new Uint8Array(await subtle.deriveBits({ name: 'ECDH', public: eph }, keys.privateKey, 256));
    const content = await openBox(await hmac(te.encode('hwc-device-v1'), x), record.grant.slice(64));
    const tag = (await hmac(secret, record.owner.toBytes())).slice(0, 16);
    return content.length === 48 && sameBytes(content.slice(32), tag) ? content.slice(0, 32) : null;
  } catch {
    return null;
  }
}

// ---------- steps 2 and 3: read, decrypt and decode every card
export async function readCards(connection, wallet, dataKey) {
  const vault = parseVault(await connection.getAccountInfo(await vaultAddress(wallet), 'confirmed'));
  if (!vault) return [];
  const addresses = await Promise.all(vault.cards.map((id) => cardAddress(wallet, id)));
  const infos = addresses.length ? await connection.getMultipleAccountsInfo(addresses, 'confirmed') : [];
  const cards = [];
  for (const info of infos) {
    const card = parseCard(info);
    if (card && card.owner.equals(wallet)) cards.push({ id: card.id, ...decodeCard(await openBox(dataKey, card.box)) });
  }
  return cards;   // in the user's order
}

export function decodeCard(bytes) {
  let at = 0;
  const td = new TextDecoder();
  const u8 = () => bytes[at++];
  const u16 = () => (u8() << 8) | u8();
  const u24 = () => (u8() << 16) | (u8() << 8) | u8();
  const text = () => { const n = u8(); const s = td.decode(bytes.slice(at, at + n)); at += n; return s; };
  const version = u8();
  if (version !== 1 && version !== 2) throw new Error('unknown card format ' + version);
  const card = { name: text(), number: text(), color: u24() };
  const kind = u8(), w = u16(), h = u16();
  const dots = [];
  for (let i = 0; i < w * h; i++) dots.push((bytes[at + (i >> 3)] >> (7 - (i & 7))) & 1);
  at += Math.ceil((w * h) / 8);
  card.code = { kind, w, h, dots };          // kind 1 = QR (w x h), 2 = barcode (w bars)
  const lw = u8(), lh = u8();
  card.logo = null;
  if (lw && lh) {
    const palette = [];
    for (let n = u8(); n > 0; n--) palette.push(u24());
    const pixels = [];
    if (version === 2 && u8() === 1) {       // run-length
      const end = u16() + at;
      for (; at < end; at++) for (let run = (bytes[at] >> 4) + 1; run > 0; run--) pixels.push(bytes[at] & 15);
    } else {                                 // raw, two pixels per byte
      for (let i = 0; i < lw * lh; i++) pixels.push((bytes[at + (i >> 1)] >> (i & 1 ? 0 : 4)) & 15);
      at += Math.ceil((lw * lh) / 2);
    }
    card.logo = { w: lw, h: lh, palette, pixels };   // pixels: palette indexes, row by row
  }
  if (at < bytes.length) { card.codeType = text(); card.codeText = text(); }
  if (at < bytes.length) card.brand = u8();
  return card;
}

// Example:
// const connection = new Connection('https://api.mainnet-beta.solana.com');
// const dataKey = await unlockWithWallet(connection, wallet.publicKey, (m) => wallet.signMessage(m));
// const cards = await readCards(connection, wallet.publicKey, dataKey);
```

Note: in a browser bundle, `@solana/web3.js` also needs the `buffer` package.
