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.1and 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-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.
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:
| 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 |
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.