Skip to main content

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.

Upcoming breaking change

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@latest and a compatible viem 2.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.

Existing Peer referral account

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.
}
}
}
MethodResult
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.

Upgrading to 0.7.3

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.

Never repeat an uncertain source route

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:

CodeAction
ORDER_NOT_FOUND immediately after cash-outRetry the read; keep the confirmed receipt and never recreate the deposit
PAYEE_VERIFICATION_REQUIREDRegister the new Wise or PayPal handle through Peer, or reuse an existing registered handle
ACCESS_POLICY_CONFIGURATION_FAILEDThe deposit exists; inspect the policy recovery data and never recreate it
ACTIVE_INTENT_BLOCKS_WITHDRAWALWait for delivery or expiry; retry the full withdrawal only when the order permits it
SOURCE_NONCE_MANAGER_REQUIREDPreflight, nothing submitted; recreate the source signer with viem's nonceManager
SOURCE_ROUTE_COMPLETED_CASHOUT_FAILEDDo not route again; retry a Base-USDC-only cash-out with BigInt(error.recovery.amount)
SOURCE_CASHOUT_SUBMISSION_UNKNOWNInspect Base wallet activity and orders before any retry
SOURCE_CASHOUT_STATUS_UNKNOWNInspect error.recovery.depositTxHash; do not resubmit while receipt status is unknown
TRANSACTION_SUBMISSION_UNKNOWNTreat the call as potentially broadcast and inspect wallet/protocol state
TRANSACTION_STATUS_UNKNOWNInspect error.recovery.transactionHash before resubmitting
INDEXER_UNAVAILABLE or ORACLE_READ_FAILEDRetry the failed read only
SIGNER_CHAIN_MISMATCHSwitch 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:

  1. Create a 1–2 USDC Base-USDC cash-out with environment: "staging".
  2. Save the depositId and transaction hash.
  3. Retry through indexer lag until order(depositId) is awaiting-buyer.
  4. Confirm orders(owner) includes the deposit.
  5. Withdraw without waiting for a buyer.
  6. Confirm the order becomes returned and 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.