Pilot pack · Stablecoins

Stablecoin payouts, with the controls finance asks for.

Your back office pays suppliers, partners and group entities in USDC or USDT, and hears about every payment it receives. Only the beneficiaries you list, within the limits you set, with an approver above an amount, and a reason whenever something is refused.

Demo

The console, in three minutes.

Recorded from the real console on a sandbox, a private copy of Ethereum mainnet where nothing has value: a payout confirmed live, one refused by the policy, a large one approved, and an incoming payment verified.

How it works

Every payout passes the policy before it is signed.

The payment engine or ERP that decides a payout sends it to CryptoRail. The payout policy reads the payment itself: who is paid and how much. A payout outside the rules is refused before anything is signed, a large one waits for an approver, and the rest goes out signed with the treasury's key. Payments arriving at the treasury reach the same systems as signed notifications.

See it in code ↓

flowchart TD
    ERP[Your ERP decides a payout] --> P{Payout policy}
    P -->|beneficiary not listed or daily limit reached| X[Refused with the reason, nothing sent]
    P -->|at or above the threshold| A[An approver signs what they read]
    P -->|within the rules| S[Signed with the treasury's key]
    A --> S
    S --> N[Stablecoin transfer on chain]
    N --> C[Confirmed: your ERP is told]
    W[Someone pays your treasury] --> T[Transfer seen on chain]
    T --> H[Signed webhook to your ERP]
What the pack brings

Four parts, ready for a pilot.

The pack connects the stablecoins that already exist; there is no contract of ours to trust.

Tokens

The real contracts

USDC and USDT at the addresses their issuers publish, checked against the chain before use. See it in code ↓

Policy

Rules on the payment

A list of beneficiaries, a daily limit in the token's own units, and an approval threshold. See it in code ↓

Approvals

Above an amount

The approver reads the decoded payment, beneficiary and amount, and signs exactly that. See it in code ↓

Incoming

Payments received

Every transfer to the treasury reaches your back office as a signed webhook, withdrawn if the chain reorganises. See it in code ↓

The controls

What finance can rely on.

A retry never pays twice, a refusal always says why, and every approval is recorded with who signed what and when.

  • Payout rules that read the payment itself: only listed beneficiaries, a daily cap in the token's own units, and approval above an amount.
  • Operations above a threshold wait for a quorum of approvers, who sign an EIP-712 digest that names neither nonce nor fee.
  • Every approval, signature and decision is recorded with who, when and what, and every change made through the API lands in an audit log.
  • Submitting the same intent twice with the same idempotency key returns the same transaction: a retry never becomes a second payment.
  • Every failure comes with a report: the stage, a stable cause, whether a retry can work and on what condition, and the suggested action.
  • Signed webhooks, retried for about three days, then held in a dead-letter queue an operator can redeliver from.
  • Chain reorganisations handled: no event lost or delivered from a discarded block, across restarts.
  • A ready-made stablecoin pilot pack for the sandbox: token addresses verified against the chain, an example payout policy and a one-command setup.
Evidence

Proven twice: on a copy of mainnet, and on a public test network.

On every change, an end-to-end scenario runs the pack against the real USDC and USDT on a fork of Ethereum mainnet. On 2026-10-10 the same pack ran on Base Sepolia with Circle's official test USDC; anyone can check its transactions on the block explorer.

On Base SepoliaOutcomeTransaction
Payout of 2 USDC to a listed beneficiaryConfirmed; the beneficiary holds 2 USDC0x0a5081ff…536d96
Payout of 1 USDC to an address not on the listRefused by the policy; nothing sentnone
Payout of 6 USDC, above the approval thresholdHeld until the approver signed; then confirmed0x57d475e5…844b9e
Payout of 4.5 USDC after 8 USDC paid that dayRefused: over the daily limit; nothing sentnone
Incoming payment of 1 USDC from an outside walletDelivered to the back office as a signed webhook, signature verified0xffb3bbac…627ea3

The rules of that run were sized to the test USDC a faucet gives: an approver from 5 USDC, at most 12 USDC a day. The treasury's address is 0xd81a680b…37422b. The scenario and the requirements behind every line above are listed on the evidence page.

For engineers

How it is done, in code.

The pack is a small library over CryptoRail's API: a manifest of the tokens verified against the chain, the payout policy for the service's rules engine, and a setup that registers the tokens, the webhook and the subscriptions. Your systems then use the typed SDK, or plain HTTP. The calls below are the ones the journey makes on every change.

Step 1

The ABI becomes your API

CryptoRail does not need to know a contract to reach it. Register its ABI, even only the functions and events you use, then the contract's address under a name of yours: its selectors are checked against the deployed code, and from then on every function is a call by name and every event arrives decoded. The pack's setup does exactly this for each token.

flowchart TD
    A[ABI: the functions and events you use] -->|POST /v1/abis| R[(Registry)]
    C[Address on a chain] -->|POST /v1/contracts, under a name| R
    R --> W[Writes: POST /v1/transactions, function by name]
    R --> Q[Reads: POST /v1/contracts/id/call]
    R --> E[Events: subscriptions and webhooks, decoded]
// 1. The ABI: any subset of the contract, readable or JSON
const abi = await cryptorail.POST('/v1/abis', {
  body: {
    fragments: [
      'function transfer(address to, uint256 amount) returns (bool)',
      'function balanceOf(address owner) view returns (uint256)',
      'event Transfer(address indexed from, address indexed to, uint256 value)',
    ],
  },
})

// 2. The contract: an address on a chain, under a name of yours
const usdc = await cryptorail.POST('/v1/contracts', {
  body: {
    chainId: 84532,
    address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
    label: 'pack-usdc',
    abiId: abi.data!.id,
    checkSelectors: true,   // every function must be in the deployed code
  },
})

// 3. Every function is now a call by name, arguments checked
//    against the ABI before anything is signed
await cryptorail.POST('/v1/contracts/{id}/call', {       // a read
  params: { path: { id: usdc.data!.id } },
  body: { function: 'balanceOf', args: [treasury] },
})
await cryptorail.POST('/v1/transactions', {               // a write
  body: {
    chainId: 84532,
    actor: { kind: 'identity', identityId: treasuryId },
    target: {
      kind: 'contract', contractId: usdc.data!.id,
      function: 'transfer', args: [supplierA, '2000000'],
    },
  },
})
// The same events, decoded by name, feed subscriptions and webhooks
Step 2

Set the pack up

Three commands: verify that the addresses still hold the tokens the pack knows, print the policy, and register everything on the service. Run again, the setup registers nothing twice.

# Check every fact of the pack against the chain, at its pinned block
node packages/pack-stablecoins/dist/bin.js verify --network base-sepolia

# Print the payout policy for the service's configuration
node packages/pack-stablecoins/dist/bin.js policy --network base-sepolia \
  --beneficiaries 0xa1…,0xb0… --threshold 5 --cap USDC=12 \
  --approvers <approver id>

# Register the tokens, the ERP's webhook and the Transfer subscriptions
CRYPTORAIL_API_KEY=<admin key> \
node packages/pack-stablecoins/dist/bin.js setup --network base-sepolia \
  --url <service URL> --receiving <treasury> --webhook <ERP URL>
# It prints what it registered, and the webhook's secret once
Step 3

The payout policy

The rules read the payment itself, the beneficiary and the amount of the token transfer, in USDC, not in raw units. A payment the rules cannot read is refused, never let through.

import { BASE_SEPOLIA, payoutPolicy } from '@cryptorail/pack-stablecoins'

// The same document the command line prints, built in code
const policy = payoutPolicy({
  version: 'treasury-2026-10',      // recorded with every decision
  network: BASE_SEPOLIA,
  beneficiaries: [supplierA, supplierB],
  dailyCap: { USDC: '12' },          // in USDC, all payouts of the day
  approvalThreshold: '5',            // from 5 USDC an approver signs
  approverIds: [treasurerId],
})

// Service configuration:
// adapters: { policy: { kind: 'rules', options: policy } }
Step 4

A payout

One call from the ERP, keyed by the ERP’s own payment id. The policy decides before anything is signed; a refusal comes back as a failure report with a stable cause your systems can act on.

import { createClient } from '@cryptorail/sdk'

const cryptorail = createClient({ baseUrl, apiKey })

// The ERP's payment id as idempotency key: a retry never pays twice
const { data } = await cryptorail.POST('/v1/transactions', {
  params: { header: { 'idempotency-key': `payout-${erpPaymentId}` } },
  body: {
    chainId: 84532,                      // Base Sepolia
    actor: { kind: 'identity', identityId: treasuryId },
    target: {
      kind: 'contract',
      contractId: usdcId,                // registered by the pack
      function: 'transfer',
      args: [supplierA, '2000000'],      // 2 USDC, six decimals
    },
  },
})

const tx = await cryptorail.GET('/v1/transactions/{id}', {
  params: { path: { id: data!.transactionId } },
})
// tx.data.state: CONFIRMED, PENDING_APPROVAL or REJECTED_BY_POLICY
// An unlisted beneficiary: refused before anything is signed
{
  "state": "REJECTED_BY_POLICY",
  "failure": {
    "stage": "policy",
    "cause": "policy.operation-denied",
    …
  }
}
// Over the day's cap, the cause is "policy.limit-exceeded"
Step 5

The approval

A payout at or above the threshold waits, holding no nonce and no fee. The approver signs the typed data the service serves; the signature is checked before it counts.

// What the approver reads: the decoded call, beneficiary and amount
const approval = await cryptorail.GET('/v1/approvals/{transactionId}', {
  params: { path: { transactionId } },
})

// EIP-712 typed data with neither nonce nor fee: it never goes stale.
// Any wallet signs it, MetaMask included:
const signature = await wallet.request({
  method: 'eth_signTypedData_v4',
  params: [approverAddress, JSON.stringify(approval.data!.typedData)],
})

const signed = await cryptorail.POST(
  '/v1/approvals/{transactionId}/signatures',
  {
    params: { path: { transactionId } },
    body: { approverId: treasurerId, signature },
  },
)
// signed.data.quorumReached: the payout goes on to signing
Step 6

An incoming payment

Every USDC transfer to the treasury arrives as a signed webhook. Verify the signature, then apply each event once by its id; a reversal names the event it undoes.

import { verifyWebhook } from '@cryptorail/core'

// In the ERP's webhook endpoint, before trusting anything
const check = verifyWebhook([webhookSecret], {
  id: headers['webhook-id'],
  timestamp: headers['webhook-timestamp'],
  signatureHeader: headers['webhook-signature'],
  body: rawBody,
  nowSeconds: Math.floor(Date.now() / 1000),
})
if (!check.ok) return reply(401)    // never recorded

for (const event of JSON.parse(rawBody).events) {
  // event.type: 'contract.event', or 'contract.event-reverted'
  //   (with payload.originalEventId) if the chain took it back
  // event.payload: { contractLabel: 'pack-usdc',
  //   eventName: 'Transfer', params: { from, to, value }, … }
  // event.id is stable: apply each one exactly once
}
Demo

See it run, then try it on yours.

We show the pack on our sandbox, on a public test network where nothing of value moves. A pilot gives you your own: A pilot environment stood up in minutes with nothing installed on your side.