Skip to content

Commit db07dc3

Browse files
feat(cashout): allocation-funded offramp v2 + retry-from-cancelled + proxy history merge (p2pdotme#11)
Opt-in, backward-compatible support for the TradeStars offramp v2 (per-user-proxy allocation). offramp-machine: retry-from-cancelled (retain feeUsdc, add retryPlace/canRetryPlace, PLACING resets order fields). Cashout: optional fetchAvailableOfframp sources the Max/insufficient amount from an integrator allocation instead of balanceOf; Try-again button on the cancelled screen. types: CashoutProps.fetchAvailableOfframp?. PaymentHistory: optional resolveExtraAddresses merges the user's per-user-proxy orders (offramp order.user) with the EOA's, de-duped + re-sorted, bypassing the b2b intersection. README recipe added. No SDK change. npm run verify green. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 02fb132 commit db07dc3

5 files changed

Lines changed: 175 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,88 @@ For a different integrator (e.g., a custom one not following the LotPot
575575
shape), the contract above is the same — only the ABI fragments and event
576576
parser change. The widget never knows the difference.
577577

578+
### TradeStars offramp v2 (allocation-funded)
579+
580+
The TradeStars offramp funds the SELL from a **per-user-proxy allocation** (a
581+
relayer moves vault USDC into the user's proxy after a Solana burn), not the
582+
user's wallet USDC. Three differences vs the LotPot recipe:
583+
584+
1. **No USDC approve** — the proxy already holds the funds; `placeCashout` just
585+
calls `userStartOfframp(allocationId, …)`.
586+
2. **`fetchAvailableOfframp`** sources the "Max" amount from the integrator's
587+
`availableOfframp(user)` view instead of the wallet balance.
588+
3. **History** merges the user's per-user proxy (where `order.user` lives for
589+
offramps) via `<PaymentHistory resolveExtraAddresses=…>`.
590+
591+
```ts
592+
const TRADESTARS_ABI = [
593+
{ name: "userStartOfframp", type: "function", stateMutability: "nonpayable",
594+
inputs: [
595+
{ name: "allocationId", type: "uint256" }, { name: "currency", type: "bytes32" },
596+
{ name: "fiatAmount", type: "uint256" }, { name: "circleId", type: "uint256" },
597+
{ name: "preferredPaymentChannelConfigId", type: "uint256" }, { name: "userPubKey", type: "string" },
598+
], outputs: [{ name: "orderId", type: "uint256" }] },
599+
{ name: "userDeliverOfframpUpi", type: "function", stateMutability: "nonpayable",
600+
inputs: [{ name: "orderId", type: "uint256" }, { name: "encUpi", type: "string" }], outputs: [] },
601+
{ name: "syncOfframp", type: "function", stateMutability: "nonpayable",
602+
inputs: [{ name: "orderId", type: "uint256" }], outputs: [] },
603+
{ name: "availableOfframp", type: "function", stateMutability: "view",
604+
inputs: [{ name: "user", type: "address" }], outputs: [{ type: "uint256" }] },
605+
{ name: "pendingAllocations", type: "function", stateMutability: "view",
606+
inputs: [{ name: "user", type: "address" }], outputs: [{ type: "uint256[]" }] },
607+
{ name: "proxyAddress", type: "function", stateMutability: "view",
608+
inputs: [{ name: "user", type: "address" }], outputs: [{ type: "address" }] },
609+
{ type: "event", name: "OfframpOrderPlaced", inputs: [
610+
{ name: "allocationId", type: "uint256", indexed: true },
611+
{ name: "orderId", type: "uint256", indexed: true },
612+
{ name: "user", type: "address", indexed: true },
613+
{ name: "amount", type: "uint256", indexed: false } ] },
614+
] as const;
615+
616+
// Host picks an allocationId from pendingAllocations(user) before mounting <Cashout>.
617+
const allocationId = /* selected pending allocation (a bigint) */;
618+
619+
<Cashout
620+
/* …usdcAddress, diamondAddress, signer, currencies, subgraphUrl… */
621+
fetchAvailableOfframp={(user) =>
622+
publicClient.readContract({ address: INTEGRATOR, abi: TRADESTARS_ABI,
623+
functionName: "availableOfframp", args: [user] }) as Promise<bigint>}
624+
placeCashout={async (ctx) => { // NO approve — proxy already funded
625+
const data = encodeFunctionData({ abi: TRADESTARS_ABI, functionName: "userStartOfframp",
626+
args: [allocationId, stringToHex(ctx.currency.symbol, { size: 32 }), 0n,
627+
ctx.currency.circleId!, ctx.currency.paymentChannelConfigId ?? 0n, ctx.userPubKey] });
628+
const { hash } = await signer.sendTransaction({ to: INTEGRATOR, data, gasLimit: 1_000_000 });
629+
const receipt = await publicClient.waitForTransactionReceipt({ hash });
630+
return { orderId: parseTradeStarsOrderId(receipt) /* OfframpOrderPlaced */, txHash: hash };
631+
}}
632+
deliverUpi={async (ctx) => {
633+
const data = encodeFunctionData({ abi: TRADESTARS_ABI, functionName: "userDeliverOfframpUpi",
634+
args: [BigInt(ctx.orderId), ctx.encryptedUpi] });
635+
const { hash } = await signer.sendTransaction({ to: INTEGRATOR, data, gasLimit: 500_000 });
636+
await publicClient.waitForTransactionReceipt({ hash });
637+
return { txHash: hash };
638+
}}
639+
reconcile={async (ctx) => { // permissionless syncOfframp
640+
const data = encodeFunctionData({ abi: TRADESTARS_ABI, functionName: "syncOfframp",
641+
args: [BigInt(ctx.orderId)] });
642+
const { hash } = await signer.sendTransaction({ to: INTEGRATOR, data, gasLimit: 200_000 });
643+
await publicClient.waitForTransactionReceipt({ hash });
644+
return { txHash: hash };
645+
}}
646+
/>
647+
648+
// Offramps are attributed to the user's proxy — merge it into history:
649+
<PaymentHistory signer={signer} subgraphUrl={SUBGRAPH} usdcAddress={USDC}
650+
resolveExtraAddresses={async (user) => [
651+
(await publicClient.readContract({ address: INTEGRATOR, abi: TRADESTARS_ABI,
652+
functionName: "proxyAddress", args: [user] })) as `0x${string}`,
653+
]} />
654+
```
655+
656+
A cancelled offramp leaves the USDC in the user's proxy, so the `<Cashout>`
657+
"Try again" button re-places from the same balance — self-serve retry, no
658+
relayer/owner. See `payment-integrators/docs/OFFRAMP-V2.md` for the contract side.
659+
578660
> **Tip:** `userPubKey` is auto-generated from the SDK's relay identity
579661
> (lazily persisted in localStorage). Hosts that already use `<Checkout>`
580662
> share the same identity — no extra wiring required.

‎src/core/offramp-machine.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@ interface OfframpState {
4848
currency: CurrencyOption | null;
4949
paymentAddress: string | null;
5050
usdcAmount: bigint | null;
51+
/** Small-order fee retained so a retry-from-cancelled can recompute the charge. */
52+
feeUsdc: bigint | null;
5153
fiatAmount: bigint | null;
5254
error: string | null;
5355
}
5456

5557
type OfframpAction =
56-
| { type: "PLACING"; currency: CurrencyOption; paymentAddress: string; usdcAmount: bigint }
58+
| { type: "PLACING"; currency: CurrencyOption; paymentAddress: string; usdcAmount: bigint; feeUsdc: bigint }
5759
| { type: "PLACED"; orderId: string; txHash: string }
5860
| { type: "ACCEPTED" }
5961
| { type: "ENCRYPTING" }
@@ -66,12 +68,12 @@ type OfframpAction =
6668
const INITIAL: OfframpState = {
6769
phase: "form",
6870
orderId: null, txHash: null, currency: null, paymentAddress: null,
69-
usdcAmount: null, fiatAmount: null, error: null,
71+
usdcAmount: null, feeUsdc: null, fiatAmount: null, error: null,
7072
};
7173

7274
function reducer(s: OfframpState, a: OfframpAction): OfframpState {
7375
switch (a.type) {
74-
case "PLACING": return { ...s, phase: "placing", currency: a.currency, paymentAddress: a.paymentAddress, usdcAmount: a.usdcAmount, error: null };
76+
case "PLACING": return { ...s, phase: "placing", currency: a.currency, paymentAddress: a.paymentAddress, usdcAmount: a.usdcAmount, feeUsdc: a.feeUsdc, orderId: null, txHash: null, fiatAmount: null, error: null };
7577
case "PLACED": return { ...s, phase: "placed", orderId: a.orderId, txHash: a.txHash };
7678
case "ACCEPTED": return { ...s, phase: "accepted" };
7779
case "ENCRYPTING": return { ...s, phase: "encrypting" };
@@ -139,7 +141,7 @@ export function useOfframpMachine(opts: UseOfframpMachineOpts) {
139141
// SDK routing, host's approve+place txs) can each take several seconds
140142
// and would otherwise leave the user staring at a disabled button
141143
// with no feedback.
142-
dispatch({ type: "PLACING", currency, paymentAddress, usdcAmount });
144+
dispatch({ type: "PLACING", currency, paymentAddress, usdcAmount, feeUsdc });
143145

144146
const errorCtx: P2PErrorContext = {
145147
flow: "place-sell",
@@ -346,11 +348,24 @@ export function useOfframpMachine(opts: UseOfframpMachineOpts) {
346348
}
347349
}, [state.orderId, publicClient, opts, encryptAndDeliver]);
348350

351+
/**
352+
* Re-place a fresh SELL after a CANCELLED order. For allocation-funded
353+
* offramps (TradeStars) the cancelled order's USDC is back in the user's
354+
* proxy, so re-placing draws from the same balance — self-serve retry with
355+
* no relayer/owner. Reuses the retained currency / paymentAddress / amount.
356+
*/
357+
const retryPlace = useCallback(() => {
358+
if (!state.currency || state.paymentAddress === null || state.usdcAmount === null) return;
359+
return submit(state.currency, state.paymentAddress, state.usdcAmount, state.feeUsdc ?? 0n);
360+
}, [state.currency, state.paymentAddress, state.usdcAmount, state.feeUsdc, submit]);
361+
349362
return {
350363
state,
351364
submit,
352365
retryDeliver,
366+
retryPlace,
353367
canRetry: state.phase === "error" && state.orderId !== null,
368+
canRetryPlace: state.phase === "cancelled" && state.currency !== null,
354369
reset: () => dispatch({ type: "RESET" }),
355370
};
356371
}

‎src/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,11 @@ export interface CashoutProps {
364364
* Diamond hits a terminal status. Skip if your integrator doesn't
365365
* need it. Always called best-effort (errors swallowed). */
366366
reconcile?: (ctx: ReconcileContext) => Promise<{ txHash: string }>;
367+
/** Optional — source the cashout-able amount from an integrator allocation
368+
* (e.g. TradeStars per-user-proxy) instead of the user's wallet USDC
369+
* balance. When provided, the "Max" affordance + insufficient-balance check
370+
* use this value (6-decimal USDC). Omit for the default user-holds-USDC flow. */
371+
fetchAvailableOfframp?: (user: `0x${string}`) => Promise<bigint>;
367372
chainId?: number;
368373
rpcUrl?: string;
369374
/** Required when any selected `CurrencyOption` omits `circleId` — passed

‎src/widgets/Cashout.tsx‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function Cashout(props: CashoutProps) {
4040
const {
4141
usdcAddress, diamondAddress, signer, currencies,
4242
chainId = 84532, rpcUrl, subgraphUrl, fiatAmountLimit,
43-
placeCashout, deliverUpi, reconcile,
43+
placeCashout, deliverUpi, reconcile, fetchAvailableOfframp,
4444
defaultAmountUsdc, mode = "modal", open = true, theme,
4545
onClose, onOrderPlaced, onComplete, onCancelled, onError,
4646
} = props;
@@ -116,16 +116,27 @@ export function Cashout(props: CashoutProps) {
116116
}
117117
}, [amountInput]);
118118

119-
// Read USDC balance for the "Max" affordance + insufficient-balance hint.
120-
// Uses the read-only ERC20 ABI fragment — no integrator dependency.
119+
// Source the cashout-able amount for the "Max" affordance + insufficient
120+
// hint. Default: the user's on-chain USDC balance (read-only ERC20 ABI, no
121+
// integrator dependency). Allocation-funded offramps (e.g. TradeStars) pass
122+
// `fetchAvailableOfframp` so the amount comes from the user's per-user-proxy
123+
// allocation instead of their wallet balance.
121124
useEffect(() => {
125+
let cancelled = false;
126+
if (fetchAvailableOfframp) {
127+
fetchAvailableOfframp(signer.address)
128+
.then((b) => { if (!cancelled) setBalance(b); })
129+
.catch(() => {});
130+
return () => { cancelled = true; };
131+
}
122132
const chain = chainId === 8453 ? base : baseSepolia;
123133
const pc = createPublicClient({ chain, transport: http(rpcUrl) });
124134
pc.readContract({
125135
address: usdcAddress, abi: ERC20_READ_ABI,
126136
functionName: "balanceOf", args: [signer.address],
127-
}).then((b) => setBalance(b as bigint)).catch(() => {});
128-
}, [chainId, rpcUrl, usdcAddress, signer.address]);
137+
}).then((b) => { if (!cancelled) setBalance(b as bigint); }).catch(() => {});
138+
return () => { cancelled = true; };
139+
}, [chainId, rpcUrl, usdcAddress, signer.address, fetchAvailableOfframp]);
129140

130141
useEffect(() => {
131142
if (!dropdownOpen) return;
@@ -138,7 +149,7 @@ export function Cashout(props: CashoutProps) {
138149
return () => document.removeEventListener("mousedown", onClick);
139150
}, [dropdownOpen]);
140151

141-
const { state, submit, retryDeliver, canRetry, reset } = useOfframpMachine({
152+
const { state, submit, retryDeliver, canRetry, retryPlace, canRetryPlace, reset } = useOfframpMachine({
142153
usdcAddress, diamondAddress, signer,
143154
chainId, rpcUrl, subgraphUrl, fiatAmountLimit,
144155
placeCashout, deliverUpi, reconcile,
@@ -506,11 +517,21 @@ export function Cashout(props: CashoutProps) {
506517
<CenterStatus
507518
icon={<XIcon />}
508519
title="Order cancelled"
509-
subtitle="Your USDC was refunded to your wallet automatically. You can try again any time."
520+
subtitle="The order was cancelled and your funds were returned. You can try again any time."
510521
variant="warning"
511522
/>
523+
{canRetryPlace && (
524+
<button style={{ ...S.primaryBtn, marginTop: 20 }} onClick={() => retryPlace()}>
525+
Try again
526+
</button>
527+
)}
512528
{onClose && (
513-
<button style={{ ...S.primaryBtn, marginTop: 20 }} onClick={onClose}>Close</button>
529+
<button
530+
style={canRetryPlace
531+
? { ...S.ghostBtn, width: "100%", marginTop: 8, height: 40 }
532+
: { ...S.primaryBtn, marginTop: 20 }}
533+
onClick={onClose}
534+
>Close</button>
514535
)}
515536
</div>
516537
)}

‎src/widgets/PaymentHistory.tsx‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ export interface PaymentHistoryProps {
124124
* address. Lookup is case-insensitive.
125125
*/
126126
integratorNames?: Record<string, string>;
127+
/**
128+
* Optional — extra addresses to merge into this user's history. For
129+
* integrators that place orders under a per-user proxy (e.g. TradeStars
130+
* offramp v2, where `order.user` is the user's proxy), resolve the proxy
131+
* address(es) here so those orders show alongside the EOA's. Results are
132+
* merged, de-duped by orderId, and re-sorted newest-first; extra-address
133+
* orders bypass the b2b intersection (they're keyed on the proxy, not the EOA).
134+
*/
135+
resolveExtraAddresses?: (user: `0x${string}`) => Promise<`0x${string}`[]>;
127136
/** Optional theming overrides. See `P2PTheme` for the surface. */
128137
theme?: P2PTheme;
129138
}
@@ -142,6 +151,7 @@ export function PaymentHistory(props: PaymentHistoryProps) {
142151
b2bOnly,
143152
integrators,
144153
integratorNames,
154+
resolveExtraAddresses,
145155
theme,
146156
} = props;
147157
const themeStyle = themeToCssVars(theme);
@@ -173,6 +183,9 @@ export function PaymentHistory(props: PaymentHistoryProps) {
173183
const [loading, setLoading] = useState(true);
174184
// orderId (decimal string) → owning integrator, populated only in B2B mode.
175185
const [b2bMap, setB2bMap] = useState<Map<string, B2BOrderMeta> | null>(null);
186+
// orderIds sourced from an extra (proxy) address via `resolveExtraAddresses`;
187+
// these bypass the b2b intersection since they're keyed on the proxy.
188+
const [extraOrderIds, setExtraOrderIds] = useState<Set<string>>(new Set());
176189
// Per-currency on-chain config — fetched lazily for currencies that have
177190
// at least one order missing `actualFiatAmount` (i.e. pre-acceptance:
178191
// placed orders and orders cancelled before a merchant accepted). Lets us
@@ -199,19 +212,40 @@ export function PaymentHistory(props: PaymentHistoryProps) {
199212
// Fetch the canonical order list and (in B2B mode) the user's B2B
200213
// order→integrator map together so they land in the same render and
201214
// the list never flickers through a "no orders" frame.
202-
const [result, b2b] = await Promise.all([
215+
const extraAddrs = resolveExtraAddresses
216+
? await resolveExtraAddresses(signer.address)
217+
: [];
218+
const [result, b2b, ...extra] = await Promise.all([
203219
client.getOrders({ userAddress: signer.address, skip: 0, limit }),
204220
b2bMode ? fetchB2BMap(subgraphUrl, signer.address) : Promise.resolve(null),
221+
...extraAddrs.map((a) => client.getOrders({ userAddress: a, skip: 0, limit })),
205222
]);
206223
if (result.isErr()) throw result.error;
207-
setOrders(result.value);
224+
// Merge the EOA's orders with any extra-address (e.g. per-user proxy)
225+
// orders, de-duping by orderId and re-sorting newest-first.
226+
const merged = [...result.value];
227+
const seen = new Set(merged.map((o) => o.orderId.toString()));
228+
const extraIds = new Set<string>();
229+
for (const r of extra) {
230+
if (r.isErr()) continue;
231+
for (const o of r.value) {
232+
const id = o.orderId.toString();
233+
if (seen.has(id)) continue;
234+
seen.add(id);
235+
extraIds.add(id);
236+
merged.push(o);
237+
}
238+
}
239+
merged.sort((a, b) => Number(b.placedAt - a.placedAt));
240+
setOrders(merged);
241+
setExtraOrderIds(extraIds);
208242
setB2bMap(b2b);
209243
} catch (err: any) {
210244
setError(err?.message ?? "Failed to fetch orders");
211245
} finally {
212246
setLoading(false);
213247
}
214-
}, [signer?.address, subgraphUrl, usdcAddress, chainId, diamondAddress, rpcUrl, limit, b2bMode]);
248+
}, [signer?.address, subgraphUrl, usdcAddress, chainId, diamondAddress, rpcUrl, limit, b2bMode, resolveExtraAddresses]);
215249

216250
useEffect(() => { fetchOrders(); }, [fetchOrders, refreshKey]);
217251

@@ -286,6 +320,9 @@ export function PaymentHistory(props: PaymentHistoryProps) {
286320
// integrator is on it. In non-B2B mode this is a no-op pass-through.
287321
const allOrders = b2bMode
288322
? overlaidOrders.filter((o) => {
323+
// Extra-address (per-user proxy) orders are keyed on the proxy, not the
324+
// EOA's b2Borders set, so the intersection would drop them — keep them.
325+
if (extraOrderIds.has(o.orderId.toString())) return true;
289326
const meta = b2bMap?.get(o.orderId.toString());
290327
if (!meta) return false;
291328
if (integratorFilter && !integratorFilter.has(meta.integrator)) return false;

0 commit comments

Comments
 (0)