
Building a production-grade cryptocurrency checkout engine requires abandoning the synchronous assumptions of traditional payment gateways like Stripe. On EVM networks (Ethereum, Base, Arbitrum, Polygon), block reorganizations (reorgs), client-side currency manipulation, out-of-order webhook delivery, and asynchronous refund states create severe reconciliation risks. In StableIn Payments, this is resolved by decoupling optimistic user notifications from immutable finality proofs: combining server-signed EIP-712 quotes, raw integer BigInt accounting, MySQL InnoDB transaction retries (
SELECT ... FOR UPDATE), cursor-based chain event projection with deterministic rollback detection (payment.reorged), and lease-fenced idempotent webhook outboxes.
1. The Production Failure State: Why Traditional Webhooks Cause Crypto Double-Spends
When software engineers integrate stablecoin payments (such as USDC or USDT on Base, Polygon, or Ethereum), they frequently replicate the pattern used for card payments: listen for a transaction hash, wait for 1 confirmation, and immediately dispatch the goods or credit user balances.
In production environments, this pattern consistently leads to two severe failure modes:
[Failure Mode 1: The Shallow Block Reorg]
T0: User broadcasts 1,000 USDC transfer on a fast L2/sidechain.
T1: Block #10,400,210 includes the transaction. Application webhook triggers with status = "paid".
T2: Merchant server immediately provisions a digital license key or ships a software entitlement.
T3: Due to network latency between validators, Block #10,400,210 is orphaned in a 2-block reorganization.
T4: The user's transaction was replaced or dropped from the mempool.
Result: The merchant delivered goods for a transaction that never finalized on-chain. Capital lost.
[Failure Mode 2: Out-of-Order Webhook Delivery]
T0: User initiates 500 USDC order. Webhook event 'payment.paid' is dispatched but delayed in transit.
T1: Order expires or customer requests cancellation; refund event 'refund.completed' is dispatched.
T2: Merchant server receives 'refund.completed' first -> marks order as refunded.
T3: 200ms later, the delayed 'payment.paid' event arrives -> naive server flips status back to 'paid'.
Result: Customer balance is re-credited or digital goods are delivered after full refund.
In credit card processing, the acquirer absorbs or arbitrates chargeback disputes through banking rails. In Web3, once digital goods (API keys, RWA tokens, software downloads) are released, they cannot be charged back. The payment engine must treat the blockchain not as an instantaneous transaction log, but as an eventually consistent, branching state tree.
To eliminate these vulnerabilities, StableIn Payments enforces strict boundaries:
- Server-Owned Quotes: The client never dictates the price, token address, or gateway contract.
- Raw Integer Precision: Zero floating-point math; all values operate as
uint256raw atomic strings. - Reorg-Safe Event Projection: Transaction inclusion (
confirming) is structurally separated from consensus finality (paid). - Idempotent Outbox Fencing: Webhook delivery uses lease versions and monotonic state machines.
2. Relational Schema Architecture: Orders, Events, and Outbox Queues

