Peer Cash Integration Guide
This guide takes a Peer Cash integration from capability discovery through order recovery. Read the Peer Cash SDK overview first if you are deciding between @zkp2p/cash and the general-purpose @zkp2p/sdk.
Before adopting the upcoming fixed-fill release, read the fixed-fill migration guide. The unreleased change makes USD Venmo, PayPal, and Cash App use approved totals by default. Arbitrary amounts require an explicit flexible mode after upgrading.
Prerequisites
You need:
@zkp2p/cash@latestand a compatibleviem2.x release- Node 22 or newer for a Node runtime
- A Base RPC connection
- A Base wallet that controls the USDC being deposited
- A valid payee handle for the selected payment platform
- A source-chain wallet as well when routing a non-Base asset through Relay
Use a small, separately funded wallet for staging verification. Never put a private key in client-side code or commit it to source control.
1. Create the client
import { createCashClient } from "@zkp2p/cash";
import { http } from "viem";
const cash = createCashClient({
environment: "production",
transport: http("https://your-base-rpc.example"),
referrer: "your-analytics-code",
});
environment selects the matching contracts, indexer, and Curator service. Supported values are production, preproduction, and staging. Use indexerUrl, curatorUrl, or Relay options only when you intentionally operate custom infrastructure.
The client is read-capable without a signer. Pass a viem WalletClient to each mutating method, which keeps signing and user approval in the system that already owns them.
Earn from the flow you bring
An integration can receive Peer's direct integration share without an API key, a registration transaction, or a second wallet. Sign in to the Peer mobile or web app, copy the six-character code from the referral screen, and configure it once when you create the client:
const cash = createCashClient({
environment: "production",
referralCode: "ABC123",
});
The SDK normalizes the code and adds peer-ref-ABC123 to the ERC-8021
attribution on every prepared or signed transaction. The code remains owned by
the same Peer Privy account even if its displayed referral code is renamed
later, so an open deposit never loses its payout destination.
When liquidity from that deposit is filled, Curator includes a 50 bps fee for the code owner's Peer wallet, capped by the service fee already charged. This does not add a fee for the user. It is mutually exclusive with the maker referral ladder: a valid integration marker selects the direct integration share; a deposit without one continues to use the maker's Level 1/Level 2 relationships.
Use at most one referralCode per client/deposit. An unknown or conflicting
peer-ref-XXXXXX marker receives no integration share. The separate referrer
option remains analytics-only and may carry values such as your-app; it does
not select a fee recipient.
The integration code is the normal code already shown in Peer. The existing referral dashboard remains the self-serve place to view or rename it and to see wallet earnings; there is no separate integration enrollment flow.
2. Discover the live product boundary
Do not hardcode payment platforms, currencies, payee formats, amount limits, or Relay source assets.
const capabilities = cash.capabilities();
for (const platform of capabilities.platforms) {
console.log({
id: platform.platform,
currencies: platform.currencies,
payeeHint: platform.payeeHint,
requiresIdentityAttestation: platform.requiresIdentityAttestation,
});
}
console.log(capabilities.amount);
console.log(capabilities.pricing); // { kind: "oracle-market-rate", spreadBps: 0 }
The synchronous call includes the default Base USDC source and destination. Fetch Relay-supported EVM sources only when your interface needs them:
const capabilitiesWithSources = await cash.capabilities({
includeRelaySources: true,
});
console.log(capabilitiesWithSources.source.relay?.chains);
Relay support is live metadata, not a promise that every asset has a viable route at every moment. Request a fresh source quote before execution.
Payee verification
The Curator service validates payee data during cash-out preparation.
- Venmo, Revolut, Cash App, and Monzo registrations are checked against the live platform. The account must exist.
- Wise and PayPal require a signed identity attestation for a new payee registration. Peer Cash cannot create that attestation. A handle already registered through Peer can be reused.
- UPI accepts any valid UPI ID from any bank. It is locally format-checked and needs no bank login, identity attestation, extension, or prior payee registration. UPI is available in every environment without a rollout flag; buyers prove payment through Amazon Pay.
- Other listed platforms are format-checked according to their current integration.
Use payeeHint to shape input and handle PAYEE_VERIFICATION_REQUIRED explicitly. Do not hide Wise or PayPal unconditionally; an existing registration can still be valid.
X Money cash-outs
X Money supports USD since @zkp2p/cash@0.7.0. Install the current stable
release:
npm install @zkp2p/cash@0.7.3 viem
Use this payout leg with cashout() or prepare():
const receive = {
platform: "xmoney",
currency: "USD",
payee: "@YourHandle",
} as const;
Raw string payees are trimmed, stripped of one leading @, and lowercased.
Structured payee data must already use offchainId: "yourhandle".
The receiving account must have X Money enabled. Registration checks that the
X account exists, but does not verify X Money enrollment. No identity attestation
or Peer Pay merchant-policy follow-up is required for this payout leg.
Optional X Money receipt linking
Cash 0.7.3 adds the same prepare/open/status flow as Venmo for Gmail, personal
Outlook, and iCloud Mail. Enable X Money email notifications and receive a
payment before connecting the receipt inbox.
const link = await cash.prepareXMoneyConnect("@yourhandle");
// Bind directly to a click; prepare the account before enabling the button.
const onLinkXMoneyClick = () => cash.openXMoneyConnect(link);
// Re-check after completion, return, or dismissal.
const readConnection = () => cash.isXMoneyConnected(link.payeeDetails);
prepareXMoneyConnect() returns { payeeDetails, payeeUsername, url } and
accepts an optional { returnUrl } for native hosts. The hosted
/connect/xmoney page binds the registered hash to the normalized X username;
both stay in the URL fragment. Re-prepare when the selected handle changes.
openXMoneyConnect() rejects with XMoneyConnectError; handle its code and
message as in the Venmo example below. isXMoneyConnected() returns true
only for an active mailbox credential; service errors reject. Linking is
separate from cashout() and prepare() and uses the matching environment.
Optional Venmo receipt linking
Available since @zkp2p/cash@0.6.2; use 0.7.3 so iCloud Mail links report
as connected. Offer Link Venmo as a separate, optional account step.
cashout() and prepare() never open Gmail, check receipt credentials, or
require linking. This step needs no signer, wallet,
Peer login, extension, deposit, or payment.
import { createCashClient, VenmoGmailConnectError } from "@zkp2p/cash";
const cash = createCashClient({ environment: "production" });
// Prepare when the user selects their Venmo handle, before enabling the link button.
const link = await cash.prepareVenmoGmailConnect("@yourhandle");
const alreadyConnected = await cash.isVenmoGmailConnected(link.payeeDetails);
// Bind this function directly to the link button; do not await registration inside it.
async function onLinkVenmoClick() {
try {
await cash.openVenmoGmailConnect(link.payeeDetails);
console.log(await cash.isVenmoGmailConnected(link.payeeDetails));
} catch (error) {
if (error instanceof VenmoGmailConnectError) {
console.error(error.code, error.message); // Render this in your UI.
} else {
throw error; // Registration/status service failures also need visible handling.
}
}
}
| Method | Result |
|---|---|
prepareVenmoGmailConnect(handle, { returnUrl }?) | Registers the selected Venmo handle; returns { payeeDetails, url } with an optional app callback |
openVenmoGmailConnect(payeeDetails) | Opens the hosted flow; resolves { payeeDetails } or rejects with the hosted error |
isVenmoGmailConnected(payeeDetails) | true for an active Gmail, Outlook, or iCloud Mail credential; service errors reject |
The authoritative payee hash is already sent to the popup; the user does not
re-enter their Venmo handle. Re-prepare when the selected handle changes, and
keep persisted hashes scoped to their environment and user. An already-active
Gmail, Outlook, or iCloud Mail connection needs no reconnect.
isVenmoGmailConnected() treats google_oauth, microsoft_oauth, and
icloud_mail as linked through @zkp2p/sdk isMailboxSellerCredentialType.
Cash versions before 0.7.2 report iCloud Mail links as unlinked, and versions
before 0.6.3-rc.2 also report Outlook links as unlinked.
Bump @zkp2p/cash to 0.7.3 (it pins @zkp2p/sdk@0.14.10; bump a direct
@zkp2p/sdk dependency to match) and regenerate your lockfile. Venmo keeps the
same API; X Money adds its own prepare/open/status methods above.
The hosted page lists Gmail, including Google-hosted school and custom-domain
inboxes, personal Outlook, and iCloud Mail. Gmail and Outlook open the
provider's consent. iCloud Mail asks for the iCloud Mail address and an Apple
app-specific password, with steps to create one; the password goes from the
Peer page to Peer's attested enclave and never reaches your app. Do not reject
an email by its suffix: the hosted flow verifies actual Venmo receipts.
venmo_google_oauth_receipt_not_found (Gmail),
VENMO_MICROSOFT_OAUTH_RECEIPT_NOT_FOUND (Outlook), or
icloud_receipt_not_found (iCloud Mail) means verification found no matching
receipt; display the error and let the user choose the receipt inbox. Other
iCloud failures use icloud_<reason> codes, such as
icloud_app_password_rejected when Apple rejects the password. The hosted page states that the inbox needs at least one receipt for a Venmo payment
received in the past year (a friend sending $1 creates one), and explains how to
forward Venmo receipts from another mailbox to the inbox being connected, so
partners need no forwarding UI of their own.
VenmoGmailConnectError is separate from CashError. Emails, mailbox tokens,
app-specific passwords, and authorization codes never pass through the partner
app.
Desktop requests a popup; mobile requests a new browser tab. The browser
controls presentation. On return, or after connection_closed /
connection_timeout, call isVenmoGmailConnected() before offering another
attempt: losing the opener does not prove verification failed. Native or
redirect hosts can open link.url and read status on return. Status reads
cannot recover attempt-specific receipt or consent errors.
environment selects the matching hosted origin: app.peer.xyz in production,
ramp-preprod.peer.xyz in preproduction, and ramp-staging.peer.xyz in staging.
Use peerOrigin / curatorUrl only for intentional overrides, and ensure
Curator permits your partner origin. A localhost partner demo can use the
production hosted flow; only a locally hosted Peer callback needs its own
Google redirect-URI registration. Test the return flow in the mobile browsers
your app supports.
See the browser example.
Native app callback
The hosted page supports an optional callback URI, with no registration with
Peer or environment setting. In @zkp2p/cash@0.6.2, pass it when preparing
the link:
import { createCashClient } from "@zkp2p/cash";
import * as WebBrowser from "expo-web-browser";
const cash = createCashClient({ environment: "production" });
const returnUrl = "partner-app://venmo-linked"; // Scheme registered in your app binary
const link = await cash.prepareVenmoGmailConnect("@yourhandle", { returnUrl });
await WebBrowser.openAuthSessionAsync(link.url, returnUrl);
const connected = await cash.isVenmoGmailConnected(link.payeeDetails);
Preparation can also run on your backend. openVenmoGmailConnect() uses
browser popup APIs; native apps open link.url through their system browser
session. The lower-level getVenmoGmailConnectUrl({ payeeDetails, returnUrl })
builder is available in @zkp2p/sdk@0.14.3.
To keep Cash out of your Expo app, construct the hosted URL directly:
import * as WebBrowser from "expo-web-browser";
const returnUrl = "partner-app://venmo-linked"; // Scheme registered in your app binary
const connectUrl = new URL("https://app.peer.xyz/connect/venmo");
connectUrl.searchParams.set("returnUrl", returnUrl);
connectUrl.hash = payeeDetails;
await WebBrowser.openAuthSessionAsync(connectUrl.toString(), returnUrl);
// Re-check seller-credential/status through your backend on return or dismissal.
For Expo's native auth session, register the callback scheme in your app binary
and test on iOS and Android; see
Expo's callback requirements.
Google or Microsoft still returns to Peer. After verification, Peer navigates
to your URI unchanged, without appending credentials or a success result. A Return to
your app button remains available if navigation is blocked. Errors stay
visible with a manual return; closing or cancelling the browser also requires
status reconciliation. Treat the account as linked only when the status endpoint
returns status: "active" and a mailbox credentialType: "google_oauth",
"microsoft_oauth", or "icloud_mail" (isMailboxSellerCredentialType in
@zkp2p/sdk 0.14.7).
An omitted or empty returnUrl preserves the close attempt after verification:
script-opened desktop popups can close, while mobile browser tabs that block
closure keep the success page visible for the user to dismiss.
Customize the hosted popup
With @zkp2p/cash@0.6.2, configure branding once on the client. The same
appearance is used by link.url and openVenmoGmailConnect().
const cash = createCashClient({
environment: "production",
venmoGmail: {
appearance: {
logoUrl: "https://your-app.example/logo.png",
logoAlt: "Your app",
backgroundColor: "#FFFFFF",
textColor: "#172033",
secondaryTextColor: "#636B7A",
buttonColor: "#6246EA",
buttonTextColor: "#FFFFFF",
fontFamily: "Inter, sans-serif",
headingTextTransform: "none",
buttonTextTransform: "none",
buttonBorderRadius: 12,
buttonHeight: 52,
},
popup: { width: 500, height: 620 },
},
});
Your hosted logo appears beside Venmo with three connecting dots. Without a
partner logo, the page shows Peer beside Venmo. Colors use
hex values and dimensions use pixels. Optional fontUrl accepts a hosted
WOFF/WOFF2 font with cross-origin loading enabled. Popup dimensions apply to
desktop; mobile browsers control the new tab's size. The same text, border,
focus, and font settings style the iCloud Mail step's fields.
Styling never changes consent, account selection, or receipt verification.
See the full appearance options.
Protected payout rails
Venmo and PayPal use a deposit whitelist to restrict intent signaling to
approved Peer Pay buyers by default. This access rule is separate from stake-backed dispute protection.
Both methods currently have nonzero risk windows and default-on protection, so
hosts must not readiness-gate deposit creation or submit a redundant
setDisputeProtectionEnabled(true) transaction. Cash App is non-chargebackable,
stays public, and does not require stake.
Generic cashout() works with any viem wallet, including an external EOA. It
creates the deposit, then submits the method-scoped Peer Pay policy with the
same wallet. prepare() returns accessPolicyPaymentMethods so a custody-
separated host can submit the same follow-up after finalizing the receipt.
Peer web supports this sequence for external wallets, with a separate wallet
confirmation for deposit whitelisting after deposit creation. Embedded wallets
batch creation and whitelisting when atomic execution is available. Dispute
protection is independent of whether the whitelist is configured atomically.
Do not present a sequential cash-out as ready until every required policy
transaction has confirmed. If a policy transaction is rejected, retry setup on
the existing deposit instead of creating another one.
3. Show an honest estimate
Amounts use token base units. Base USDC has six decimals, so the usdc() helper is the safest way to construct common values.
import { usdc } from "@zkp2p/cash";
const estimate = await cash.estimate({
amount: usdc(250),
currency: "EUR",
platform: "revolut",
});
console.log({
kind: estimate.kind, // "oracle-estimate"
receiveAmount: estimate.receiveAmount,
rate: estimate.rate,
stale: estimate.stale,
eta: estimate.eta,
});
The estimate is idempotent and has no transaction side effects. The returned rate is a current oracle reading for display; the binding rate resolves when a buyer fills. Render it with approximate language. If stale is true, warn the user or suppress the rate until a fresh oracle read is available.
eta, when present, is a historical observation from recent comparable fills. It is not a countdown, service-level commitment, or guarantee.
4. Start a Base USDC cash-out
import { createWalletClient, custom } from "viem";
import { base } from "viem/chains";
import { usdc } from "@zkp2p/cash";
const signer = createWalletClient({
account: makerAddress,
chain: base,
transport: custom(window.ethereum),
});
const result = await cash.cashout(
{
amount: usdc(250),
receive: {
platform: "revolut",
currency: "EUR",
payee: { offchainId: "your-revtag" },
},
},
{ signer },
);
await ordersTable.insert({
userId,
depositId: result.depositId,
depositTransactionHash: result.txHash,
});
cashout() handles payee registration, any required USDC approval, deposit creation, receipt confirmation, DepositReceived decoding, and the Peer Pay policy follow-up for restricted payout rails. The returned depositId has the form escrowAddress_onchainId and is available from the confirmed receipt without waiting for the indexer.
Every deposit uses the paid intent guardian. For Venmo and PayPal, the result also includes the confirmed access-policy transaction hashes. For a method with a nonzero risk window, dispute protection remains the method-scoped contract default and requires no additional transaction.
Offer several currencies or platforms
A payout leg can list several currencies, and receive accepts an array of legs across different payment platforms. Both widen the pool of buyers who can fill the order; the buyer picks the option they can pay, and every option fills at the live zero-spread oracle rate for its currency.
const result = await cash.cashout(
{
amount: usdc(250),
receive: [
{
platform: "revolut",
currencies: ["EUR", "GBP"],
payee: { offchainId: "your-revtag" },
},
{
platform: "chime",
currency: "USD",
payee: { offchainId: "$your-chime-sign" },
},
],
},
{ signer },
);
Each platform may appear at most once; a duplicate or empty leg set fails with INVALID_PAYOUT_PLATFORMS before any wallet interaction. Multi-leg orders work identically through prepare() and the cash_cashout agent tool, and order().payouts reports one entry per platform-currency pair.
Persist at least:
- your user or account identifier
depositId- the Base transaction hash
- source-route evidence when a Relay path was used
The SDK has no session database. Your userId → depositId mapping is enough to resume the protocol state later.
5. Observe and reconcile the order
Read once with order():
const order = await cash.order(depositId);
console.log(order.state);
console.log(order.nextActions);
console.log(order.explain());
Or watch until the order becomes terminal:
for await (const order of cash.watch(depositId, {
pollIntervalMs: 5_000,
timeoutMs: 30 * 60_000,
})) {
await saveOrderSnapshot(order);
if (!order.isInFlight) break;
}
An immediate ORDER_NOT_FOUND after a successful cash-out normally means the indexer has not observed the confirmed deposit yet. Keep the receipt and retry the read. watch() and useOrder() absorb this short indexing window.
Treat fulfilled fills as receipts. Reconcile against their verified fiatPaid, paidCurrency, paymentId, paidAt, and releasedAmount values rather than recomputing settlement from the original estimate.
6. Withdraw or top up
There is one unwind verb:
const order = await cash.order(depositId);
if (order.nextActions.includes("withdraw")) {
const result = await cash.withdraw(depositId, { signer });
console.log(result.withdrawTxHash);
}
Without an amount, withdraw() closes what remains of the order. It prunes expired buyer intents first when required. A live buyer intent blocks a full close because the buyer may still complete the payment.
Withdraw only unlocked funds by passing an amount:
await cash.withdraw(depositId, {
signer,
amount: usdc(25),
});
Add funds to a live order without changing its payee or market-rate pricing:
await cash.topUp(depositId, usdc(100), { signer });
7. Route another EVM asset through Relay
The high-level source path is exact-input by default. amount is expressed in the source token's base units. Peer Cash routes that asset into canonical Base USDC, then creates the cash-out order with Relay's guaranteed minimum output.
const result = await cash.cashout(
{
amount: sourceAmount,
source: {
chainId: sourceChainId,
currency: sourceTokenAddress,
tradeType: "EXACT_INPUT",
},
receive: {
platform: "chime",
currency: "USD",
payee: { offchainId: "$your-chime-sign" },
},
},
{
signer: baseSigner,
sourceSigner,
onSourceProgress: (progress) => saveRelayProgress(progress),
},
);
await saveCashOrder({
depositId: result.depositId,
baseTransactionHash: result.txHash,
relayRequestId: result.source?.requestId,
guaranteedBaseUsdc: result.source?.amount,
relayTransactions: result.source?.transactions,
});
result.source.amount is the guaranteed minimum Base USDC output and the exact amount deposited into the cash-out order. It is not the route's eventual actual output. Non-Base sources require sourceSigner; signer remains the Base wallet that owns the resulting cash-out deposit.
Routes that need more than one source-chain transaction (an ERC-20 approve, then the route transaction) require a nonce-managed local source signer: create it with privateKeyToAccount(pk, { nonceManager }) from viem. Without one, the SDK refuses the route preflight with SOURCE_NONCE_MANAGER_REQUIRED before any transaction is submitted, instead of letting the route transaction reuse the approval's nonce and revert mid-route. Browser wallets are unaffected.
Persist the Relay request ID and origin/destination transaction evidence. If Relay completed but Base cash-out creation failed, retry only the Base USDC step with the recovery amount. If Base submission or receipt status is unknown, inspect the supplied transaction evidence before sending anything again.
8. Use unsigned transaction plans
When signing happens in an account-abstraction wallet, policy engine, remote signer, or agent host, use the Base-USDC prepare methods:
import type { TransactionReceipt } from "viem";
const plan = await cash.prepare({
amount: usdc(250),
receive: {
platform: "revolut",
currency: "EUR",
payee: { offchainId: "your-revtag" },
},
});
let createDepositReceipt: TransactionReceipt | undefined;
for (let index = 0; index < plan.txs.length; index += 1) {
const transaction = plan.txs[index];
const step = plan.steps[index];
await policy.review({ step, transaction });
const hash = await hostSigner.sendTransaction(transaction);
const receipt = await hostSigner.waitForReceipt(hash);
if (step.kind === "createDeposit") createDepositReceipt = receipt;
}
if (!createDepositReceipt) throw new Error("createDeposit receipt missing");
const result = cash.finalizePreparedCashout(createDepositReceipt);
for (const paymentMethod of plan.accessPolicyPaymentMethods) {
await submitAndConfirm(
cash.prepareAccessPolicy(result.depositId, paymentMethod),
);
}
await saveCashOrder({
depositId: result.depositId,
transactionHash: result.txHash,
});
txs[] and steps[] use the same order. Depending on allowance and order state, steps may include approve, createDeposit, pruneExpiredIntents, withdrawDeposit, removeFunds, or addFunds.
For Venmo and PayPal, every entry in
accessPolicyPaymentMethods is mandatory. Submit each policy with the
depositor after the deposit confirms, and never recreate the cash-out after a
policy failure. Method-scoped dispute protection defaults on only when the
method has a nonzero risk window; there is no protection follow-up.
prepare() is Base-USDC only. A custody-separated host must quote, execute, and confirm Relay in its own signing runtime before preparing the resulting Base USDC cash-out. Do not pass source to prepare().
For tool-use systems, import the versioned JSON-schema manifest:
import { cashToolManifest } from "@zkp2p/cash/tools";
for (const tool of cashToolManifest.tools) {
registerTool(tool);
}
Mutating tools return unsigned Base transactions. The host retains custody, policy checks, user approval, ordered submission, and receipt confirmation.
9. Add React hooks
Create one stable client and pass it into the optional hook layer:
import { createCashClient, usdc } from "@zkp2p/cash";
import { useCashout, useEstimate, useOrder } from "@zkp2p/cash/react";
import type { WalletClient } from "viem";
const cash = createCashClient({ environment: "production" });
type CashOutProps = {
signer: WalletClient | null;
depositId: string | null;
};
function CashOut({ signer, depositId }: CashOutProps) {
const { estimate, isLoading: isEstimating } = useEstimate({
client: cash,
amount: usdc(100),
currency: "USD",
platform: "chime",
refreshIntervalMs: 30_000,
});
const mutation = useCashout({ client: cash, signer });
const lifecycle = useOrder({ client: cash, depositId });
return (
<section>
<p>
{isEstimating
? "Reading the market rate…"
: `Approximately ${estimate?.receiveAmount ?? "—"} USD`}
</p>
<p>{lifecycle.order?.explain() ?? "No active cash-out"}</p>
{mutation.error ? <p role="alert">{mutation.error.message}</p> : null}
</section>
);
}
useOrder() continues through immediate indexer lag and stops polling after a terminal state. useOrders() builds a wallet's order history and in-flight count. Keep WalletClient identities stable and never treat a React remount as evidence that an on-chain operation failed.
10. Handle typed failures
import { isCashError } from "@zkp2p/cash";
try {
await cash.cashout(input, { signer, sourceSigner });
} catch (error) {
if (!isCashError(error)) throw error;
logger.warn({
code: error.code,
retryable: error.retryable,
remediation: error.remediation,
recovery: error.recovery,
});
}
The most important recovery boundaries are:
| Code | Action |
|---|---|
ORDER_NOT_FOUND immediately after cash-out | Retry the read; keep the confirmed receipt and never recreate the deposit |
PAYEE_VERIFICATION_REQUIRED | Register the new Wise or PayPal handle through Peer, or reuse an existing registered handle |
ACCESS_POLICY_CONFIGURATION_FAILED | The deposit exists; inspect the policy recovery data and never recreate it |
ACTIVE_INTENT_BLOCKS_WITHDRAWAL | Wait for delivery or expiry; retry the full withdrawal only when the order permits it |
SOURCE_NONCE_MANAGER_REQUIRED | Preflight, nothing submitted; recreate the source signer with viem's nonceManager |
SOURCE_ROUTE_COMPLETED_CASHOUT_FAILED | Do not route again; retry a Base-USDC-only cash-out with BigInt(error.recovery.amount) |
SOURCE_CASHOUT_SUBMISSION_UNKNOWN | Inspect Base wallet activity and orders before any retry |
SOURCE_CASHOUT_STATUS_UNKNOWN | Inspect error.recovery.depositTxHash; do not resubmit while receipt status is unknown |
TRANSACTION_SUBMISSION_UNKNOWN | Treat the call as potentially broadcast and inspect wallet/protocol state |
TRANSACTION_STATUS_UNKNOWN | Inspect error.recovery.transactionHash before resubmitting |
INDEXER_UNAVAILABLE or ORACLE_READ_FAILED | Retry the failed read only |
SIGNER_CHAIN_MISMATCH | Switch the signer to the required chain and obtain a fresh Relay quote if applicable |
The remediation field is written for direct use in logs, operator interfaces, and agent results. Serialize CashError with error.toJSON().
11. Serialize across process boundaries
Cash orders contain bigint values and an explain() method, so plain JSON.stringify(order) is not a persistence format. Use the exported codecs:
import { orderFromJson, orderToJson } from "@zkp2p/cash";
const wireOrder = orderToJson(order);
await cache.set(depositId, JSON.stringify(wireOrder));
const restored = orderFromJson(JSON.parse(await cache.get(depositId)));
console.log(restored.explain());
Equivalent codecs are available for capabilities, estimates, prepared transactions, Relay results, and buyer profiles.
12. Verify on staging
Before shipping, prove the maker-controlled lifecycle with a small funded wallet:
- Create a 1–2 USDC Base-USDC cash-out with
environment: "staging". - Save the
depositIdand transaction hash. - Retry through indexer lag until
order(depositId)isawaiting-buyer. - Confirm
orders(owner)includes the deposit. - Withdraw without waiting for a buyer.
- Confirm the order becomes
returnedand the wallet's USDC is restored, excluding gas.
If you support Relay sources, repeat with one live source returned by capabilities({ includeRelaySources: true }). Persist the Relay request and transaction evidence, confirm the Base order appears, then withdraw it.
Stop and investigate if a withdrawal fails with funds apparently stuck. Do not retry transactions blindly.