Skip to content

Verify a payment ​

A purchase from a Chain Daddy store is a payment on a public chain, from the buyer's wallet straight to the seller's. You can prove it happened three ways, and you never have to take our word for it:

WayWhat it provesTrusts
The webhook signatureThe event came from Chain DaddyUs
The receipt (a signed JWS)Chain Daddy confirmed this order for this walletUs
The chain (this page)The buyer paid exactly this order, to exactly these addressesNobody

Use the webhook to be fast and the chain to be sure. A server that hands over something valuable should check the chain before it does.

What you need: the order's verification block ​

Every order that touches a chain carries it, on GET /api/v2/iap/orders/{id} and in the response that created the order:

json
"verification": {
  "kind": "evm",
  "chainId": "eip155:8453",
  "checkout": "0x…",
  "feeAddress": "0x…",
  "order": {
    "orderId": "0x9e1f…",
    "token": "0x…",
    "payees": ["0x…", "0x…"],
    "amounts": ["975000", "25000"],
    "deadline": 1791234567
  },
  "confirmations": 5
}
  • checkout is the contract the order is paid through. Once the order is paid it is the contract that emitted the payment, so it never changes afterwards.
  • order is the exact struct the buyer's wallet signed for: who is paid (payees) and how much each (amounts, in the token's smallest unit). token is the zero address for the chain's own coin.
  • confirmations is how deep we wait before calling the order confirmed.
  • A test order has no verification block: it never touches a chain.

Before you trust the block, compare it with what you sold: payees[0] is your payout address and amounts[0] your share; feeAddress, when present, is the next payee, and any payee after that is a wallet the item splits its sales with.

With the helper ​

On EVM chains, @chaindaddy/js/verify does the whole check in one call. It has no dependencies, it never talks to Chain Daddy, and it runs anywhere fetch does (Node 20 or later).

sh
npm install @chaindaddy/js
javascript
// verify-order.mjs    run: node verify-order.mjs 0x9e1f…
import { verifyPayment, VerifyError } from '@chaindaddy/js/verify';

// What you sold, from YOUR OWN records. Never copy these from the order you are checking.
const SALE = {
  chainId: 'eip155:8453',             // the chain your store is on
  token: process.env.COIN_ADDRESS,    // the coin you are paid in (its contract address)
  payee: process.env.PAYOUT_ADDRESS,  // your payout address
  amount: 975000n,                    // the least it must receive, in the coin's smallest unit
};

const orderId = process.argv[2];
const res = await fetch(`https://api.chaindaddy.io/api/v2/iap/orders/${orderId}`, {
  headers: { Authorization: `Bearer ${process.env.CHAINDADDY_IAP_KEY}` },
});
const { verification } = await res.json();

try {
  const result = await verifyPayment(verification, {
    rpcUrl: process.env.RPC_URL,      // your own JSON-RPC endpoint for that chain, not ours
    expect: { orderId, ...SALE },
  });
  console.log(result.paid ? `paid by ${result.payer}` : 'not paid yet');
} catch (e) {
  if (!(e instanceof VerifyError)) throw e;
  console.error(`could not verify (${e.code}): ${e.message}`);
}

result.paid is true only when the checkout contract itself says this exact order was paid, result.depth blocks deep. false means not yet: ask again later. Everything else is a VerifyError, never a "paid".

expect is the part that protects you, and it is required. The block comes from an API. A block that someone has tampered with can describe a real payment of a different order: a cheaper item, another seller's sale, the same sale on a test network. So the helper refuses any block that is not the order (orderId), the chain (chainId), the asset (token, or NATIVE_TOKEN for the chain's own coin), and at least the amount to your payee that you say you sold. Take all five from your own records. For amount, start from your price and take off the store fee and any split you set. Pass it as a bigint or a decimal string; a JavaScript number is refused, because it cannot hold a large amount exactly.

The helper also refuses a checkout that is not one of Chain Daddy's checkout contracts on that chain. The list ships inside the package, so a tampered block cannot point the check at a contract that answers "paid" to everything. When we deploy a new checkout, a new version of the package carries it: if you see unknown_checkout on a fresh order, update @chaindaddy/js.

e.codeMeaning
expectation_mismatchThe block is not the order, asset, payout address or amount you expect. Do not deliver
unknown_checkoutThe block names a contract that is not a Chain Daddy checkout on that chain
wrong_chainThe block, your expect.chainId and your RPC endpoint are not all the same chain
bad_verificationThe block is missing or malformed (a test order has none)
bad_optionsSomething you passed is missing or malformed
rpc_errorYour RPC endpoint failed, or answered something that is not an answer. Try again
unsupportedA Solana order: the helper does not check those yet, so follow the steps below

