<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Wen]]></title><description><![CDATA[Wen]]></description><link>https://wen-cat.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 05:48:51 GMT</lastBuildDate><atom:link href="https://wen-cat.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[One reference key per order: matching Solana Pay payments without guesswork]]></title><description><![CDATA[One reference key per order: matching Solana Pay payments without guesswork]]></description><link>https://wen-cat.hashnode.dev/one-reference-key-per-order-matching-solana-pay-payments-without-guesswork</link><guid isPermaLink="true">https://wen-cat.hashnode.dev/one-reference-key-per-order-matching-solana-pay-payments-without-guesswork</guid><category><![CDATA[Solana]]></category><category><![CDATA[Blockchain]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Web3]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Wen]]></dc:creator><pubDate>Mon, 26 Oct 2026 20:05:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad744d895a33a036ffd5746/b5900d26-b576-420c-a896-169c82f79f83.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A small shop sells a hand-made cat plush online. A customer scans a QR code and pays. Now the shop has to answer one boring question: <strong>which order was that?</strong></p>
<p>Card payments solve this with an acquirer that calls the shop back. On Solana, a shop can do the same job itself, with no middleman and no customer data, using one idea from <strong>Solana Pay</strong>: a fresh, random <strong>reference key</strong> for every order.</p>
<p>This walkthrough comes from Wen Open Lab, a free set of models built by the Wen community to show what open Solana tools can do for ordinary people and small businesses.</p>
<blockquote>
<p>⚠️ Educational example, not audited, not a payment product. Use devnet and a new, empty wallet. Real payments have tax, consumer-law and licensing rules that code does not replace.</p>
</blockquote>
<h2>The idea in four steps</h2>
<ol>
<li><strong>Create</strong> a payment link for the order, with a unique random <code>reference</code>.</li>
<li><strong>Show</strong> it as a link or QR code. The customer's wallet adds the reference to the transfer.</li>
<li><strong>Find</strong> the transaction on-chain by that reference.</li>
<li><strong>Validate</strong> recipient, amount, token, reference and memo, then mark the order paid.</li>
</ol>
<p>The reference is a public key that nobody signs with. It rides along in the transaction as a read-only account, so the chain can be searched by it. It identifies the order without saying anything about the customer.</p>
<h2>Step 1: a payment request per order</h2>
<pre><code class="language-ts">export async function createPaymentRequest(orderId: string, recipient: Address, amount: number, splToken?: Address) {
  const reference = (await generateKeyPairSigner()).address;   // random, never reused
  const url = encodeURL({
    recipient, amount, splToken, reference,
    label: 'Wen Open Lab demo shop',
    message: `Order ${orderId}`,
    memo: `order:${orderId}`,
  });
  return { orderId, recipient, amount, splToken, reference, url: url.toString() };
}
</code></pre>
<ul>
<li><code>splToken</code> omitted → the payment is in SOL; set → the payment is in that SPL token.</li>
<li><code>memo</code> puts the order number on-chain. It is <strong>public</strong>: an order number, never a name or an address.</li>
<li>The result is a <code>solana:</code> URL. Any QR library can turn it into a code for a checkout page or a printed receipt.</li>
</ul>
<h2>Step 2: wait, find, validate</h2>
<pre><code class="language-ts">export async function waitForPayment(req: PaymentRequest, opts: { rpcUrl: string; webhook: string; timeoutMs?: number }) {
  const rpc = createSolanaRpc(opts.rpcUrl);
  const deadline = Date.now() + (opts.timeoutMs ?? 5 * 60_000);
  while (Date.now() &lt; deadline) {
    try {
      const found = await findReference(rpc, req.reference, { commitment: 'confirmed' });
      // throws if recipient, amount, token, reference or memo do not match
      await validateTransfer(rpc, found.signature, {
        recipient: req.recipient, amount: req.amount, splToken: req.splToken,
        reference: req.reference, memo: `order:${req.orderId}`,
      }, { commitment: 'confirmed' });
      await fetch(opts.webhook, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ orderId: req.orderId, status: 'paid', signature: found.signature }),
      });
      return found.signature;
    } catch (e) {
      if (!(e instanceof FindReferenceError)) throw e;   // not found yet: keep waiting
      await new Promise(r =&gt; setTimeout(r, 1500));
    }
  }
  throw new Error(`Order ${req.orderId}: payment not found before timeout`);
}
</code></pre>
<p>Two details matter more than they look:</p>
<ul>
<li><strong>Finding is not trusting.</strong> <code>findReference</code> only says "a transaction mentions this key". <code>validateTransfer</code> checks that the money went to the right wallet, in the right token, for the right amount, with the right memo. Skipping validation would let anyone "pay" with a tiny transfer that carries the reference.</li>
<li><strong>Only "not found yet" is retried.</strong> Any other error, including a failed validation, stops the loop instead of being silently ignored.</li>
</ul>
<p>The webhook call plays the role of an acquirer's callback: the shop's own order system hears "order A-1001 is paid, here is the signature", and the signature is the receipt anyone can look up in an explorer.</p>
<h2>What the tests prove</h2>
<p>The example repository runs its checks locally, with no network and no funds:</p>
<pre><code class="language-text">PASS  M4 payment link round-trips: recipient, amount, token, reference, memo
PASS  M4 every order gets a unique reference
</code></pre>
<p>The first check builds a link, parses it back and confirms that nothing was lost or changed. The second confirms that two orders never share a reference. The watcher itself needs a live cluster, so try it on <strong>devnet</strong>: create a request with your devnet address as the recipient, pay it from a second devnet wallet that supports Solana Pay links, and watch the loop find and validate the payment.</p>
<h2>Why this is useful to people, not only to developers</h2>
<ul>
<li><strong>Small sellers</strong> can reconcile payments without a processor account and without matching amounts by hand.</li>
<li><strong>Customers</strong> give no card number and no personal data to the shop's server; the receipt is a public signature.</li>
<li><strong>Bookkeeping</strong> gets a clean link between an order number and a transaction that cannot be edited later.</li>
</ul>
<h2>Limits worth stating plainly</h2>
<ul>
<li>Prices move if the shop accepts a volatile token. A shop that needs stable amounts should accept a stable asset; the model does not convert anything.</li>
<li>Refunds, disputes and taxes are not in the code. They are the shop's responsibility, as with any payment method.</li>
<li>Polling every 1.5 seconds is fine for a demo. A busy shop needs its own RPC provider and limits.</li>
<li><strong>Not audited, not a product.</strong> It is a model for learning.</li>
</ul>
<hr />
<p>The code (Apache-2.0), the tests and the other Token-2022 models (a soulbound warranty card, creator royalties, B2B invoices) are open in one place: <a href="https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code"><strong>https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code</strong></a></p>
]]></content:encoded></item><item><title><![CDATA[A payment that refuses to arrive without an invoice number]]></title><description><![CDATA[Every accountant knows the problem: money arrives, but nobody can say which invoice it pays. The bank statement says "transfer", the reference field is…]]></description><link>https://wen-cat.hashnode.dev/a-payment-that-refuses-to-arrive-without-an-invoice-number</link><guid isPermaLink="true">https://wen-cat.hashnode.dev/a-payment-that-refuses-to-arrive-without-an-invoice-number</guid><category><![CDATA[Solana]]></category><category><![CDATA[Web3]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Wen]]></dc:creator><pubDate>Tue, 20 Oct 2026 10:56:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad744d895a33a036ffd5746/f89db4d2-8942-4e77-8ccf-b55c60ece82d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every accountant knows the problem: money arrives, but nobody can say which invoice it pays. The bank statement says "transfer", the reference field is empty, and someone spends the afternoon matching amounts by hand.</p>
<p>On Solana, the <strong>Token-2022</strong> program can make the <em>receiving account itself</em> refuse any payment that does not carry an invoice number. Combined with two other built-in extensions, a plain token gets rules that a B2B settlement needs, and no custom on-chain program is required:</p>
<ol>
<li><strong>Required Memo</strong> (account extension): incoming transfers must be preceded by a memo instruction, so no invoice number means no payment.</li>
<li><strong>Default Account State = Frozen</strong> (mint extension): every new account starts frozen, and only the issuer can thaw it. Thawing = approving a counterparty after off-chain checks.</li>
<li><strong>Transfer Fee</strong> (mint extension): a percentage fee with a hard cap, withheld by the token program itself.</li>
</ol>
<p>Everything below is an educational model, tested in a local VM and then on <strong>devnet</strong>. It is not a stablecoin and not a payment product. A real settlement token is issued by a licensed entity.</p>
<blockquote>
<p>⚠️ Not audited. Devnet only, with a new, empty wallet.</p>
</blockquote>
<h2>Step 1: the settlement mint</h2>
<pre><code class="language-ts">export const DECIMALS = 6;
export const FEE_BPS = 25;                               // 0.25%
export const FEE_CAP = 50n * 10n ** BigInt(DECIMALS);    // never more than 50 units

