Reference
Smart contract
The PaymentRouter contract moves USDT from the customer straight to your payout wallet, and back again for refunds.
Every Whitechain Commerce payment and refund is a call to one small, non-upgradeable contract. It records which order was paid, emits an event that links the transfer to the order, and never holds a token balance. This page lists its full interface and shows how to check any payment on the chain yourself.
Deployment
| Chain ID | 1874 (0x752) |
| PaymentRouter | 0xa99a6D97607567f4fe734831910D24f4e9BD0508 |
| Deploy transaction | 0x1d2827bf…c38b37 |
| Deploy block | 12373441 |
| Owner (backend signer) | 0xA8d17110550f389801470A0088e423C72C5A9c12 |
| Token | USDT 0xcedfB56113143d55b2358b8CFbB0dEB5Ad2f55a7, 6 decimals |
| Compiler | solc 0.8.30, optimizer on (200 runs), EVM version prague, OpenZeppelin Contracts v5.7.0 |
| Source | contracts/src/PaymentRouter.sol |
| Public RPC | https://rpc.testnet.whitechain.io |
| Explorer | https://explorer.testnet.whitechain.io (Blockscout) |
Use https://rpc.testnet.whitechain.io, with a dot. The similar-looking https://rpc-testnet.whitechain.io (with a hyphen) is a different network (chain id 2625).
Source verification
| Verifier | Status |
|---|---|
| Sourcify | Verified, exact match for creation and runtime bytecode: sourcify.dev |
| Whitechain explorer (Blockscout) | Verified: explorer |
Both matches prove the deployed bytecode was built from contracts/src/PaymentRouter.sol.
How it fits together
- When you register a payout wallet in the dashboard, the backend signer (the contract owner) calls
registerMerchant(merchantId, payout). - At checkout, the customer's wallet calls
approve(router, amount)on USDT, thenpay(merchantId, orderId, amount)on the router. The checkout widget does both. It skips the approval when the existing allowance already covers the amount, and first resets a non-zero allowance to 0 if the token rejects changing it directly. paytransfers the USDT from the customer to your payout wallet in the same call and emitsPaymentSettled.- The indexer reads
PaymentSettled, runs the settlement checks, marks the sessionpaidand sendspayment.succeeded. - To refund, your payout wallet approves the router and calls
refund(paymentId, amount). The USDT goes from your payout wallet back to the original payer, and the contract emitsRefunded. See refunds.
Ids
The contract works with three bytes32 ids. All of them are keccak256 hashes, so anyone who knows the off-chain ids can recompute them.
| Onchain id | Derived from | Formula |
|---|---|---|
merchantId |
Your merchant id (mer_...) |
keccak256(utf8(merchant.id)) |
orderId |
The checkout session id (cs_...) |
keccak256(utf8(checkoutSession.id)) |
paymentId |
merchantId and orderId |
keccak256(abi.encode(merchantId, orderId)) |
The onchain orderId is the hash of the checkout session id, not of your own orderId. One checkout session pays exactly one order, and because the contract allows one payment per paymentId, a session can't be paid twice.
You rarely need to compute these yourself:
- The checkout session object (including the public
GET /api/v1/checkout_sessions/:id) returnschainMerchantIdandchainOrderId. See the API reference. - The payment object, in the API and in webhooks, returns
chainPaymentId.
To derive them with viem (this mirrors packages/core/src/ids.ts):
import { encodeAbiParameters, keccak256, toBytes, type Hex } from "viem";
const chainMerchantId = (merchantId: string): Hex => keccak256(toBytes(merchantId));
const chainOrderId = (sessionId: string): Hex => keccak256(toBytes(sessionId));
const chainPaymentId = (merchantId: Hex, orderId: Hex): Hex =>
keccak256(encodeAbiParameters([{ type: "bytes32" }, { type: "bytes32" }], [merchantId, orderId]));
// Values from the first live testnet run:
const merchantId = chainMerchantId("mer_ZzAU0GqUZD5iZuhbP7Af");
// 0xeb1a564a0d25499a0f1db5e4121f95eb0a3bf795babee2b0d6063f5d6a4b9e0b
const orderId = chainOrderId("cs_fQqtlPuXFH5iqScwzpnm");
// 0xbcf4a0921c17cf58ebef2b225194ddbfb72dae472b365f693cc86fcf27e34c53
const paymentId = chainPaymentId(merchantId, orderId);
// 0xf023343c687ea8a44a45aa484624ae2da28c009014941a13a4982ce083da4993
The contract exposes the same calculation as paymentIdOf(merchantId, orderId).
With Foundry's cast:
cast keccak "cs_fQqtlPuXFH5iqScwzpnm"
cast keccak "$(cast abi-encode 'f(bytes32,bytes32)' \
0xeb1a564a0d25499a0f1db5e4121f95eb0a3bf795babee2b0d6063f5d6a4b9e0b \
0xbcf4a0921c17cf58ebef2b225194ddbfb72dae472b365f693cc86fcf27e34c53)"
Functions
pay
function pay(bytes32 merchantId, bytes32 orderId, uint256 amount) external nonReentrant returns (bytes32 paymentId)
Pays amount USDT, in base units (6 decimals, so 5980000 is 5.98 USDT), for the given merchant and order. The caller must have approved the router for at least amount. The USDT moves from msg.sender to the merchant's registered payout wallet, and the call emits PaymentSettled.
Reverts with:
MerchantNotRegistered(merchantId)if the merchant has no payout wallet.ZeroAmount()ifamountis 0.AlreadyPaid(paymentId)if this(merchantId, orderId)pair has already been paid.- The token's own error if the transfer fails. The USDT contract reverts with a reason string:
ERC20: transfer amount exceeds balancewhen the payer's balance is too low, andERC20: transfer amount exceeds allowancewhen the approval is too low. If both are too low, you get the balance error.
Anyone can call pay for any registered merchant. The contract doesn't know about checkout sessions or prices; that check happens off-chain in the settlement checks.
refund
function refund(bytes32 paymentId, uint256 amount) external nonReentrant
Refunds part or all of a payment to its original payer. Only the merchant's current payout wallet can call it, and that wallet must have approved the router for at least amount. The USDT moves from the payout wallet to the payer, and the call emits Refunded. Call it several times for several partial refunds; the total can never exceed the original amount.
Reverts with:
PaymentNotFound(paymentId)if there is no such payment.NotPayout(caller, payout)ifmsg.senderis not the merchant's current payout wallet.ZeroAmount()ifamountis 0.RefundExceedsPayment(requested, refundable)ifamountis more thanamount - refundedfor the payment.- The token's own error if the transfer fails, for example
ERC20: transfer amount exceeds allowancewhen the payout wallet hasn't approved the router foramount.
If you change your payout wallet, refunds for older payments must be signed by the new wallet.
registerMerchant
function registerMerchant(bytes32 merchantId, address payout) external onlyOwner
Registers a merchant or changes its payout wallet, and emits MerchantRegistered. Only the owner (the platform's backend signer) can call it. The dashboard calls it when you add or change your payout wallet. Reverts with ZeroAddress() if payout is the zero address, and OwnableUnauthorizedAccount(caller) if the caller isn't the owner.
Read functions
function getPayment(bytes32 paymentId) external view returns (Payment memory)
function merchantPayout(bytes32 merchantId) external view returns (address)
function paymentIdOf(bytes32 merchantId, bytes32 orderId) public pure returns (bytes32)
IERC20 public immutable usdt; // getter: function usdt() external view returns (address)
getPayment returns this struct:
struct Payment {
bytes32 merchantId;
bytes32 orderId;
address payer;
uint256 amount; // base units paid
uint256 refunded; // base units refunded so far
}
For an unknown paymentId, getPayment does not revert. It returns a zeroed struct, so check that payer is not the zero address. merchantPayout returns the zero address for an unregistered merchant.
Inherited from OpenZeppelin Ownable
function owner() public view returns (address)
function transferOwnership(address newOwner) public onlyOwner
function renounceOwnership() public onlyOwner
Apart from transferring or renouncing ownership itself, the owner's only power in this contract is registerMerchant.
Constructor
constructor(address usdt_, address owner_)
Reverts with ZeroAddress() if usdt_ is the zero address, and OwnableInvalidOwner(address(0)) if owner_ is.
Events
event MerchantRegistered(bytes32 indexed merchantId, address payout);
event PaymentSettled(
bytes32 indexed paymentId,
bytes32 indexed merchantId,
bytes32 indexed orderId,
address payer,
address payout,
address token,
uint256 amount
);
event Refunded(bytes32 indexed paymentId, address indexed payer, uint256 amount, uint256 totalRefunded);
// inherited from Ownable
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
| Event | topic0 |
|---|---|
MerchantRegistered |
0x1108b573acb7954c6be9806cc3a17ed5864f50fa96ad4cbc9cd82fa63d42b9f7 |
PaymentSettled |
0x3fdc1d05dfa723b79de4307baf18ae87604c0fedbffec3b67e00a7b8c9f52339 |
Refunded |
0xd906944adfb7859a98917af9d79763e627ff23f5d2448a7c197f3cb1a927f40a |
PaymentSettled.payout is the wallet that received the funds at the time of payment. Refunded.amount is this refund; Refunded.totalRefunded is the running total for the payment after it.
Errors
| Error | Selector | Raised by | Meaning |
|---|---|---|---|
ZeroAddress() |
0xd92e233d |
constructor, registerMerchant |
A required address is the zero address. |
ZeroAmount() |
0x1f2a2005 |
pay, refund |
amount is 0. |
MerchantNotRegistered(bytes32 merchantId) |
0xcd37aa72 |
pay |
No payout wallet is registered for the merchant. |
AlreadyPaid(bytes32 paymentId) |
0xf4b76a0e |
pay |
This merchant and order have already been paid. |
PaymentNotFound(bytes32 paymentId) |
0x272f9fc1 |
refund |
No payment with this id. |
NotPayout(address caller, address payout) |
0x7fb966d8 |
refund |
The caller isn't the merchant's current payout wallet. |
RefundExceedsPayment(uint256 requested, uint256 refundable) |
0x333d1b2c |
refund |
The refund is larger than what is left to refund. |
From OpenZeppelin:
| Error | Selector | Meaning |
|---|---|---|
OwnableUnauthorizedAccount(address account) |
0x118cdaa7 |
A non-owner called registerMerchant, transferOwnership or renounceOwnership. |
OwnableInvalidOwner(address owner) |
0x1e4fbdf7 |
Zero address passed as owner. |
ReentrancyGuardReentrantCall() |
0x3ee5aeb5 |
Re-entry into pay or refund. |
SafeERC20FailedOperation(address token) |
0x5274afe7 |
The token returned false from transferFrom, or has no code. |
A transferFrom that reverts inside the token passes the token's own revert data through unchanged. The USDT contract uses reason strings (Error(string)), such as ERC20: transfer amount exceeds balance and ERC20: transfer amount exceeds allowance.
Non-custodial guarantees
These follow from the contract code, which you can read in full in PaymentRouter.sol:
- It never holds funds.
paytransfers straight from the payer to the payout wallet, andrefundstraight from the payout wallet to the payer, each with a singletransferFrom. The router's own USDT balance stays at zero. The fuzz testtestFuzz_routerNeverHoldsFundsincontracts/test/PaymentRouter.t.solchecks this after every payment and refund. - There is no withdraw function. Nothing in the contract can move tokens except
payandrefund, and both only move tokens from the caller. - Refunds go to the original payer only. The destination is the stored
payer; the caller can't choose it. - Refunds are capped. The total refunded can never exceed the amount paid.
- One payment per order. A second
payfor the same(merchantId, orderId)reverts withAlreadyPaid. - Single token. The USDT address is
immutableand set at deploy time. - Not upgradeable. It's a plain contract, not a proxy.
What you do trust the platform for:
- The payout mapping. The owner (
0xA8d1...9c12) can change any merchant's payout wallet withregisterMerchant. That would redirect future payments for that merchant, and the new wallet would control refunds. It can't touch payments that have already settled, because those funds are already in your wallet. The dashboard only callsregisterMerchantwhen you add or change your payout wallet. You can watch for unexpected changes by filteringMerchantRegisteredon yourmerchantId. - Order bookkeeping. Whether a payment counts as paying your checkout session is decided off-chain by the indexer. See settlement checks.
Verify a payment onchain
You don't have to take the platform's word for a payment. With the chainPaymentId and txHash from a payment.succeeded event or from GET /api/v1/payments/:id, read the contract directly.
Read getPayment
This reads the payment's current state from contract storage, including refunds so far:
import { createPublicClient, defineChain, formatUnits, http, parseAbi, parseEventLogs, type Hex } from "viem";
const settlementChain = defineChain({
id: 1874,
name: "Settlement chain",
nativeCurrency: { name: "WhiteBIT Coin", symbol: "WBT", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.testnet.whitechain.io"] } },
blockExplorers: { default: { name: "Whitechain Explorer", url: "https://explorer.testnet.whitechain.io" } },
testnet: true,
});
const ROUTER = "0xa99a6D97607567f4fe734831910D24f4e9BD0508";
const USDT = "0xcedfB56113143d55b2358b8CFbB0dEB5Ad2f55a7";
const routerAbi = parseAbi([
"function getPayment(bytes32 paymentId) view returns ((bytes32 merchantId, bytes32 orderId, address payer, uint256 amount, uint256 refunded))",
"event PaymentSettled(bytes32 indexed paymentId, bytes32 indexed merchantId, bytes32 indexed orderId, address payer, address payout, address token, uint256 amount)",
]);
const client = createPublicClient({ chain: settlementChain, transport: http() });
// From a payment.succeeded webhook (or GET /api/v1/payments/:id).
const chainPaymentId: Hex = "0xf023343c687ea8a44a45aa484624ae2da28c009014941a13a4982ce083da4993";
const txHash: Hex = "0xebd1c99ae548a3e497084f92a3d41d1bb0a8a5f4b775a681ebccd715e3e05125";
// 1. Current state of the payment, straight from contract storage.
const p = await client.readContract({ address: ROUTER, abi: routerAbi, functionName: "getPayment", args: [chainPaymentId] });
if (p.payer === "0x0000000000000000000000000000000000000000") throw new Error("No such payment onchain");
console.log(`paid ${formatUnits(p.amount, 6)} USDT, refunded ${formatUnits(p.refunded, 6)} USDT, payer ${p.payer}`);
// paid 5.98 USDT, refunded 2 USDT, payer 0xd25e2d037A49F19EAe6d55396fa52f2D95B7f27e
// 2. The PaymentSettled event in the payment transaction.
const receipt = await client.getTransactionReceipt({ hash: txHash });
const [settled] = parseEventLogs({ abi: routerAbi, eventName: "PaymentSettled", logs: receipt.logs }).filter(
(l) => l.address.toLowerCase() === ROUTER.toLowerCase(),
);
console.log(receipt.status, settled.args.paymentId === chainPaymentId, settled.args.token === USDT, settled.args.payout);
// success true true 0xA8d17110550f389801470A0088e423C72C5A9c12
Always check that the log came from the router address. Any contract can emit an event with the same signature.
The same read with cast:
cast call 0xa99a6D97607567f4fe734831910D24f4e9BD0508 \
"getPayment(bytes32)((bytes32,bytes32,address,uint256,uint256))" \
0xf023343c687ea8a44a45aa484624ae2da28c009014941a13a4982ce083da4993 \
--rpc-url https://rpc.testnet.whitechain.io
Filter PaymentSettled and Refunded
All three ids in PaymentSettled are indexed, so you can filter by payment, merchant or order. This finds every payment and refund for one paymentId, reusing client and ROUTER from above:
import { parseAbiItem } from "viem";
const paymentSettled = parseAbiItem(
"event PaymentSettled(bytes32 indexed paymentId, bytes32 indexed merchantId, bytes32 indexed orderId, address payer, address payout, address token, uint256 amount)",
);
const refunded = parseAbiItem("event Refunded(bytes32 indexed paymentId, address indexed payer, uint256 amount, uint256 totalRefunded)");
const DEPLOY_BLOCK = 12373441n; // router deploy block
const CHUNK = 2000n; // the public RPC allows at most 10,000 blocks per eth_getLogs call
const head = await client.getBlockNumber();
const payments = [];
const refunds = [];
for (let from = DEPLOY_BLOCK; from <= head; from += CHUNK) {
const to = from + CHUNK - 1n < head ? from + CHUNK - 1n : head;
const range = { address: ROUTER, args: { paymentId: chainPaymentId }, fromBlock: from, toBlock: to } as const;
payments.push(...(await client.getLogs({ ...range, event: paymentSettled })));
refunds.push(...(await client.getLogs({ ...range, event: refunded })));
}
console.log(payments.length, refunds.length);
// 1 1 (the live-run payment and its 2.00 USDT refund)
To list all of your payments, filter on args: { merchantId } instead. The public RPC rejects a range wider than 10,000 blocks (query exceeds max block range 10000) and a toBlock beyond the current head, so read the head first and query in chunks. The platform's indexer uses 2,000 blocks per request.
You can also open the transaction on the explorer and look at its logs. The contract is verified there, so the explorer decodes them by name.
Settlement checks
PaymentSettled proves money moved, but not that it paid the right checkout session for the right price. Before marking an order paid, the indexer checks every PaymentSettled log against the database: chain id, router address, token, the paymentId formula, the session and merchant ids, the exact amount, the token decimals and the receipt status, with 1 block confirmation. The same code runs in the background indexer and when the checkout widget submits a transaction hash, so a submitted hash is never trusted on its own. The full ordered list, and what happens when a check fails, is in Payments: settlement checks.
A PaymentSettled that fails a check is skipped and logged. The USDT has still moved to your payout wallet, because the contract already sent it there; it just isn't linked to a checkout session. See Payments: wrong amounts.
Refunded logs are checked the same way and then matched to pending refund requests. See Refunds: how onchain refunds are matched.
Related
- Webhooks: the events sent after these checks pass
- Refunds: requesting and signing refunds from the dashboard
- Payments: the payment object
- DEPLOYMENTS.md: deploy details and how to redeploy