How deep it reads. The block's confirmations also arrives from an API, so the helper does not rely on it alone: the package carries the depth our own server waits on each chain (MIN_CONFIRMATIONS), and the helper reads at the deepest of that, the block's number, and yours. Pass confirmations: 30 to wait longer; a smaller number changes nothing.

Your own client. request takes the place of rpcUrl when you already have one: request: client.request with viem, or any function shaped like ({ method, params }) => result.

The rest of this page is what the helper does, step by step, so you can do it in any language.

EVM: ask the contract ​

The checkout contract keeps one fact per order: who paid it. Two read-only calls give you the answer.

  1. hashOrder(order) on checkout returns the order's hash. It is an EIP-712 digest bound to that contract's address and chain, so the same order hashes differently anywhere else. Always call the checkout the order names, never an address you looked up yourself.
  2. paidBy(hash) returns the wallet that paid, or the zero address if nobody has.

Ask at a block confirmations deep, so a reorganised block cannot fool you:

javascript
import { createPublicClient, http, zeroAddress } from 'viem';
import { base } from 'viem/chains';

const abi = [
  { type: 'function', name: 'hashOrder', stateMutability: 'view',
    inputs: [{ name: 'o', type: 'tuple', components: [
      { name: 'orderId', type: 'bytes32' }, { name: 'token', type: 'address' },
      { name: 'payees', type: 'address[]' }, { name: 'amounts', type: 'uint256[]' },
      { name: 'deadline', type: 'uint256' }] }],
    outputs: [{ type: 'bytes32' }] },
  { type: 'function', name: 'paidBy', stateMutability: 'view',
    inputs: [{ type: 'bytes32' }], outputs: [{ type: 'address' }] },
];

// `v` is the order's verification block. Use your own RPC, not ours.
async function paidOnChain(v) {
  const client = createPublicClient({ chain: base, transport: http(process.env.RPC_URL) });
  const order = {
    orderId: v.order.orderId, token: v.order.token, payees: v.order.payees,
    amounts: v.order.amounts.map(BigInt), deadline: BigInt(v.order.deadline),
  };
  const head = await client.getBlockNumber();
  const blockNumber = head - BigInt(v.confirmations) + 1n;   // `confirmations` deep
  const hash = await client.readContract({ address: v.checkout, abi, functionName: 'hashOrder', args: [order] });
  const payer = await client.readContract({ address: v.checkout, abi, functionName: 'paidBy', args: [hash], blockNumber });
  return payer !== zeroAddress ? payer : null;
}

A non-zero answer means: this exact order (these payees, these amounts, this token) was paid in full through that contract, by that wallet, at least confirmations blocks ago. The contract moves the tokens from the payer to every payee inside the same call, and reverts unless all of it succeeds, so there is nothing else to check.

Or read the payment's log. If you have the transaction hash (txHash on the order), its receipt holds an OrderPaid(bytes32 orderId, address payer, address token, uint256 total, address[] payees, uint256[] amounts) event. Accept it only if the log's address is checkout, orderId is your order's id, and payees and amounts equal the block's. Anyone can emit an event with that name from their own contract, so the address check is the one that matters.

Tokens that take a cut on transfer deliver less than amounts says. The contract records what the order sends, not what arrives.

Solana: find the transfer ​

A Solana order is paid by an ordinary transfer that carries two markers. The helper does not check Solana orders yet, so these steps are the way:

json
"verification": {
  "kind": "solana",
  "cluster": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
  "reference": "B7t…",
  "memo": "cdiap:0x9e1f…"
}
  1. getSignaturesForAddress(reference, { commitment: 'finalized' }) lists the transactions that name the order's reference key. There should be one.
  2. getTransaction(signature, { commitment: 'finalized' }): check that it succeeded (meta.err is null), that its memo is exactly memo, and that your payout address's balance rose by the order's payoutAmount: compare meta.preTokenBalances with meta.postTokenBalances for the order's currency mint, or preBalances with postBalances when the order is paid in SOL.

The reference key is the order id's 32 bytes read as an address. Nobody holds a key for it; it exists only so the payment can be found.

Which to use ​

  • Delivering something a buyer could resell or cannot give back: check the chain, then deliver.
  • Updating a balance you can correct later: the signed webhook is enough; reconcile from the event feed.
  • A buyer says they paid and your server never heard: read the order (GET /api/v2/iap/orders/{id}), then run the check above. If the chain says paid, it is paid, whatever any API says.