Store receipts
Every Store purchase mints a receipt in the same transaction as the payment: a 1-sat SVG inscription in the buyer’s wallet. The receipt JSON is inside the SVG’s <metadata> and in the inscription’s MAP fields.
Format
Section titled “Format”type StoreReceipt = { app: 'bwalletx'; type: 'store-receipt'; v: 1; order: string; // the Store's order reference; also in the payment's OP_RETURN item: { kind: string; id: string; label: string }; // e.g. { kind: 'theme', id: 'npg', label: 'NPG' } usd: number; sats: number; seller: string; // the address the Store is paid at buyer: string; // the buyer's identity key issuedAt: string; signer?: string; // the Store's public key sig?: string; // its signature over everything else};Signature
Section titled “Signature”ECDSA on secp256k1 (low-S, DER hex) over SHA-256 of the receipt’s canonical JSON: object keys sorted at every level, undefined fields dropped, without sig.
The Store’s public key is published at:
curl https://pay.bwallet.space/api/theme-orders/store-key# {"signer":"03c6…"}Verifying a receipt
Section titled “Verifying a receipt”A receipt counts as Verified ✓ only when all four hold:
sigis valid forsignerover the canonical JSON.signerequals the published Store key.- The same transaction pays
sellerat leastsats. - The same transaction carries the
orderreference.
A picture alone proves nothing (anyone can inscribe one). The checks against the transaction are what count.
The order reference is an OP_FALSE OP_RETURN output with two pushes: bwx-theme-buy and the order id as text.
A complete verifier
Section titled “A complete verifier”node verify-receipt.mjs <txid> (needs @bsv/sdk). It reads the purchase transaction from WhatsOnChain and runs all four checks:
// Verify a bWalletX Store receipt end to end: the Store's signature, its published key, and the payment.import { P2PKH, PublicKey, Script, Signature, Transaction, Utils } from '@bsv/sdk';
const txid = process.argv[2];const hex = (await fetch(`https://api.whatsonchain.com/v1/bsv/main/tx/${txid}/hex`).then((r) => r.text())).trim();const tx = Transaction.fromHex(hex);
// The receipt JSON travels in the 1-sat inscription output (in the MAP and in the SVG's <metadata>).// Take the first JSON object that starts with {"app":"bwalletx" and has type store-receipt.const findJson = (text) => { for (let i = text.indexOf('{"app":"bwalletx"'); i >= 0; i = text.indexOf('{"app":"bwalletx"', i + 1)) { let depth = 0; for (let j = i; j < text.length; j++) { if (text[j] === '{') depth++; else if (text[j] === '}' && --depth === 0) { try { const v = JSON.parse(text.slice(i, j + 1)); if (v.type === 'store-receipt') return v; } catch {} break; } } }};let receipt;for (const o of tx.outputs) { receipt = findJson(Buffer.from(o.lockingScript.toBinary()).toString('utf8')); if (receipt) break;}if (!receipt) throw new Error('No store receipt in this transaction');
// 1. Signature: ECDSA (secp256k1, sha256) by `signer` over canonical JSON of the receipt minus `sig`.const canonical = (v) => Array.isArray(v) ? `[${v.map(canonical).join(',')}]` : v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}` : JSON.stringify(v);const { sig, ...body } = receipt;const signed = PublicKey.fromString(receipt.signer).verify(Utils.toArray(canonical(body), 'utf8'), Signature.fromDER(sig, 'hex'));
// 2. The signer is the key the Store publishes.const { signer } = await fetch('https://pay.bwallet.space/api/theme-orders/store-key').then((r) => r.json());
// 3. The same transaction pays the seller at least the price.const lock = new P2PKH().lock(receipt.seller).toHex();const paid = tx.outputs.some((o) => o.lockingScript.toHex() === lock && o.satoshis >= receipt.sats);
// 4. The same transaction carries the order reference: OP_FALSE OP_RETURN 'bwx-theme-buy' <order>.const memo = tx.outputs.some((o) => { const ret = o.lockingScript.chunks.find((c) => c.op === 0x6a && c.data?.length); // bytes after OP_RETURN if (!ret) return false; const d = Script.fromBinary(ret.data).chunks.filter((c) => c.data?.length).map((c) => Buffer.from(c.data).toString('utf8')); return d[0] === 'bwx-theme-buy' && d[1] === receipt.order;});
console.log(`${receipt.item.label} (${receipt.item.kind}) $${receipt.usd} = ${receipt.sats} sats, order ${receipt.order.slice(0, 12)}…`);console.log('Store signature valid:', signed);console.log('signed by the published Store key:', receipt.signer === signer);console.log('transaction pays the seller the price:', paid);console.log('transaction carries the order reference:', memo);console.log(signed && receipt.signer === signer && paid && memo ? 'Verified receipt ✓' : 'NOT verified');We ran it on the first Store purchase made with a receipt (17 Oct 2026, from a test build):
$ node verify-receipt.mjs 2fc4f31b1d1273cd3bd7f6a55e475a5b055296c95d7bf5897416570b77391758NPGX (theme) $1 = 5449092 sats, order a030c44f8e35…Store signature valid: truesigned by the published Store key: truetransaction pays the seller the price: truetransaction carries the order reference: trueVerified receipt ✓Two details that trip people up: the SDK keeps everything after OP_RETURN as one chunk, so parse it again with Script.fromBinary to get the pushes; and OP_FALSE shows up as an empty data chunk, so skip empty chunks.
Status
Section titled “Status”- ✓ Signing key live on the Store server
- ✓ First receipt on chain, verified end to end (tx
2fc4f31b…, 17 Oct 2026) - ◻ Receipts in the wallet for everyone (next release)