Skip to content

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.

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
};

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:

Terminal window
curl https://pay.bwallet.space/api/theme-orders/store-key
# {"signer":"03c6…"}

A receipt counts as Verified ✓ only when all four hold:

  1. sig is valid for signer over the canonical JSON.
  2. signer equals the published Store key.
  3. The same transaction pays seller at least sats.
  4. The same transaction carries the order reference.

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.

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 2fc4f31b1d1273cd3bd7f6a55e475a5b055296c95d7bf5897416570b77391758
NPGX (theme) $1 = 5449092 sats, order a030c44f8e35…
Store signature valid: true
signed by the published Store key: true
transaction pays the seller the price: true
transaction carries the order reference: true
Verified 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.

  • ✓ 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)