@usdctofiat/offramp
v4.5.1
Published
USDC-to-fiat offramp SDK — create delegated deposits in one function call
Readme
@usdctofiat/offramp
USDC-to-fiat offramp SDK for Base. Deposit creation, OTC helpers, React hooks, platform/currency catalogs, and typed developer resources.
Agent bundle
If you are integrating through an agent (Claude Code, Cursor, etc.), start here:
- Developer portal: https://usdctofiat.xyz/developers
- Drop-in skill: https://usdctofiat.xyz/skills/usdctofiat.md
- Short machine reference: https://usdctofiat.xyz/llms.txt
- Full machine reference: https://usdctofiat.xyz/llms-full.txt
- Scaffold CLI:
npx create-offramp-app@latest my-app --template=next|vite|telegram-bot - Starters (Next.js / Vite / Telegram bot, plus runnable example scripts): https://github.com/ADWilkinson/usdctofiat-peerlytics-starters
- Companion SDK for analytics + explorer:
@peerlytics/sdk
The SDK also exports a typed resource map for self-serve products and coding agents:
import { OFFRAMP_DEVELOPER_RESOURCES, getOfframpDeveloperResources } from "@usdctofiat/offramp";
OFFRAMP_DEVELOPER_RESOURCES.delegation.required; // true
OFFRAMP_DEVELOPER_RESOURCES.delegation.feeRateBps; // DELEGATE_MANAGER_FEE_BPS
OFFRAMP_DEVELOPER_RESOURCES.links.agentSkill; // https://usdctofiat.xyz/skills/usdctofiat.md
OFFRAMP_DEVELOPER_RESOURCES.upstreamSourceTruths.map((source) => source.label);
// ["@zkp2p/sdk", "zkp2p/protocol curator", ...]
OFFRAMP_DEVELOPER_RESOURCES.checklist.map((item) => item.title); // integration doctor
const botPlaybook = getOfframpDeveloperResources("bot");
// profile-specific steps + canonical docs/starters resourcesUse upstreamSourceTruths before generating protocol-sensitive code. It points
agents at the current ZKP2P SDK and zkp2p/protocol curator, attestor, and contracts sources
so they do not invent payment-method hashes, proof payload fields, or contract
addresses.
For agent-built integrations, copy the same prompt the developer console uses:
import { getOfframpAgentPrompt } from "@usdctofiat/offramp";
const prompt = getOfframpAgentPrompt("bot");Install
bun add @usdctofiat/offrampQuick Start
import { offramp, PLATFORMS, CURRENCIES } from "@usdctofiat/offramp";
const result = await offramp(walletClient, {
amount: "100",
platform: PLATFORMS.REVOLUT,
currency: CURRENCIES.EUR,
identifier: "alice",
});
// { depositId: "362", txHash: "0x...", resumed: false }Fees
Every deposit created with this SDK is delegated to the Delegate vault's rate manager (the SDK always calls setRateManager to the vault — see How It Works). That rate manager charges a manager fee:
- Who pays: the buyer (taker). The fee is deducted from the USDC released to them at fill time — it is not added to your deposit cost and is not baked into the displayed rate.
- Where it goes: the Delegate vault (Galleon Labs).
- Discover it in code:
createOfframp({ walletClient }).getVaultStatus().feeRateBpsreturns the currentfeeRateBps(defined byDELEGATE_MANAGER_FEE_BPSinsrc/config.ts).
The only other attribution the SDK attaches is the on-chain referrer tag referrer: "galleonlabs" (ERC-8021 builder attribution). That is not a fee — no separate referral/referrerFee is configured by this SDK. There is no SDK spread either: getQuote() returns vaultSpreadBps: 0.
Deposit Management
import { deposits, close } from "@usdctofiat/offramp";
const list = await deposits("0xYourAddress");
await close(walletClient, "361");React
import { useOfframp } from "@usdctofiat/offramp/react";
import { PLATFORMS, CURRENCIES } from "@usdctofiat/offramp";
function SellButton({ walletClient }: { walletClient: WalletClient }) {
const { offramp, step, isLoading } = useOfframp();
return (
<button
disabled={isLoading}
onClick={() => offramp(walletClient, {
amount: "100",
platform: PLATFORMS.REVOLUT,
currency: CURRENCIES.EUR,
identifier: "alice",
})}
>
{step ?? "Sell USDC"}
</button>
);
}Platform & Currency Data
import { PLATFORMS, CURRENCIES } from "@usdctofiat/offramp";
PLATFORMS.REVOLUT.name; // "Revolut"
PLATFORMS.REVOLUT.currencies; // ["USD", "EUR", "GBP", ...]
PLATFORMS.REVOLUT.identifier.label; // "Revtag"
PLATFORMS.REVOLUT.identifier.placeholder; // "revtag (no @)"
PLATFORMS.REVOLUT.identifier.help; // "Revtag without @ (must be public)"
PLATFORMS.REVOLUT.validate("@alice"); // { valid: true, normalized: "alice" }
CURRENCIES.EUR.symbol; // "€"
CURRENCIES.EUR.name; // "Euro"
CURRENCIES.EUR.countryCode; // "eu"Integration Playbooks
getOfframpDeveloperResources() returns the canonical developer bundle:
- app: user-facing wallet flows with
useOfframp() - bot: server-wallet and automation flows
- agent: skill,
llms.txt, and starter-template routing for coding agents - private-otc: one-buyer deposits with whitelist enforcement
- peerlytics: paid protocol API, analytics, orderbook, credits, deposits, and intents
This is intentionally duplicated into the package so integrators can expose docs, help menus, agent context, or onboarding checklists without scraping the site.
OTC Private Orders
Restrict a deposit to a single taker wallet. Pass otcTaker and the deposit is created, delegated, and restricted in one call:
import { offramp, PLATFORMS, CURRENCIES } from "@usdctofiat/offramp";
const { depositId, otcLink } = await offramp(walletClient, {
amount: "100",
platform: PLATFORMS.REVOLUT,
currency: CURRENCIES.EUR,
identifier: "alice",
otcTaker: "0xBuyerAddress",
});
// otcLink = "https://otc.usdctofiat.xyz/d/0x.../362"
// Share otcLink with the buyer — they open it, connect their wallet, and fill the order.Toggle OTC on existing deposits:
import { enableOtc, disableOtc, getOtcLink } from "@usdctofiat/offramp";
await enableOtc(walletClient, "362", "0xBuyerAddress");
await disableOtc(walletClient, "362"); // make public again
getOtcLink("362"); // just the URL, no txResumable
offramp() is idempotent on the delegation step. If an undelegated deposit exists for the wallet, it skips straight to delegation. Handles browser crashes, failed delegation, and retries automatically. Just call offramp() again.
Idempotency & server-side dedup
OfframpParams.idempotencyKey is browser-only: the replay cache is backed by sessionStorage, so it is a no-op in Node and workers — a backend integrator who passes a key gets zero duplicate protection there. It is also honored only by the Offramp class (createDeposit) and the useOfframp hook; the standalone offramp() function ignores it entirely. When you pass a key in a runtime with no sessionStorage, the SDK emits a one-time console.warn so you find out at startup instead of via duplicate deposits.
For server-side dedup, check for an existing open deposit before creating a new one (this is the same idea as the resume behavior above):
import { createOfframp, deposits } from "@usdctofiat/offramp";
const existing = await deposits(walletAddress);
const open = existing.find((d) => d.status === "active" && d.remainingUsdc > 0);
const result = open
? open // reuse it — don't create a duplicate
: await createOfframp({ walletClient }).createDeposit(params);Error Handling
import { offramp, OfframpError, OFFRAMP_ERROR_CODES } from "@usdctofiat/offramp";
try {
await offramp(walletClient, params);
} catch (err) {
if (err instanceof OfframpError) {
if (err.code === "USER_CANCELLED") return;
if (err.code === OFFRAMP_ERROR_CODES.EXTENSION_REGISTRATION_REQUIRED) {
// User needs to register in the Peer extension first — see below.
return;
}
// For any other failure: call offramp() again to resume
}
}offramp() already maps wallet rejections to OfframpError with code
USER_CANCELLED. If you also make your own wallet calls (a custom approve step,
say) or want to inspect OfframpError.cause, use the same matcher the SDK uses
internally:
import { isUserCancellation } from "@usdctofiat/offramp";
try {
await walletClient.writeContract(/* your own call */);
} catch (err) {
if (isUserCancellation(err)) return; // user dismissed the wallet prompt
throw err;
}Peer Extension Registration (PayPal, Wise, Venmo, Cash App)
PayPal, Wise, Venmo, and Cash App makers may need to register their handle
inside the Peer (PeerAuth) browser extension before the first deposit. The SDK
throws OfframpError with code EXTENSION_REGISTRATION_REQUIRED when curator
rejects a maker for this reason. Drive install, connection approval, headless
seller-credential capture, seller-credential upload, and retry via the React
hook:
import { PLATFORMS, CURRENCIES, OFFRAMP_ERROR_CODES } from "@usdctofiat/offramp";
import { useOfframp, usePeerExtensionRegistration } from "@usdctofiat/offramp/react";
function PayPalSellButton({ walletClient }) {
const { offramp, lastError } = useOfframp();
const peer = usePeerExtensionRegistration(PLATFORMS.PAYPAL);
const depositParams = {
amount: "100",
platform: PLATFORMS.PAYPAL,
currency: CURRENCIES.USD,
identifier: "alicepay", // PayPal.me username, NOT email
};
const needsExtension = lastError?.code === OFFRAMP_ERROR_CODES.EXTENSION_REGISTRATION_REQUIRED;
return (
<>
<button onClick={() => offramp(walletClient, depositParams)}>Sell USDC</button>
{needsExtension && (
<div>
<p>{peer.info?.requiredPrompt}</p>
{peer.phase === "needs_install" && (
<button onClick={peer.installExtension}>Install Peer Extension</button>
)}
{peer.phase === "needs_connection" && (
<button onClick={peer.connectExtension} disabled={peer.busy}>
Connect Peer Extension
</button>
)}
{peer.phase === "ready" && (
<button onClick={peer.startRegistrationCapture} disabled={peer.busy}>
{peer.info?.ctaLabel ?? "Register with Peer"}
</button>
)}
{peer.capturedMetadata && !peer.error && (
<button onClick={() => peer.completeRegistration(walletClient, depositParams)}>
Continue registration
</button>
)}
{peer.info?.ctaSubtext && <small>{peer.info.ctaSubtext}</small>}
</div>
)}
</>
);
}Or drive the handshake manually via peerExtensionSdk.
getPeerExtensionRegistrationAuthParams(...) returns the right capture mode for
the platform. Venmo and Cash App use seller-credential bundle capture; PayPal
and Wise use buyerTee identity-attestation capture.
import {
CURRENCIES,
PLATFORMS,
completePeerExtensionRegistration,
getPeerExtensionRegistrationAuthParams,
isPeerExtensionMetadataBridgeAvailable,
offramp,
peerExtensionSdk,
} from "@usdctofiat/offramp";
const params = getPeerExtensionRegistrationAuthParams("paypal");
if (!params) throw new Error("PayPal registration is not extension-gated.");
const depositParams = {
amount: "100",
platform: PLATFORMS.PAYPAL,
currency: CURRENCIES.USD,
identifier: "alicepay",
};
const startCapture = () => {
const unsubscribe = peerExtensionSdk.onMetadataMessage(async (message) => {
if (message.platform !== params.platform) return;
await completePeerExtensionRegistration({
platform: PLATFORMS.PAYPAL,
identifier: "alicepay",
capturedMetadata: message,
callerAddress: walletAddress,
});
await offramp(walletClient, depositParams);
});
peerExtensionSdk.authenticate(params);
return unsubscribe;
};
const state = await peerExtensionSdk.getState();
if (state === "needs_install" || !isPeerExtensionMetadataBridgeAvailable()) {
peerExtensionSdk.openInstallPage();
} else if (state === "needs_connection") {
const approved = await peerExtensionSdk.requestConnection();
if (approved) startCapture();
} else {
startCapture();
}For PayPal and Wise, completePeerExtensionRegistration(...) requires
callerAddress and posts an identity attestation through /v2/makers/create.
For Venmo and Cash App, captured metadata must include
sarCredentialCapture.credentialBundle; completion validates the captured
offchainId, checks the bundle payee hash, uploads the seller credential
bundle, and returns sellerCredentialResponse plus sellerCredentialStatus.
After registration succeeds, call offramp() again with the same handle.
Dynamic Taker Tiers
Curator computes taker caps and platform locks dynamically. Use
getTakerTier() plus findTakerPlatformLimit() when a take-side UI needs the
current limit for a wallet/payment rail:
import { findTakerPlatformLimit, getTakerTier } from "@usdctofiat/offramp";
const tier = await getTakerTier({ owner: takerAddress });
const paypalLimit = findTakerPlatformLimit(tier, { platform: "paypal" });
if (paypalLimit?.isLocked) {
console.log(`PayPal unlocks at ${paypalLimit.minTierRequired} tier`);
}How It Works
Every deposit is automatically delegated to the Delegate vault for oracle-based rate management (which charges a manager fee — see Fees), auto-closes when filled, and is attributed to Galleon Labs via ERC-8021.
Testing your integration
There is no public staging or sandbox deployment — the SDK targets Base mainnet and the production curator API. Two ways to test:
- Small-amount mainnet runs (recommended). Create a 1 USDC deposit against a real payment handle, confirm it on basescan and via
deposits(walletAddress), thenclose()it. This exercises the real approve → register → deposit → delegate path end to end. - Proxy or mock the curator REST surface. Pass
apiBaseUrlto point the SDK's maker registration/validation calls (/v2/makers/*) at a local proxy or mock. Defaults are byte-for-byte unchanged when omitted. Only the curator REST surface is overridable — the indexer and Base RPC always target mainnet, and on-chain calls still need a real wallet client.
import { createOfframp, PLATFORMS, CURRENCIES } from "@usdctofiat/offramp";
const sdk = createOfframp({
walletClient,
apiBaseUrl: "http://localhost:8787", // proxy/mock of https://api.zkp2p.xyz
});
await sdk.createDeposit({
amount: "1",
platform: PLATFORMS.REVOLUT,
currency: CURRENCIES.EUR,
identifier: "alice",
});Lifecycle State
Use deposits(address) for seller-owned deposit reconciliation. Use @peerlytics/sdk when your product needs broader deposit, intent, orderbook, or explorer reads.
Links
usdctofiat.xyz · usdctofiat.xyz/delegate · peerlytics.xyz · peerlytics.xyz/orderbook
