Collateral & withdrawal
Contract §1, continued. Section numbers are contract-wide and never change; the contract overview maps every section to its page.
Conventions — authentication, the response envelope, idempotency, retry safety, pagination, rate limits — are defined in the Partner Integration Guide and apply to every endpoint here.
These three endpoints hang off /clients/:id and were published under the Clients section.
They live on their own page because they are the money path: reading a balance, reading the
on-chain coordinates, and obtaining a signature that moves collateral.
Withdrawing collateral, end to end
Withdrawal takes two signatures: the issuer's, which only the service can obtain, and yours as the contract admin, which only you can produce. The service never signs your half and never moves funds — you submit the transaction.
Verified live on Base Sepolia, 2026-08-28. Every step below was executed against the sandbox and a real V2 contract, twice, ending in two confirmed on-chain transfers. Where a value below differs from what you might infer from the issuer's own documentation, the measured behaviour is what is published here.
Five steps. Steps 1 and 2 are the service API calls; 3 to 5 happen in your backend, against the chain.
Step 1 — read the contract coordinates, once
GET /clients/:id/collateral-contract returns everything the later steps need:
chainId, contractVersion, collateralContract, coordinatorContract, adminAddresses[]
and tokens[]. It is static — read it once and cache it.
Do this as a separate call, not as part of a withdrawal. The authorization in step 2 is short-lived and one-at-a-time per client; spending part of that window reading an address you could have cached is the cheapest mistake on this page to avoid.
Two values decide everything downstream:
contractVersion—V2needs your admin signature and a ten-argumentwithdrawAsset.V1takes the issuer's signature alone and a shorter list. Read it; do not assume.adminAddresses[]— the wallets allowed to withdraw. The wallet submitted asinitialUser.walletAddresswhen the client was created becomes an admin of the contract, and there is no API to add another. If that key is lost, the collateral is stranded.
Step 2 — request the withdrawal authorization
POST /clients/:id/withdrawal-authorization with amount, token, admin and optionally
recipient. You get back the issuer's signature and the six values that travel with it.
Request it when you are ready to submit, not in advance: the authorization expires in
5 to 7 minutes and the length varies between calls, so read expiresAt rather than
budgeting a fixed number.
Two errors here mean different things and are worth branching on:
WITHDRAWAL_ADMIN_INVALID(422) — theadminis not on this contract. The service checks this before calling the issuer, deliberately: the issuer does not check it, so an unvalidated wrong wallet would return an authorization that can never be spent while still holding your client's only slot for its full lifetime.WITHDRAWAL_AUTH_NOT_READY(409) — the issuer will not sign yet, most often a cool-down of about 40 seconds that runs after the previous authorization expires. Nothing of yours is wrong; retry shortly.
Step 3 — sign the admin half (V2 only)
Read adminNonce() on the collateral contract and sign an EIP-712 Withdraw message with
your admin wallet. The nonce increments on every successful withdrawal, so read it fresh each
time — a stale nonce reverts.
import { toHex } from 'viem';
const adminSalt = toHex(crypto.getRandomValues(new Uint8Array(32))); // fresh, per signature
const nonce = await publicClient.readContract({
address: contract.collateralContract,
abi: [{ name: 'adminNonce', type: 'function', stateMutability: 'view',
inputs: [], outputs: [{ type: 'uint256' }] }],
functionName: 'adminNonce',
});
const adminSignature = await walletClient.signTypedData({
domain: {
name: 'Collateral',
version: '2',
chainId: contract.chainId,
verifyingContract: contract.collateralContract, // NOT the coordinator
salt: adminSalt,
},
types: {
Withdraw: [
{ name: 'user', type: 'address' },
{ name: 'asset', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'nonce', type: 'uint256' },
],
},
primaryType: 'Withdraw',
message: {
user: adminAddress, // the signer itself
asset: authorization.token,
amount: BigInt(authorization.amount), // base units, exactly as published
recipient: authorization.recipient,
nonce,
},
});
verifyingContract is the collateral contract, not the coordinator. The coordinator is
only where the transaction is sent. Mixing the two produces a signature that verifies against
nothing, and the revert names no field.
Step 4 — submit withdrawAsset
Call the coordinator, carrying both signatures. Five of the seven published fields go to
the chain untouched; two change encoding, and both fail the same silent way — the contract
reverts InvalidSignature() and names nothing:
const a = authorization; // the step-2 response
const args = [
a.collateralContract, // as published
a.token, // as published
BigInt(a.amount), // as published — base units, never re-derived
a.recipient, // as published
BigInt(Math.floor(new Date(a.expiresAt).getTime() / 1000)), // ISO-8601 → unix SECONDS
`0x${Buffer.from(a.salt, 'base64').toString('hex')}`, // base64 → bytes32
a.signature, // as published
[adminSalt], // yours, from step 3
[adminSignature], // yours, from step 3
true, // directTransfer
];
// Simulate first. A revert here is free and names its reason; a blind send burns gas AND the
// authorization window, and you cannot request a replacement until the old one expires.
const { request } = await publicClient.simulateContract({
account, address: contract.coordinatorContract,
abi: coordinatorAbi, functionName: 'withdrawAsset', args,
});
const hash = await walletClient.writeContract(request);
On V1 the call takes the issuer's signature alone: drop [adminSalt], [adminSignature] and
directTransfer, and skip step 3 entirely.
Step 5 — confirm
Wait for the receipt, then poll GET /clients/:id/funding — spendingPower falls once the
issuer observes the transfer. There is no withdrawal webhook, so polling is the only
confirmation the service offers; the chain receipt is the authoritative one.
Endpoint reference
The three endpoints the walkthrough uses, in full.
GET /clients/:id/funding · Beta
Beta, read-only. This endpoint is live and was verified end to end on the sandbox (funded pool → live deposit address + spending power, 2026-08-26). The budget-card capability it serves — fund the client pool, cap each card with a spending limit — is not yet verified end to end in production: the production decline battery has not run. The shapes and error codes below are stable; treat limit enforcement behaviour as provisional until production verification is announced.
{
"depositAddress": { "network": "base-sepolia", "address": "0x…" },
"spendingPower": { "amount": "1250.00", "currency": "USD" },
"asOf": "2026-08-26T04:12:00.000Z"
}
You fund the deposit address directly on the named network. There is no funding write endpoint.
depositAddress.networkvocabulary (each value ships only with dated per-chain verification):base-sepolia,solana-devnet(sandbox). Production labels publish in an announcement as each chain is verified. Match exact strings; new values are additive.spendingPower.amountis a signed 2-dp decimal string and can be negative — posted charges can exceed the funded pool.asOfis the service's clock at snapshot assembly (the issuer sends no timestamp). The read is always live — nothing is cached or stored, which is also why it is rate limited: 6/min per client plus a shared global budget; over either →429 FUNDING_RATE_LIMITED.- Poll this endpoint — polling is required, not a fallback:
spendingPowerchanges on card spend, spend events are not delivered as webhooks today, andcontract.*events are ignored at ingest (§6). There is no funding webhook to wait for. - Errors, all side-effect-free and retry-safe:
FUNDING_NOT_READY(409 — the issuer is still provisioning; normally minutes, so if it persists for hours contact support with your client id, as some causes need service-side action),FUNDING_AMBIGUOUS(409 — more than one funding contract is registered; contact support, do not retry until resolved),FUNDING_UPSTREAM_UNAVAILABLE(502 — retry with backoff),FUNDING_READ_FAILED(502 — retry with backoff; escalate with the request id if it persists),FUNDING_RATE_LIMITED(429 — retry after the current minute window).
GET /clients/:id/collateral-contract
200. The contract coordinates you need to build a withdrawal. Static — the addresses
change only if the issuer redeploys — so read it once and cache it. It is separate from the
authorization below precisely so that reading an address costs you nothing: the
authorization is rate limited and holds a short-lived lock, this is neither.
Response:
{
"chainId": 84532,
"network": "base-sepolia",
"contractVersion": 2,
"collateralContract": "0x…", // your EIP-712 verifyingContract — NOT the coordinator
"coordinatorContract": "0x…", // where you send withdrawAsset (the issuer's controllerAddress)
"adminAddresses": ["0x…"], // who may withdraw
"tokens": [{ "address": "0x…", "symbol": "rUSD", "decimals": 6, "balance": "100.0" }]
}
contractVersiondecides your on-chain call. V2 needs your admin EIP-712 signature alongside the issuer's; V1 takes the issuer's alone and a shorter argument list. Read it — do not assume.symbolanddecimalsare supplied by the service, not by the issuer, from a vetted token map. Every other field is relayed.
POST /clients/:id/withdrawal-authorization
201, and no Idempotency-Key — see the repeat rule below; the issuer is already
idempotent on identical values.
Withdrawal needs two signatures — the issuer's and yours as the contract admin — and the issuer's comes from a call only the service can make. This endpoint relays the issuer's withdrawal authorization. The service never signs your admin message and never moves funds; you add your admin EIP-712 signature and submit the transaction yourself.
Body:
| Field | Req | Value |
|---|---|---|
amount | ✔ | decimal string in the token's own units, e.g. "50.00" for 50 rUSD — not cents, not base units |
token | ✔ | which collateral token to withdraw; one of the addresses from GET /clients/:id/collateral-contract |
admin | ✔ | the admin wallet you will sign with. Must be one of adminAddresses — the service verifies this before relaying |
recipient | destination wallet; defaults to admin. May be any address |
Response — every field is an argument of the on-chain call, in the order it is consumed:
{
"collateralContract": "0x…", // parameters[0] · also your EIP-712 verifyingContract
"token": "0x…", // parameters[1]
"amount": "50000000", // parameters[2] · the token's BASE UNITS, as a string
"recipient": "0x…", // parameters[3]
"expiresAt": "2026-08-28T04:29:01.000Z", // parameters[4] on-chain, as unix seconds
"salt": "FXGd/9Xdqf…", // parameters[5] · BASE64 — decode before use
"signature": "0x…" // parameters[6] · hex, 65 bytes
}
saltis base64, not hex. Decode it to bytes before passing it to the contract.amountcomes back in base units even though you send token units — that is the value the issuer signed, so pass it through unchanged. Never re-derive it.expiresAtis short-lived and NOT a fixed length. Measured between 5 and 7 minutes on the same client. Budget for five, readexpiresAtrather than assuming a duration, and request the authorization when you are ready to submit — not in advance.
What you do with it is step 3 and
step 4 above: sign the admin EIP-712 Withdraw message
against the collateral contract, then call withdrawAsset on the coordinator with
both signatures. The two encoding conversions and the simulate-first advice are there too.
One authorization at a time, per client. A second request with different values while
one is live answers WITHDRAWAL_AUTH_CONFLICT; the response tells you how long to wait, and
that wait is an upper bound. A repeat with identical values is safe and returns the same
authorization, so retrying a timed-out request costs nothing.
Confirm a completed withdrawal by polling GET /clients/:id/funding — spendingPower falls.
There is no withdrawal webhook.
Errors: SPENDING_POWER_INSUFFICIENT (422 — the contract holds less of that token than you
asked for; lower the amount), WITHDRAWAL_AUTH_CONFLICT (409 — a different authorization is
already active for this client; retry after it clears or expires).