const space = getMintLen([ExtensionType.TransferFeeConfig, ExtensionType.DefaultAccountState]);

const ixs = [
  SystemProgram.createAccount({ fromPubkey: issuer, newAccountPubkey: mint,
    space, lamports: rent, programId: TOKEN_2022_PROGRAM_ID }),
  createInitializeTransferFeeConfigInstruction(mint, issuer, issuer, FEE_BPS, FEE_CAP, TOKEN_2022_PROGRAM_ID),
  createInitializeDefaultAccountStateInstruction(mint, AccountState.Frozen, TOKEN_2022_PROGRAM_ID),
  createInitializeMintInstruction(mint, DECIMALS, issuer, /* freeze authority */ issuer, TOKEN_2022_PROGRAM_ID),
];
</code></pre>
<p>The detail that matters: the issuer <strong>keeps the freeze authority</strong>. Without it, "frozen by default" would freeze accounts forever; with it, the issuer becomes the gatekeeper of who may hold the token.</p>
<h2>Step 2: a company account that demands an invoice number</h2>
<pre><code class="language-ts">const space = getAccountLen([ExtensionType.TransferFeeAmount, ExtensionType.MemoTransfer]);

const ixs = [
  SystemProgram.createAccount({ fromPubkey: company, newAccountPubkey: account,
    space, lamports: rent, programId: TOKEN_2022_PROGRAM_ID }),
  createInitializeAccount3Instruction(account, mint, company, TOKEN_2022_PROGRAM_ID),
  createEnableRequiredMemoTransfersInstruction(account, company, [], TOKEN_2022_PROGRAM_ID),
];
</code></pre>
<p><code>TransferFeeAmount</code> must be in the account size because fee-bearing mints withhold the fee <em>in the recipient account</em>. <code>MemoTransfer</code> is the switch that the seller turns on for its own account. The rule belongs to the receiver, not to the sender's app.</p>
<h2>Step 3: approve a counterparty</h2>
<pre><code class="language-ts">createThawAccountInstruction(account, mint, issuer, [], TOKEN_2022_PROGRAM_ID);
</code></pre>
<p>One instruction. The checks behind it (company registration, contract and so on) stay off-chain; the chain records only the outcome: this account may now send and receive.</p>
<h2>Step 4: pay the invoice, memo first</h2>
<pre><code class="language-ts">const SPL_MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr');