The data model powering StableIn-Platform is implemented in TypeORM and MySQL InnoDB, establishing explicit relational boundaries between orders, signing jobs, chain events, and delivery attempts.
Core Order and Idempotency Schema
From StableIn-Platform/src/migrations/1792108801000-PaymentOrders.ts:L1-L46:
CREATE TABLE pay_orders (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
environment VARCHAR(4) NOT NULL,
merchant_order_id VARBINARY(512) NOT NULL,
order_id BINARY(32) NOT NULL,
payment_id BINARY(32) NOT NULL,
chain_id INT UNSIGNED NOT NULL,
gateway BINARY(20) NOT NULL,
merchant_id VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
token BINARY(20) NOT NULL,
amount VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
fee VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
net_amount VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
recipient BINARY(20) NOT NULL,
owner BINARY(20) NOT NULL,
signer BINARY(20) NOT NULL,
deadline BIGINT UNSIGNED NOT NULL,
config_version BIGINT UNSIGNED NOT NULL,
fee_bps SMALLINT UNSIGNED NOT NULL,
intent_status VARCHAR(12) NOT NULL DEFAULT 'pending',
payment_status VARCHAR(20) NOT NULL DEFAULT 'open',
refund_status VARCHAR(8) NOT NULL DEFAULT 'none',
refunded_amount VARCHAR(78) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '0',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1,
intent JSON NOT NULL,
digest BINARY(32) NOT NULL,
signature VARBINARY(65) NULL,
checkout_digest BINARY(32) NOT NULL UNIQUE,
checkout_cipher BLOB NOT NULL,
checkout_expires_at DATETIME(6) NOT NULL,
return_url VARCHAR(2048) NULL,
metadata JSON NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY merchant_reference (tenant_id, merchant_order_id),
UNIQUE KEY chain_order (chain_id, gateway, merchant_id, order_id),
UNIQUE KEY payment_identifier (chain_id, gateway, payment_id),
KEY tenant_list (tenant_id, created_at, id)
) ENGINE=InnoDB;
CREATE TABLE pay_idempotency_keys (
tenant_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
method VARBINARY(8) NOT NULL,
path VARBINARY(200) NOT NULL,
idempotency_key VARBINARY(512) NOT NULL,
request_hash BINARY(32) NOT NULL,
resource_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
response_json JSON NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (tenant_id, method, path, idempotency_key)
) ENGINE=InnoDB;
Crucial design elements to note:
amountasVARCHAR(78): Represents up to 2256 − 1 without precision truncation. Stored withCOLLATE ascii_binto enforce byte-exact matching.revisioncounter: A monotonicBIGINT UNSIGNEDincremented on every state transition. Downstream consumers userevisionto reject out-of-order webhook delivery.- Unique Constraints:
merchant_reference (tenant_id, merchant_order_id)prevents accidental double billing on the merchant’s business ID, whilechain_orderandpayment_identifierensure on-chain unique routing.
Chain Scanners, Canonical Blocks, and Event Storage
From StableIn-Platform/src/migrations/1792108802000-PaymentChain.ts:L1-L41:
CREATE TABLE pay_scan_cursors (
chain_id INT UNSIGNED PRIMARY KEY,
gateway BINARY(20) NOT NULL,
next_block BIGINT UNSIGNED NOT NULL,
finalized_block BIGINT UNSIGNED NULL,
finalized_hash BINARY(32) NULL,
fault_reason VARCHAR(100) NULL,
finality_error VARCHAR(100) NULL,
lease_owner CHAR(36) NULL,
lease_until DATETIME(6) NULL,
lease_version BIGINT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB;
CREATE TABLE pay_chain_blocks (
chain_id INT UNSIGNED NOT NULL,
number BIGINT UNSIGNED NOT NULL,
hash BINARY(32) NOT NULL,
parent_hash BINARY(32) NOT NULL,
timestamp BIGINT UNSIGNED NOT NULL,
canonical BOOLEAN NOT NULL,
finalized BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (chain_id, hash),
KEY canonical_height (chain_id, canonical, number)
) ENGINE=InnoDB;
CREATE TABLE pay_chain_events (
id CHAR(36) PRIMARY KEY,
chain_id INT UNSIGNED NOT NULL,
block_number BIGINT UNSIGNED NOT NULL,
block_hash BINARY(32) NOT NULL,
tx_hash BINARY(32) NOT NULL,
tx_index INT UNSIGNED NOT NULL,
log_index INT UNSIGNED NOT NULL,
name VARCHAR(40) NOT NULL,
args JSON NOT NULL,
order_id CHAR(36) NULL,
anomaly VARCHAR(100) NULL,
canonical BOOLEAN NOT NULL,
finalized BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE KEY event_identity (chain_id, block_hash, tx_hash, log_index),
KEY order_events (order_id, canonical, block_number, tx_index, log_index)
) ENGINE=InnoDB;
3. Server-Side Validation: Raw Token Decimals and Zero-Trust Quoting
In our previous discussion on server amount validation (Validate Stablecoin Checkout Amounts on the Server), we established that relying on amounts submitted by the browser is an invitation to fraud.
In StableIn-Platform/packages/protocol/src/amounts.ts:L1-L29, token parsing is locked down to pure integer mathematics:
export const UINT256_MAX = (1n << 256n) - 1n;
export function parseUint(value: string, bits = 256): bigint {
if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value) || value.length > 78)
throw new Error('Expected canonical unsigned decimal string');
if (!Number.isInteger(bits) || bits < 1 || bits > 256) throw new Error('Invalid integer width');
const result = BigInt(value);
if (result >= 1n << BigInt(bits)) throw new Error('Integer exceeds its width');
return result;
}
function scale(decimals: number): bigint {
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 77) throw new Error('Invalid decimals');
return 10n ** BigInt(decimals);
}
export function parseAmount(value: string, decimals: number): bigint {
const unit = scale(decimals);
if (typeof value !== 'string' || value.length > 157 || !/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value))
throw new Error('Invalid decimal amount');
const [whole, fraction = ''] = value.split('.');
if (fraction.length > decimals) throw new Error('Too many fractional digits');
const result = BigInt(whole) * unit + BigInt(fraction.padEnd(decimals, '0') || '0');
if (result > UINT256_MAX) throw new Error('Amount exceeds uint256');
return result;
}
When an order is created, the server asserts that:
raw_amountstrictly equalsdisplay_amount * 10^decimals(e.g. 100 USDC is exactly100000000nwith 6 decimals).- The destination
tokenmatches the official contract address deployed on thatchain_id. - The server signs an EIP-712 structured typed data envelope incorporating
orderId,merchantId,token,amount,recipient,deadline, andriskEpoch(src/modules/payment/signing/quote-validation.ts:L1-L30). If a user attempts to execute the transaction afterdeadlineor tampers with the recipient address, the smart contract router rejects the execution on-chain.
4. End-to-End State Machine: Handling Reorgs and Confirmations
The core payment lifecycle in StableIn-Platform is governed by explicit status enums (packages/protocol/src/api-types.ts:L1-L34):
export type PaymentStatus =
| 'open' // Quote created, waiting for on-chain transfer
| 'confirming' // Included in canonical block, awaiting finality threshold
| 'paid' // Block finalized by consensus; safe to fulfill
| 'expired' // Deadline passed without payment
| 'cancelled' // Merchant or protocol cancellation
| 'invalidated' // Merchant config changed before payment
| 'review_required';// Chain anomaly or invariant check failed
Deterministic Event Projection and Reorg Rollback
The event projection worker continuously reads from pay_chain_events. Crucially, it does not mutate state using simple incremental counters. It reconstructs the order state directly from the ordered sequence of canonical events (src/modules/payment/chain/projector.ts:L22-L93):
// 1. Fetch all canonical events for the order in strict chain execution order
const events = await m.query(
'SELECT * FROM pay_chain_events WHERE order_id=? AND canonical=TRUE ORDER BY block_number,tx_index,log_index',
[id]
);
const payment = events.find(e => e.name === 'PaymentSettled' && !e.anomaly);
const cancelled = events.find(e => e.name === 'OrderCancelled' && !e.anomaly);
const anomalous = events.some(e => e.anomaly);
// 2. Clear existing projections within the transaction
await m.query('DELETE FROM pay_refunds WHERE order_id=?', [id]);
await m.query('DELETE FROM pay_payments WHERE order_id=?', [id]);
let cumulative = 0n;
let evidence: any = null;
if (payment) {
const a = json(payment.args);
await m.query(
'INSERT INTO pay_payments (order_id,event_id,payment_id,payer,tx_hash,block_number,block_hash,log_index,finalized) VALUES (?,?,?,?,?,?,?,?,?)',
[id, payment.id, r.payment_id, dbAddress(a.payer), payment.tx_hash, payment.block_number, payment.block_hash, payment.log_index, payment.finalized]
);
evidence = {
paymentId: readHash(r.payment_id),
chainId: r.chain_id,
gateway: readAddress(r.gateway),
txHash: readHash(payment.tx_hash),
blockNumber: payment.block_number,
finality: payment.finalized ? 'finalized' : 'included'
};
}
// 3. Determine status based on consensus finality
const status = fault || anomalous
? 'review_required'
: payment
? (payment.finalized ? 'paid' : 'confirming')
: cancelled?.finalized
? 'cancelled'
: invalidated
? 'invalidated'
: BigInt(r.deadline) < BigInt(Math.floor(Date.now() / 1000))
? 'expired'
: 'open';
// 4. Update order state and emit outbox event
if (r.payment_status !== status || reorg) {
const oldStatus = r.payment_status;
Object.assign(r, { payment_status: status, revision: String(BigInt(r.revision) + 1n) });
await m.query(
'UPDATE pay_orders SET payment_status=?,revision=? WHERE id=?',
[status, r.revision, id]
);
// If a reorg occurred, explicitly notify downstream systems
if (reorg) {
await emit(m, r, 'payment.reorged', evidence);
} else if (oldStatus !== status) {
await emit(m, r, `payment.${status}`, evidence);
}
}
What Happens During a Block Reorg?
When pay_scan_cursors detects that a previously scanned block’s parent_hash no longer matches the canonical tip:
- All blocks on the orphaned branch have their
canonicalflag set toFALSEinpay_chain_blocks. - All events in those orphaned blocks are flipped to
canonical = FALSEinpay_chain_events. - The projector re-executes. Because
events.find(e => e.canonical)now returnsundefined, the projector deletes thepay_paymentsrecord, calculates that no canonical payment exists, revertspay_orders.payment_statusfromconfirmingback toopen(orexpired), incrementsrevision, and immediately enqueues apayment.reorgedoutbox event. - Because the merchant never fulfills on
confirming(only onpaid), no inventory is lost.
5. Webhook Security and Merchant Fencing
In Is a Valid Payment Webhook Signature Enough to Fulfill an Order?, we demonstrated that verifying the cryptographic signature is merely step one.
The webhook delivery mechanism in StableIn-Platform implements an Idempotent Outbox Relay with Lease Fencing (src/migrations/1792108804000-PaymentWebhooks.ts:L1-L28):
CREATE TABLE pay_webhook_deliveries (
id CHAR(36) PRIMARY KEY,
event_id CHAR(36) NOT NULL,
endpoint_id CHAR(36) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
next_attempt_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
lease_owner CHAR(36) NULL,
lease_until DATETIME(6) NULL,
lease_version BIGINT UNSIGNED NOT NULL DEFAULT 0,
last_http_status SMALLINT NULL,
last_error VARCHAR(60) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY event_endpoint (event_id, endpoint_id),
KEY due (status, next_attempt_at)
) ENGINE=InnoDB;
Raw Byte Verification (packages/sdk/src/webhook.ts:L1-L15)
To prevent JSON serialization normalization bugs (e.g. whitespace or key reordering altering the hash), the SDK strictly validates signatures against the raw binary request buffer:
export function verifyWebhook(
rawBody: Uint8Array,
headers: Record<string, string>,
secret: string,
nowSeconds: number
): VerifiedWebhook {
const normalized = Object.fromEntries(
Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
);
const t = normalized['stablein-timestamp'];
const sig = normalized['stablein-signature'];
// 1. Strict format and clock-drift tolerance (300 seconds)
if (!/^(?:0|[1-9][0-9]{0,11})$/.test(t ?? '') || !/^v1=[a-f0-9]{64}$/.test(sig ?? '') || Math.abs(nowSeconds - Number(t)) > 300)
throw new StableInError('INVALID_WEBHOOK');
// 2. Timing-safe HMAC comparison over timestamp + '.' + rawBody
const expected = Buffer.from('v1=' + createHmac('sha256', secret).update(t + '.').update(rawBody).digest('hex'));
const actual = Buffer.from(sig);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
throw new StableInError('INVALID_WEBHOOK');
// 3. Payload integrity assertion
const parsed = JSON.parse(Buffer.from(rawBody).toString('utf8'));
if (parsed?.id !== normalized['stablein-event-id'])
throw new StableInError('INVALID_WEBHOOK');
return parsed;
}
Merchant Store Consumption: Guarding Against Delayed Events
In StableIn-Platform/examples/merchant-store/src/rules.mjs:L1-L26, the merchant application validates fulfillment eligibility before issuing digital assets:
export function canFulfill(order, localRevision) {
try {
assertCurrentRisk(order);
} catch {
return false;
}
return (
order.fulfillmentDecision === 'allow' &&
order.merchantRisk.serviceStatus === 'enabled' &&
order.merchantRisk.chainEnforcement === 'allowed' &&
order.paymentStatus === 'paid' &&
order.refundStatus !== 'full' &&
BigInt(order.refundedAmount) < BigInt(order.amount) &&
BigInt(order.revision) >= BigInt(localRevision)
);
}
Notice the critical check: BigInt(order.revision) >= BigInt(localRevision). If a delayed webhook arrives carrying revision = 3, but the local store database has already progressed to revision = 5 via a subsequent refund or status check, the webhook is immediately flagged as stale and discarded without triggering fulfillment.
6. Decoupling Refunds from Digital Entitlements
In Partial Refunds and Digital Product Entitlements, we demonstrated why storing a single status = "refunded" field on an order creates severe bugs when processing SLA adjustments or partial settlements.
In StableIn-Platform, refunds operate as an independent ledger (src/modules/payment/refunds/refunds.service.ts:L23-L44):
// Calculate remaining refundable balance: original - settled_refunds - pending_requests
const pending = await m.query(
"SELECT amount FROM pay_refund_requests WHERE order_id=? AND abandoned=FALSE AND status<>'completed'",
[orderId]
);
const remaining = BigInt(r.amount) - BigInt(r.refunded_amount) - pending.reduce((sum, p) => sum + BigInt(p.amount), 0n);
if (BigInt(input.amount) > remaining)
throw new PaymentError(409, 'REFUND_EXCEEDS_REMAINING', { remaining: String(remaining > 0n ? remaining : 0n) });
await m.query(
'INSERT INTO pay_refund_requests (id,order_id,merchant_refund_id,refund_id,amount) VALUES (?,?,?,?,?)',
[id, orderId, Buffer.from(input.merchantRefundId), randomBytes(32), input.amount]
);
When a partial refund of 20% is issued:
pay_orders.payment_statusremainspaid.pay_orders.refund_statustransitions fromnonetopartial.pay_orders.refunded_amountupdates from0to20000000(for a 100 USDC order).- The merchant’s digital fulfillment state machine can decide whether to maintain software access, throttle API rate limits, or revoke entitlement based on explicit business rules—preventing accidental customer lockouts.
7. Architectural Comparison: Hosted vs. Reorg-Safe Native Engine
| Dimension | Hosted Third-Party Gateway | Naive Custom Webhook Listener | StableIn Reorg-Safe Architecture |
|---|---|---|---|
| Reorg Defense | Black box (often 12-30 min latency) | None (Fulfills on 1st confirmation) | Cursor projection with payment.reorged event |
| Numeric Precision | Floating-point JSON representation | Mixed JS numbers / strings | Native VARCHAR(78) & BigInt atomic units |
| Idempotency Guarantee | Basic HTTP retry header | In-memory cache / None | MySQL (tenant, method, path, key) unique index |
| Webhook Delivery Fencing | Fixed HTTP POST retries | Cron script without locks | Lease-fenced outbox (pay_webhook_deliveries) |
| Quote Security | Client-generated or unpinned | Client submits price parameter | Server-signed EIP-712 with expiry & risk epoch |
| Partial Refund Accounting | Overwrites main order status | Corrupts order ledger | Independent refund requests & cumulative tracking |
| Data Sovereignty | Custodial / Vendor lock-in | Self-hosted but fragile | 100% self-hosted, verifiable open schema |
8. Integration & Production Roadmap
Deploying stablecoin payments for high-value B2B transactions, digital SaaS licensing, or autonomous AI agents requires treating edge cases as first-class citizens. When handling irreversible blockchain transactions, eventual consistency must be modeled directly in database constraints.
Recommended Next Steps:
- Audit Your Order FSM: Review your checkout flow to ensure that order creation, webhook ingestion, and asset fulfillment are separated across distinct database transactions.
- Review the Engineering Guidelines: Explore our related deep dives on Build an Idempotent Order API with Python and SQLite and Mapping Payment Provider Events to Order States.
- Explore the Working Implementation: Review the screen captures and architectural specifications on the StableIn Payments Product Page or the 中文版产品主页.
- Schedule an Architecture Review: If your team is implementing multi-chain checkout, requires automated partial refund state machines, or needs a white-label payment gateway deployed to private infrastructure, book a technical demo walkthrough or request an enterprise integration quote.