Route Relay-supported EVM assets or NEAR Intents 1Click external deposits into Base USDC, then cash out to fiat on Venmo, Revolut, Wise, Zelle, and more at the live Chainlink market rate, with zero spread and no centralized off-ramp provider.
Peer Cash is an offramp-only SDK for the ZKP2P protocol. The cashing-out user is the maker: their USDC becomes a deposit in the protocol contracts, a buyer pays them fiat and proves the payment, and the SDK gives the integrator a small set of typed verbs plus readable order state. No hosted widget, no provider custody, no quote engine to maintain.
npm · Lifecycle and recovery · Agent integration manual
npm install @zkp2p/cash viemimport { createCashClient, usdc } from '@zkp2p/cash';
const cash = createCashClient({ environment: 'production' });
const est = await cash.estimate({ amount: usdc(1000), currency: 'USD' });
// { rate: 1, receiveAmount: 1000, kind: 'oracle-estimate', eta: { seconds, label } }
// "≈", never a locked quote. Base USDC remains the default source.
// Progressive UI: render rate/receive first, then resolve the exact pair ETA.
const rateOnly = await cash.estimate(
{ amount: usdc(1000), currency: 'USD' },
{ includeEta: false },
);
const fillStats = await cash.fillStats();
const pairStats = fillStats['venmo:USD'];
const multiCurrencyStats = fillStats['revolut:EUR+GBP+USD'];
const { depositId, accessPolicyTxHash } = await cash.cashout(
{
amount: usdc(1000),
receive: { platform: 'venmo', currency: 'USD', payee: '@you' },
},
{ signer }, // any viem WalletClient on Base, including an EOA
);
// Venmo, Cash App, and PayPal return only after their access policy confirms.
console.log(depositId, accessPolicyTxHash);
// One method can offer several currencies. The buyer chooses the fill
// currency, and each option resolves at its own live oracle rate.
const fastFill = await cash.cashout(
{
amount: usdc(1000),
receive: {
platform: 'revolut',
currencies: ['EUR', 'GBP', 'USD'],
payee: { offchainId: 'revtag' },
},
},
{ signer },
);
// One order can also offer several platforms (each at most once). The buyer
// picks the leg they can pay; every leg fills at the live oracle market rate.
const widestReach = await cash.cashout(
{
amount: usdc(1000),
receive: [
{ platform: 'venmo', currency: 'USD', payee: '@you' },
{ platform: 'revolut', currencies: ['EUR', 'GBP'], payee: { offchainId: 'revtag' } },
],
},
{ signer },
);
for await (const order of cash.watch(depositId)) {
console.log(order.state, order.explain());
if (order.state === 'delivered') break;
}Peer Cash and the general ZKP2P SDK serve different integration depths:
| Package | Use it when | Boundary |
|---|---|---|
@zkp2p/cash |
Cash-out is the product | Offramp only. The user is always the maker, the destination is Base USDC, pricing is the live Chainlink rate at fill with zero spread, and the SDK owns the resumable order lifecycle. |
@zkp2p/sdk |
You are composing directly with the Peer protocol | General maker and taker operations, deposits, intents, proofs, quotes, vaults, rate managers, referrals, hooks, and API helpers. Your application owns the workflow and protocol choices. |
Peer Cash is a narrow facade over @zkp2p/sdk, not a replacement for it. It
cannot express custom spreads, buyer-side proof flows, vaults, disputes, or
arbitrary protocol operations.
| Verb | What it does |
|---|---|
capabilities() |
Sync discovery: Base USDC destination/default source, platforms × currencies × payee hints × amount bounds |
capabilities({ includeRelaySources: true }) |
Async discovery: adds live Relay SDK EVM source chains/tokens |
capabilities({ includeNearIntentsSources: true }) |
Async discovery: adds live NEAR Intents 1Click source assets |
fillStats() |
Cached 30-day fill counts and median first-fill time per exact platform:currency pair or sorted multi-currency set |
quoteSource(input) / executeSourceQuote(quote, { signer }) |
Relay SDK EVM source routing into Base USDC before cashout |
relayStatus(requestId) |
Relay request status from the Relay SDK request path |
quoteNearIntentsSource(input) |
Signed 1Click quote with an origin-chain deposit address and optional memo |
submitNearIntentsDeposit(input) / nearIntentsStatus(input) |
Optionally register an origin tx, then track 1Click delivery/refund evidence |
estimate({ amount, currency }, { includeEta? }) |
Base USDC oracle estimate; optionally skip the historical ETA for progressive rendering |
cashout(input, { signer }) |
Creates the order with any viem wallet; Venmo, Cash App, and PayPal then attach the canonical access groups |
prepare(input) / finalizePreparedCashout(receipt) |
Prepare external signing, resolve the deposit, then check accessPolicyRequired for the follow-up |
prepareAccessPolicy(depositId) |
Prepare the post-deposit Plus, Pro, Peer Makers, and Peer Pay policy transaction |
order(depositId) / orders(owner) |
Resume any order from its id alone; list all orders for a wallet |
watch(depositId) |
Async iterator: yields on every state change until terminal, abort, or timeout |
withdraw(depositId, { signer, amount? }) |
The ONE unwind verb - partial with an amount (live intents don't block it), full close without (prunes expired intents first) |
topUp(depositId, amount, { signer }) |
Add USDC to a live order - same payee, same market rate |
buyer(address) |
A buyer's track record from their intent history - who just matched your order? |
Base-USDC cashout, withdraw, and top-up have unsigned counterparts (prepare,
prepareWithdraw, prepareTopUp). The unsigned path returns raw txs[] plus
a same-index steps[] plan such as approve, createDeposit, or
withdrawDeposit, so wallets, AA systems, and agents can show what each
transaction does before signing. prepare() is Base-USDC-only and rejects a
source with SOURCE_ROUTE_UNSUPPORTED_IN_PREPARE. A signer-backed app can
use cashout({ source }, { signer, sourceSigner }); a custody-separated host
must execute and confirm its Relay route before preparing the Base-USDC
cashout. After externally executing a prepared createDeposit, pass its
confirmed receipt to finalizePreparedCashout() to recover the same
CashoutResult shape as cashout() without importing protocol ABIs. Every
Peer Cash transaction, including approves, carries ERC-8021 attribution:
peer-cash first, optional peer-ref-XXXXXX from referralCode next, and your
analytics-only referrer code(s) after it.
Order reads fail closed against the same active catalog. If any method on an
indexed deposit is unsupported, orders() excludes the whole deposit and
order() returns ORDER_NOT_FOUND; Peer Cash never partially reclassifies a
mixed historical deposit.
| Payout rail | Access-policy behavior | New payee registration |
|---|---|---|
| Venmo / Cash App | Four groups attach after deposit confirmation | Curator validates the live handle |
| PayPal | Same four-group follow-up | Requires a Peer TEE browser-extension identity attestation |
| Wise | No access-policy follow-up | Requires a Peer TEE browser-extension identity attestation |
| Other supported rails | No access-policy follow-up; use capabilities() for currencies and format |
Follow the payeeHint; live-validation behavior is described in the integration guide |
No platform requires an atomic access-policy flow. cashout() and prepare()
work with any viem WalletClient, including a local or externally connected
EOA; no Privy wallet or signer API is required. The deprecated
requiresAtomicAccessPolicy capability remains for wire compatibility and is
always false.
Venmo, Cash App, and PayPal cash-outs restrict intent signaling to Plus, Pro,
Peer Makers, and Peer Pay groups by default. If any payout leg uses one of
those platforms, signed cashout() creates the deposit first, then uses the
same wallet to submit and confirm the policy transaction; this intentionally
leaves a brief non-atomic interval. Prepared integrations receive
accessPolicyRequired: true and, after confirming createDeposit, must call
finalizePreparedCashout(receipt) followed by
prepareAccessPolicy(depositId). Other platforms do not need the follow-up.
If policy attachment fails, ACCESS_POLICY_CONFIGURATION_FAILED.recovery
identifies the existing deposit and any submitted policy transaction. Never
create another cash-out. When recovery.transactionHash is present, inspect
that transaction before resubmitting; otherwise prepare the policy again with
the same depositor wallet.
capabilities() presents Zelle as one platform. A cashout with
receive.platform: 'zelle' attaches only the generic Zelle payment method to
the deposit. Bank-specific capture routing is outside this maker-side SDK and
never changes the on-chain payment method.
capabilities() tells you which platforms need a verified identity for a new
payee registration (requiresIdentityAttestation - Wise and PayPal today).
The SDK accepts an identityAttestation in structured payee data but does not
mint one. First-party Peer web obtains it through the Peer TEE browser
extension. An already-registered Wise or PayPal handle can be reused with bare
payee data. A new handle without its signed attestation fails during curator
registration with PAYEE_VERIFICATION_REQUIRED, before funds move on-chain.
The default/minimal flow is unchanged: pass Base USDC base units to
estimate() and cashout(). For any other source asset, pass source to
cashout() with a source-chain signer. The SDK settles the Base allowance,
executes the Relay route into Base USDC, then creates the Peer Cash order. Use
EXACT_INPUT in cash-out UIs so amount always means source-token base units.
The destination is always canonical Base USDC
(8453:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913); source support is
discovered and quoted by @relayprotocol/relay-sdk, not a static token
allowlist.
const { depositId, accessPolicyTxHash, source } = await cash.cashout(
{
amount: 10_000_000n, // exact input: 10 USDC in source-token base units
source: {
chainId: 1,
currency: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
tradeType: 'EXACT_INPUT',
},
receive: { platform: 'venmo', currency: 'USD', payee: { offchainId: '@you' } },
},
{ signer, sourceSigner },
);
// source.amount is Relay's guaranteed minimum Base USDC output and the exact
// amount deposited into the cash-out order. It is not the route's actual output.
console.log(source?.amount, source?.requestId);
console.log(source?.transactions?.origin, source?.transactions?.destination);
console.log(accessPolicyTxHash); // present because this example uses VenmoRoutes that submit more than one source-chain transaction (approve, then
route) require a nonce-managed source signer -
privateKeyToAccount(pk, { nonceManager }) from viem. Without one the SDK
refuses the route preflight with SOURCE_NONCE_MANAGER_REQUIRED instead of
letting the route transaction reuse the approval's nonce and revert
mid-route. Browser wallets are unaffected.
NEAR Intents 1Click supports non-EVM origins such as Zcash, so the SDK does
not pretend a viem wallet can execute the source transfer. It returns a signed
quote with an origin-chain depositAddress and optional depositMemo; your
wallet sends exactly once, then the SDK tracks the provider route into
canonical Base USDC. Use EXACT_OUTPUT when the Peer order amount must be
known before the origin send.
const cash = createCashClient({
environment: 'production',
nearIntents: {
// Browser-safe same-origin proxy; it keeps the 1Click JWT server-side.
apiUrl: '/api/v1/near',
transport: 'proxy',
},
});
const sources = await cash.capabilities({ includeNearIntentsSources: true });
const zec = sources.source.nearIntents?.assets.find((asset) => asset.symbol === 'ZEC');
const quote = await cash.quoteNearIntentsSource({
sourceAsset: zec!.assetId,
amount: 1_000_000n, // exact 1 Base USDC output
recipient: baseSigner.account.address,
refundTo: transparentZcashRefundAddress,
tradeType: 'EXACT_OUTPUT',
deadline: new Date(Date.now() + 3 * 60_000).toISOString(),
});
persist(quote); // before sending: address, memo, signed response, deadline
const originTxHash = await zcashWallet.send(quote.depositAddress!, quote.inputAmount);
await cash.submitNearIntentsDeposit({
depositAddress: quote.depositAddress!,
...(quote.depositMemo ? { depositMemo: quote.depositMemo } : {}),
txHash: originTxHash,
});
const route = await cash.nearIntentsStatus({
depositAddress: quote.depositAddress!,
...(quote.depositMemo ? { depositMemo: quote.depositMemo } : {}),
expectedQuote: quote,
});
// On SUCCESS, reconcile the Base receipt/balance, then call Base-only cashout().Direct server integrations may pass nearIntents: { token }. Browser code
must use a same-origin proxy and must never receive the 1Click JWT. Never reuse
an expired deposit address, resend funds after an uncertain wallet submission,
or infer success from a wallet-wide balance alone. If optional deposit
registration fails, retry only submitNearIntentsDeposit() with the same hash;
1Click can also detect the transfer on-chain.
Persist depositId, transaction hashes, and the Relay requestId as soon as
they are available. A source-routed result includes both a flat
source.txHashes list and chain-aware source.transactions.origin /
.destination entries.
SOURCE_EXECUTION_FAILEDwhere only the approval landed: the Relay request can stay inrelayStatuswaitingindefinitely. Decide from the error's recovery payload and origin transactions, never by waiting for a terminal Relay status.SOURCE_DEPOSIT_SUBMISSION_FAILED: the NEAR Intents origin transaction may already be final. Retry only the provider notification with the same address and hash; never resend source funds.SOURCE_ROUTE_COMPLETED_CASHOUT_FAILED: Relay completed, but the Base cashout was not created. Do not route again. Retry a Base-USDC-onlycashout()withBigInt(error.recovery.amount).SOURCE_CASHOUT_SUBMISSION_UNKNOWN: Relay completed, but Base submission returned no transaction hash. Inspect recent Base wallet activity andorders(error.recovery.depositor)to prove no deposit exists before retrying.SOURCE_CASHOUT_STATUS_UNKNOWN: the Base cashout transaction was submitted, but its receipt is unknown. Do not route or submit again. Inspecterror.recovery.depositTxHash; recover thedepositIdfrom itsDepositReceivedlog if it succeeded, or use the recovery amount for a Base-USDC-only retry only after confirming it reverted.TRANSACTION_SUBMISSION_UNKNOWN: a Base-only cashout or another mutation returned no hash. Treat it as potentially broadcast. Inspect recent Base wallet activity and the supplied recovery action before any retry.ACCESS_POLICY_CONFIGURATION_FAILED: the deposit exists, but its required Venmo, Cash App, or PayPal policy was not confirmed. Do not cash out again; inspectrecovery.transactionHashwhen present, then retryprepareAccessPolicy(error.recovery.depositId)only if the prior policy transaction did not succeed.
Wallet clients pinned to the wrong chain fail with SIGNER_CHAIN_MISMATCH
before a quote or transaction is submitted. Chainless wallets are checked
through getChainId(); a disconnected wallet returns retryable
SIGNER_CHAIN_UNAVAILABLE. Indexer and oracle transport outages are typed as
retryable INDEXER_UNAVAILABLE and ORACLE_READ_FAILED reads; retry the read
without repeating any transaction. TRANSACTION_STATUS_UNKNOWN carries the
submitted hash in error.recovery.transactionHash so recovery never depends
on parsing an error message.
buyer signals fiat proven
awaiting-buyer ──────────► matched ──────────► delivered
│ │ (partial fills pass through "delivering")
│ withdraw() │ buyer never pays → intent expires
▼ ▼ withdraw() prunes + returns funds
returned ◄─────────────────┘
- You are the maker. Your deposit is priced by the live Chainlink oracle
with
spreadBps: 0, making it the best price a rational maker can offer. - There is no quote. The binding rate resolves at the oracle when a buyer
fills.
estimate()says "approximately"; nothing in this API pretends to lock a price. - ETA is historical.
estimate().etais just{ seconds, label }, backed by the same rolling 30-day, intent-attributed pair sampler asfillStats(), measured from deposit creation to the first fulfilled fill through the pair. The raw snapshot is cached for 15 minutes per client and each ETA is still resolved from its exact normalizedplatform:currencykey. Multi-currency deposits also produce sorted keys such asrevolut:EUR+GBP+USD, measured to the first fill in any offered currency. Use{ includeEta: false }when rate and receive amount should render first. - Availability thresholds belong to the consumer.
fillStats()returns raw evidence. A recommended gate isfills >= 10 && medianFillSeconds <= 48h. Fail open to the fullcapabilities()catalog when stats are unavailable or the gate would empty the offered catalog. - Everything is resumable. An order is reconstructed from the chain by
depositIdalone. Close the tab, switch devices, crash the process - then callorder(depositId). - Unwind is one verb. Buyer never paid? Their intent expires;
withdraw()prunes it and returns your USDC. You never choose between cancel and recover.
Deep dive: docs/lifecycle-and-recovery.md.
Use the same six-character referral code shown in your Peer mobile or web app. No API key, registration transaction, or separate receiving address is needed: the referral code already belongs to your Peer Privy wallet.
const cash = createCashClient({
environment: 'production',
referralCode: 'ABC123',
});The SDK normalizes the value and stamps peer-ref-ABC123 into ERC-8021
attribution on the deposit transaction. When that liquidity is filled, Curator
pays the code owner 50 bps, capped by the configured Peer service fee. This is
the deposit-level integration path: it replaces the maker L1/L2 referral split
for that deposit instead of enrolling the cashing-out user as your referee.
The mapping is permanent. If you later customize your displayed Peer referral
code, open deposits carrying the old code still pay the same wallet. Include at
most one peer-ref-XXXXXX marker; an unknown or conflicting marker receives no
integration share. The existing referrer option remains available for
analytics-only ERC-8021 codes such as acme-app.
cashout/withdraw/topUphave unsigned counterparts (prepare,prepareWithdraw,prepareTopUp) - inspect readablesteps[]and calldata before signing, then submit the matchingtxs[]in order.- Mutating tool calls return unsigned transactions by default; signing stays with the host that owns custody, policy, and user approval.
- After a prepared restricted cash-out confirms, the host adapter must call
finalizePreparedCashout(receipt)andprepareAccessPolicy(depositId); these receipt/signing operations areCashClientmethods, not built-in tool calls. - Every error carries
code,retryable, and aremediationsentence. - Every order carries
nextActions: ('wait' | 'withdraw')[]- no heuristics. - Every wire type has a zod schema + JSON codec - state crosses process boundaries losslessly.
- Everything arrives decoded: platform ids and currency codes instead of bytes32 hashes, plain-number rates instead of 1e18 bigints.
- Fills are receipts: the locked rate and fiat owed at signal, then the verified fiat paid, currency, platform payment id, released USDC, and fill latency once the proof lands.
@zkp2p/cash/toolsexports a JSON-schema tool manifest of the verbs.
Start at AGENTS.md, or load the
peer-cash-integration skill.
import { useEstimate, useCashout, useOrder, useOrders } from '@zkp2p/cash/react';React is an optional peer dependency - the root entry never imports it.
production | preproduction | staging selects contracts, curator, and
indexer. Preproduction defaults to https://api-preprod.zkp2p.xyz; staging
defaults to https://api-staging.zkp2p.xyz. Indexer, curator, and Relay
options remain overridable via createCashClient options. Base USDC on Base
is the default source and the only destination asset for cashout orders.
Runnable first-party examples in examples/:
node-cashout.ts- server-side cash-out with a private-key signer, plus order tracking.agent-tool-use.ts- wiring the verbs into an agent tool-use loop with host-side signing.mpp-merchant-cashout- turn confirmed MPP merchant revenue into an unsigned Peer Cash plan while the merchant keeps custody and signing.onchain-demo- the Peer Cash Demo: the express sell flow as one page that bundles the SDK and is stored on Base as contract bytecode, served by an immutable ERC-5219 wrapper, live on Base.
The published package depends on @zkp2p/sdk for protocol internals; that
dependency currently ships from private source. Onchain custody is enforced by
the protocol: only the contract holds funds, and only the maker can withdraw
an unmatched deposit.
CLAUDE.md is the
contributor guide: ground rules, repo layout, the bun run ci gate, and the
release/publish process. AGENTS.md is the shipped manual for agents using
the package, not the contributor entry point.
MIT