const memo = new TransactionInstruction({
  programId: SPL_MEMO_PROGRAM_ID,
  keys: [{ pubkey: payer, isSigner: true, isWritable: false }],
  data: Buffer.from(`invoice:${invoiceId}`, 'utf8'),
});

const fee = (amount * BigInt(FEE_BPS)) / 10_000n;
const expectedFee = fee &gt; FEE_CAP ? FEE_CAP : fee;

const ixs = [
  memo,   // must be IMMEDIATELY before the transfer
  createTransferCheckedWithFeeInstruction(from, mint, to, payer, amount,
    DECIMALS, expectedFee, [], TOKEN_2022_PROGRAM_ID),
];
</code></pre>
<p>Two traps we hit, so you don't have to:</p>
<ul>
<li><strong>The memo must be the instruction right before the transfer.</strong> Token-2022 looks at the previous instruction in the transaction, not "somewhere in the transaction".</li>
<li><strong>Use the canonical Memo program ID.</strong> Token-2022 checks for <code>MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr</code>. Building the memo instruction by hand, as above, avoids depending on whichever ID a helper library defaults to.</li>
</ul>
<p><code>TransferCheckedWithFee</code> also asks you to state the fee you expect. If your number differs from what the mint computes, the transfer fails. That keeps the payer's software honest about the fee.</p>
<h2>What the tests prove</h2>
<p>The model runs inside <strong>LiteSVM</strong> (a local Solana VM: no network, no funds). The log of the last run:</p>
<pre><code class="language-text">PASS  M3 settlement mint created (fee 0.25% cap 50, frozen by default)
PASS  M3 seller account requires an invoice memo
PASS  M3 issuer funds the buyer account
PASS  M3 payment WITHOUT invoice number is rejected
        — Program log: Error: No memo in previous instruction required for recipient to receive a transfer
