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.

Prerequisites

You need:

  • @zkp2p/cash@0.2.1 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-app",
});

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.

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.
  • 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.

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, and DepositReceived decoding. The returned depositId has the form escrowAddress_onchainId and is available from the confirmed receipt without waiting for the indexer.

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: "venmo",
currency: "USD",
payee: { offchainId: "@your-handle" },
},
},
{
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:

const plan = await cash.prepare({
amount: usdc(250),
receive: {
platform: "revolut",
currency: "EUR",
payee: { offchainId: "your-revtag" },
},
});

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);
await hostSigner.waitForReceipt(hash);
}

txs[] and steps[] use the same order. Depending on allowance and order state, steps may include approve, createDeposit, pruneExpiredIntents, withdrawDeposit, removeFunds, or addFunds.

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: "venmo",
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
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.