Technical editorial illustration showing a stablecoin checkout state machine reconciling blockchain blocks, reorg rollback events, and merchant fulfillment outbox queues.
Reorg-safe stablecoin checkout: separating chain inclusion from irreversible digital fulfillment.

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:

  1. Server-Owned Quotes: The client never dictates the price, token address, or gateway contract.
  2. Raw Integer Precision: Zero floating-point math; all values operate as uint256 raw atomic strings.
  3. Reorg-Safe Event Projection: Transaction inclusion (confirming) is structurally separated from consensus finality (paid).
  4. Idempotent Outbox Fencing: Webhook delivery uses lease versions and monotonic state machines.

2. Relational Schema Architecture: Orders, Events, and Outbox Queues

System flow diagram showing state transitions from open to confirming, paid, payment.reorged, and partial/full refund states, with outbox relay.

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:

  • amount as VARCHAR(78): Represents up to 2256 − 1 without precision truncation. Stored with COLLATE ascii_bin to enforce byte-exact matching.
  • revision counter: A monotonic BIGINT UNSIGNED incremented on every state transition. Downstream consumers use revision to 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, while chain_order and payment_identifier ensure 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:

  1. raw_amount strictly equals display_amount * 10^decimals (e.g. 100 USDC is exactly 100000000n with 6 decimals).
  2. The destination token matches the official contract address deployed on that chain_id.
  3. The server signs an EIP-712 structured typed data envelope incorporating orderId, merchantId, token, amount, recipient, deadline, and riskEpoch (src/modules/payment/signing/quote-validation.ts:L1-L30). If a user attempts to execute the transaction after deadline or 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:

  1. All blocks on the orphaned branch have their canonical flag set to FALSE in pay_chain_blocks.
  2. All events in those orphaned blocks are flipped to canonical = FALSE in pay_chain_events.
  3. The projector re-executes. Because events.find(e => e.canonical) now returns undefined, the projector deletes the pay_payments record, calculates that no canonical payment exists, reverts pay_orders.payment_status from confirming back to open (or expired), increments revision, and immediately enqueues a payment.reorged outbox event.
  4. Because the merchant never fulfills on confirming (only on paid), 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_status remains paid.
  • pay_orders.refund_status transitions from none to partial.
  • pay_orders.refunded_amount updates from 0 to 20000000 (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.

  1. Audit Your Order FSM: Review your checkout flow to ensure that order creation, webhook ingestion, and asset fulfillment are separated across distinct database transactions.
  2. 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.
  3. Explore the Working Implementation: Review the screen captures and architectural specifications on the StableIn Payments Product Page or the 中文版产品主页.
  4. 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.