PASS  M3 payment to a NOT approved company is rejected (account frozen)
        — Program log: Error: Account is frozen
PASS  M3 invoice INV-2026-0042 paid with memo to approved company
PASS  M3 invoice number is recorded on-chain (memo in logs)
PASS  M3 seller received amount minus fee; fee withheld for the platform — received 997.5, fee 2.5
PASS  M3 fee cap works (fee on 100,000 is 50, not 250)
</code></pre>
<p>The two rejections are the interesting lines: they come from the token program itself, so no wallet, app or front end can skip them.</p>
<h2>Run it yourself</h2>
<pre><code class="language-bash">solana-keygen new -o devnet-wallet.json            # new throwaway wallet
solana airdrop 2 $(solana-keygen pubkey devnet-wallet.json) --url devnet
npm install
npm test                                           # LiteSVM, local
KEYPAIR=./devnet-wallet.json npm run devnet        # devnet, prints Explorer links
</code></pre>
<p>The devnet script creates the mint, opens two accounts, approves both, pays <code>INV-DEVNET-0001</code>, then tries a payment without a memo and confirms that it was rejected. Every step prints a Solana Explorer link with <code>?cluster=devnet</code>, so the invoice number can be read straight from the transaction logs.</p>
<h2>Limits worth stating plainly</h2>
<ul>
<li><strong>A memo is public.</strong> Put an invoice <em>number</em> in it, never names, amounts in words or anything personal.</li>
<li><strong>The issuer holds real power.</strong> It can thaw, and with the freeze authority it can also freeze. That is the point of a permissioned rail, and also the reason it needs a trustworthy, regulated issuer.</li>
<li><strong>Withheld fees still have to be collected.</strong> The fee sits in recipient accounts until the withdraw authority harvests it; the model does not include that step.</li>
<li><strong>Not audited, not a product.</strong> It is a model for learning how far built-in token rules can go.</li>
</ul>
<hr />
<p>The code (Apache-2.0), the LiteSVM tests and the other Token-2022 models (a soulbound warranty card, creator royalties, a payment link bridge) are open in one place:
<strong><a href="https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code">https://github.com/wen-watt/wen-watt.github.io/tree/main/lab/code</a></strong></p>
]]></content:encoded></item></channel></rss>