From 5afcee275717ba1c8302d6580d71c6bf5d80f97a Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:53:40 +0100 Subject: [PATCH 1/6] fix(dapp): say what a solana cash-out is short of before the wallet is asked A Solana cash-out's deposit pays the network fee and, when the payout address has no USDC account yet, about 0.00204 SOL to open one. Nothing checked either, so a wallet short of SOL or USDC only said "insufficient balance". The cash-out build route now asks the chain's deposit first. On Solana it reads the token held, the SOL held, and whether the payout account exists, and says what is needed against what is held. A read that fails doesn't refuse. Avalanche's build already checked AVAX for gas. --- .../api/cashout/orders/[id]/build/route.ts | 1 + .../__tests__/weave-deposit-solana.test.ts | 70 ++++++++++++++++++- apps/dapp/lib/weave/deposit/solana.ts | 68 +++++++++++++++++- apps/dapp/lib/weave/types.ts | 6 ++ 4 files changed, 142 insertions(+), 3 deletions(-) diff --git a/apps/dapp/app/api/cashout/orders/[id]/build/route.ts b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts index 7f86c7de..b607fc8c 100644 --- a/apps/dapp/app/api/cashout/orders/[id]/build/route.ts +++ b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts @@ -54,6 +54,7 @@ export async function POST( const deposit = depositChain(row.chain) const expectation = depositExpectation(row) try { + await deposit.assertCanPay?.(expectation) const signing = await deposit.build(expectation) deposit.assertSafe(signing, expectation) return NextResponse.json({ signing }) diff --git a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts index d8a18dc9..4215ae3a 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts @@ -188,6 +188,12 @@ function refused(transaction: string, reason: RegExp): void { } interface RpcAnswers { + /** SOL held by the account, in lamports. Plenty unless a test says otherwise. */ + lamports?: number + /** USDC held, in base units, or null for no token account at all. */ + usdc?: bigint | null + /** Whether the deposit address already has its USDC account. */ + payoutAccountExists?: boolean account?: unknown blockhash?: unknown send?: () => unknown @@ -197,9 +203,39 @@ interface RpcAnswers { function stubRpc(answers: RpcAnswers = {}): ReturnType { const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { - const { method } = JSON.parse(String(init?.body)) as { method: string } + const { method, params } = JSON.parse(String(init?.body)) as { + method: string + params: unknown[] + } + const asked = params?.[0] + const usdc = answers.usdc === undefined ? BigInt(1_000_000_000) : answers.usdc + const accountInfo = + asked === associatedTokenAccount(ACCOUNT, USDC) + ? { + result: { + context: { slot: 1 }, + value: + usdc === null + ? null + : { + owner: TOKEN_PROGRAM, + data: { + parsed: { info: { tokenAmount: { amount: String(usdc), decimals: 6 } } }, + }, + }, + }, + } + : asked === associatedTokenAccount(DEPOSIT, USDC) + ? { + result: { + context: { slot: 1 }, + value: answers.payoutAccountExists === true ? { owner: TOKEN_PROGRAM } : null, + }, + } + : (answers.account ?? { result: { context: { slot: 1 }, value: null } }) const replies: Record = { - getAccountInfo: answers.account ?? { result: { context: { slot: 1 }, value: null } }, + getAccountInfo: accountInfo, + getBalance: { result: { context: { slot: 1 }, value: answers.lamports ?? 1_000_000_000 } }, getLatestBlockhash: answers.blockhash ?? { result: { context: { slot: 1 }, @@ -581,6 +617,36 @@ describe('build', () => { await expect(solanaDeposit.build(EXPECTATION)).rejects.toBeInstanceOf(ChainActionRefused) }) + it('refuses a deposit of more USDC than the wallet holds, saying both figures', async () => { + stubRpc({ usdc: BigInt(500_000) }) + await expect(solanaDeposit.assertCanPay?.(EXPECTATION)).rejects.toThrow( + /You hold 0\.5 USDC, and this cash-out sends 25\.5 USDC/ + ) + }) + + it('refuses when there is too little SOL for the fee and opening the payout account', async () => { + stubRpc({ lamports: 400_000 }) + const attempt = solanaDeposit.assertCanPay?.(EXPECTATION) + await expect(attempt).rejects.toThrow(/open the payout address’s USDC account/) + await expect(attempt).rejects.toThrow(/You hold 0\.0004 SOL/) + }) + + it('does not refuse when the balances cannot be read, leaving it to the wallet', async () => { + stubRpc({ account: { error: { code: -32005, message: 'behind' } }, usdc: null }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{}')) + ) + await expect(solanaDeposit.assertCanPay?.(EXPECTATION)).resolves.toBeUndefined() + }) + + it('needs only the fee once the payout account exists', async () => { + stubRpc({ lamports: 400_000, payoutAccountExists: true }) + await expect(solanaDeposit.assertCanPay?.(EXPECTATION)).resolves.toBeUndefined() + stubRpc({ lamports: 10_000, payoutAccountExists: true }) + await expect(solanaDeposit.assertCanPay?.(EXPECTATION)).rejects.toThrow(/network fee/) + }) + it('refuses a memo before asking the network anything', async () => { const fetchMock = stubRpc() await expect(solanaDeposit.build({ ...EXPECTATION, memo: 'x' })).rejects.toThrow(/memo/) diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index fcca7fd6..1c60fcd5 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -31,7 +31,7 @@ import { } from '../../chains/solana/assert-safe' import { venueFetch } from '../../chains/solana/http' import { createSolanaSubmit, explorerUrl, feePayerSignature } from '../../chains/solana/submit' -import { SOLANA_TOKENS } from '../../chains/solana/tokens' +import { fromBaseUnits, SOLANA_TOKENS } from '../../chains/solana/tokens' import { withoutWalletGuards } from '../../chains/solana/wallet-guards' import { ChainActionRefused, @@ -386,6 +386,71 @@ function compile( return getBase64EncodedWireTransaction(compileTransaction(message)) } +/** Every Solana signature costs this. */ +const SIGNATURE_FEE_LAMPORTS = BigInt(5_000) +const PRIORITY_FEE_LAMPORTS = (BigInt(COMPUTE_UNIT_LIMIT) * COMPUTE_UNIT_PRICE) / BigInt(1_000_000) +/** The rent-exempt minimum of a 165-byte token account, which opening the deposit's account costs. */ +const TOKEN_ACCOUNT_RENT_LAMPORTS = BigInt(2_039_280) + +const RpcBalance = z.object({ result: z.object({ value: z.number().int().nonnegative() }) }) + +/** This wallet's balance of a token, read from its own token account, as `usdcBalance` does. */ +async function tokenBalance(account: string, token: ChainToken): Promise { + const { result } = RpcTokenAccount.parse( + await rpc('getAccountInfo', [ + associatedTokenAccount(account, token.id), + { encoding: 'jsonParsed', commitment: 'confirmed' }, + ]) + ) + return result.value === null + ? BigInt(0) + : BigInt(result.value.data.parsed.info.tokenAmount.amount) +} + +/** + * The token and the SOL this deposit spends, checked before a wallet is asked: otherwise a + * shortfall shows only as the wallet's own "insufficient balance", which says nothing of what + * is missing. The deposit opens the address's token account when it has none, and the wallet + * pays that account's rent, so the SOL needed depends on it. + */ +async function assertCanPay(expectation: DepositExpectation): Promise { + const units = orderUnits(expectation) + const token = tokenOf(expectation) + const payout = associatedTokenAccount(expectation.depositAddress, token.id) + let held: bigint + let lamports: bigint + let opens: boolean + try { + const [tokens, balance, existing] = await Promise.all([ + tokenBalance(expectation.account, token), + rpc('getBalance', [expectation.account, { commitment: 'confirmed' }]), + rpc('getAccountInfo', [payout, { encoding: 'base64', commitment: 'confirmed' }]), + ]) + held = tokens + lamports = BigInt(RpcBalance.parse(balance).result.value) + opens = RpcAccount.parse(existing).result.value === null + } catch { + // Unread is not short: the wallet still checks, and a slow node shouldn't stop a cash-out. + return + } + if (held < units) { + refuse( + `You hold ${fromBaseUnits(String(held), token.decimals)} ${token.symbol}, and this cash-out sends ${expectation.amount} ${token.symbol}.` + ) + } + const fee = SIGNATURE_FEE_LAMPORTS + PRIORITY_FEE_LAMPORTS + const needed = fee + (opens ? TOKEN_ACCOUNT_RENT_LAMPORTS : BigInt(0)) + if (lamports < needed) { + const sol = (value: bigint): string => fromBaseUnits(String(value), 9) + const why = opens + ? `${sol(fee)} for the network fee and ${sol(TOKEN_ACCOUNT_RENT_LAMPORTS)} to open the payout address’s ${token.symbol} account` + : `the network fee` + refuse( + `Sending this needs about ${sol(needed)} SOL: ${why}. You hold ${sol(lamports)} SOL. Add a little SOL and try again.` + ) + } +} + async function build(expectation: DepositExpectation): Promise { const units = orderUnits(expectation) await assertDepositIsNotTokenAccount(expectation.depositAddress, tokenOf(expectation)) @@ -448,6 +513,7 @@ export const solanaDeposit: DepositChain = { isDepositAddress: isAddress, usdcBalance, build, + assertCanPay, assertSafe, submit, } diff --git a/apps/dapp/lib/weave/types.ts b/apps/dapp/lib/weave/types.ts index 7855ad2a..068861ee 100644 --- a/apps/dapp/lib/weave/types.ts +++ b/apps/dapp/lib/weave/types.ts @@ -104,6 +104,12 @@ export interface DepositChain { /** USDC this account holds, in base units. Throws when the chain can't be read. */ usdcBalance: (account: string) => Promise build: (expectation: DepositExpectation) => Promise + /** + * Refuses, in words, a cash-out the account can't pay: too little of the token, or of the coin + * its network fee and any account it opens cost. Otherwise the wallet says only "insufficient + * balance". A read that fails doesn't refuse. Chains whose build already checks leave it out. + */ + assertCanPay?: (expectation: DepositExpectation) => Promise assertSafe: (payload: SigningPayload, expectation: DepositExpectation) => void /** * Checks what came back against `expectation` again, then sends it, or for wallets From c1ecfcb9b58b7e610efbbfa90a0e35b7a0f1280b Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:53:40 +0100 Subject: [PATCH 2/6] fix(dapp): count a relayed or smart-account evm token transfer by its transfer log A smart account, or MetaMask paying gas in a token, sends through a relayer or an EntryPoint, so the receipt's from and to are not the account and the token. The Avalanche cash-out then answered "not this order's deposit" while the USDC had moved, and the 1Click transfer stayed pending. A token transfer now counts when the token contract's own Transfer log shows exactly this amount from the account to the deposit address, whoever sent the transaction. A native coin send is unchanged, since it has no log to read, and stays pending when relayed. --- apps/dapp/lib/__tests__/evm-module.test.ts | 18 ++++++++++++--- .../__tests__/weave-deposit-avalanche.test.ts | 23 ++++++++++++++++--- apps/dapp/lib/chains/evm/submit.ts | 23 +++++++++++++++---- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 8307e0d3..0ec39431 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -1259,11 +1259,23 @@ describe('submit: what the chain says the wallet sent', () => { }) it.each<[string, () => Json]>([ + // A smart account, or MetaMask paying gas in a token, sends through a relayer or EntryPoint. + ['sent by a relayer', () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { from: OTHER })], [ - 'from another account', - () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { from: OTHER }), + 'sent through a smart account', + () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { to: OTHER }), ], - ['to another contract', () => paying([transferLog(AVAX_USDC.id, USD_UNITS)], { to: OTHER })], + ])( + 'accepts a transfer %s when the token’s own log shows this order paid', + async (_name, build) => { + rpc.waitForReceipt = async () => build() + await expect(submitTo(prepared)).resolves.toMatchObject({ ok: true, hash: HASH }) + expect(sim.notified).toHaveLength(1) + } + ) + + it.each<[string, () => Json]>([ + ['from another account, with no transfer', () => paying([], { from: OTHER })], ['with no logs', () => paying([])], ['with a log from another token', () => paying([transferLog(AVAX_USDT.id, USD_UNITS)])], [ diff --git a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts index 41eafdb9..001485b8 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts @@ -381,9 +381,26 @@ describe('avalancheDeposit.submit', () => { }) it.each<[string, DepositReceipt]>([ - ['another sender', receipt({ from: OTHER as `0x${string}` })], - ['a call to another contract', receipt({ to: OTHER_TOKEN as `0x${string}` })], - ['a contract creation', receipt({ to: null })], + // A smart account, or MetaMask paying gas in a token, sends through a relayer or EntryPoint. + ['a relayer as the sender', receipt({ from: OTHER as `0x${string}` })], + [ + 'a smart account or EntryPoint as the contract called', + receipt({ to: OTHER_TOKEN as `0x${string}` }), + ], + ])('confirms %s when USDC’s own Transfer log shows this order paid', async (_name, r) => { + await expect(withReceipt(r).submit(signed, plain)).resolves.toMatchObject({ + ok: true, + hash: HASH, + }) + }) + + it.each<[string, DepositReceipt]>([ + ['another sender, and no transfer', receipt({ from: OTHER as `0x${string}`, logs: [] })], + [ + 'a call to another contract, and no transfer', + receipt({ to: OTHER_TOKEN as `0x${string}`, logs: [] }), + ], + ['a contract creation, and no transfer', receipt({ to: null, logs: [] })], ['no logs', receipt({ logs: [] })], ['a log from another token', receipt({ logs: [transferLog({ address: OTHER_TOKEN })] })], ['a transfer to someone else', receipt({ logs: [transferLog({ to: OTHER })] })], diff --git a/apps/dapp/lib/chains/evm/submit.ts b/apps/dapp/lib/chains/evm/submit.ts index bcf7e02d..eb153d0a 100644 --- a/apps/dapp/lib/chains/evm/submit.ts +++ b/apps/dapp/lib/chains/evm/submit.ts @@ -34,13 +34,17 @@ export function transferCalldata(to: string, units: bigint): string { }) } -/** Whether this receipt shows this account paying exactly this token transfer to this address. */ +/** + * Whether this receipt shows this account paying exactly this token transfer to this address, as + * the token contract's own Transfer log says. Who sent the transaction is not asked: a smart + * account, or MetaMask paying gas in a token, sends through a relayer or an EntryPoint, and only + * the account, or a spender it approved, can move its tokens at all. + */ export function erc20TransferPaid( receipt: EvmReceipt, paid: { token: string; account: string; depositAddress: string; units: bigint } ): boolean { - if (!sameAddress(receipt.from, paid.account)) return false - if (receipt.to === null || !sameAddress(receipt.to, paid.token)) return false + if (receipt.status === 'reverted') return false return parseEventLogs({ abi: erc20Abi, eventName: 'Transfer', logs: receipt.logs }).some( (log) => sameAddress(log.address, paid.token) && @@ -97,7 +101,18 @@ export async function confirmEvmTransfer( const expectedTo = native ? depositAddress : token.id const fromThisAccountToThatAddress = sameAddress(receipt.from, account) && receipt.to !== null && sameAddress(receipt.to, expectedTo) - if (!fromThisAccountToThatAddress) return notThisSwap + if (!fromThisAccountToThatAddress) { + // A relayed or smart-account send still shows the token's own Transfer log, if it paid. + let relayedPaid = false + try { + const units = BigInt(toBaseUnits(amount, token.decimals)) + relayedPaid = + !native && erc20TransferPaid(receipt, { token: token.id, account, depositAddress, units }) + } catch { + relayedPaid = false + } + return relayedPaid ? { ok: true, hash, explorerUrl: evmExplorerUrl(chain, hash) } : notThisSwap + } // With no amount to compare against, this can't be shown to be this transfer, or not to be. let units: bigint From 39bf1322ef2094325b468280194132a4d7e84d57 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:53:22 +0100 Subject: [PATCH 3/6] fix(dapp): show what a weave cash-out actually paid, not the estimate it was made with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Sui cash-out of 0.689418 USDC showed ₦904.04 paid, while ₦877.08 reached the bank. Weave's own order record has both: its bridge to Base took 3.4% where it quoted 2%, and the payout rate came in lower, so it paid about 3% under its estimate. The order's paid amount is now read from Weave once it is paid, kept beside the estimate, and shown on the receipt, with the estimate as its own row. The review screen no longer says the final amount "can differ slightly". --- apps/dapp/components/intents/cashout-card.tsx | 14 +++++- apps/dapp/lib/__tests__/weave-orders.test.ts | 47 +++++++++++++++++-- apps/dapp/lib/weave/cashout-view.ts | 2 + apps/dapp/lib/weave/client.ts | 14 +++++- apps/dapp/lib/weave/orders.ts | 43 +++++++++++++++-- .../db/migrations/015_weave_paid_dest.sql | 5 ++ 6 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 packages/db/migrations/015_weave_paid_dest.sql diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx index 7f37437e..7974e259 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -129,6 +129,8 @@ function PayoutProgress({ onRetry: () => void }): JSX.Element { const done = order.status === 'paid' + // What Weave says it paid out. Fees and the payout rate can leave it below the estimate. + const paid = done ? order.paidDest : undefined const running = shouldPoll(order.status) const bad = !done && !running const reached = order.status === 'waiting' ? 0 : order.status === 'processing' ? 1 : 2 @@ -218,11 +220,19 @@ function PayoutProgress({ {bank !== undefined ? : null} - {formatPayout(order.estimatedDest, order.currency)} + {formatPayout(paid ?? order.estimatedDest, order.currency)}
+ {paid !== undefined && Number(paid) !== Number(order.estimatedDest) ? ( +
+
Estimated when ordered
+
+ {formatPayout(order.estimatedDest, order.currency)} +
+
+ ) : null}
Account holder
@@ -523,7 +533,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
diff --git a/apps/dapp/lib/__tests__/weave-orders.test.ts b/apps/dapp/lib/__tests__/weave-orders.test.ts index e0ea6568..76262685 100644 --- a/apps/dapp/lib/__tests__/weave-orders.test.ts +++ b/apps/dapp/lib/__tests__/weave-orders.test.ts @@ -170,7 +170,10 @@ describe('orderMismatch', () => { }) describe('syncOrder', () => { - const repo = () => ({ setStatus: vi.fn(async () => undefined) }) + const repo = () => ({ + setStatus: vi.fn(async () => undefined), + setPaid: vi.fn(async () => undefined), + }) it('records what Weave says and returns the moved row', async () => { const r = repo() @@ -199,12 +202,50 @@ describe('syncOrder', () => { expect(r.setStatus).not.toHaveBeenCalled() }) - it('does not ask about an order that has ended', async () => { + it('keeps what a paid order actually paid out, which can be below the estimate', async () => { const r = repo() - const getOrder = vi.fn() + const synced = await syncOrder(r, row(), { + getOrder: async () => ({ + ok: true, + value: { id: 'ord_1', status: 'completed', destAmount: 877.0797924000001 }, + }), + }) + expect(r.setPaid).toHaveBeenCalledWith('ord_1', '877.0797924000001') + expect(synced.row).toMatchObject({ status: 'paid', paidDest: '877.0797924000001' }) + }) + + it('asks a paid order once more for what it paid, when a webhook marked it paid', async () => { + const r = repo() + const getOrder = vi.fn(async () => ({ + ok: true as const, + value: { id: 'ord_1', status: 'completed', destAmount: 877.08 }, + })) const synced = await syncOrder(r, row({ status: 'paid', providerStatus: 'completed' }), { getOrder, }) + expect(getOrder).toHaveBeenCalledTimes(1) + expect(synced.row.paidDest).toBe('877.08') + }) + + it('does not keep a figure before the order is paid', async () => { + const r = repo() + await syncOrder(r, row(), { + getOrder: async () => ({ + ok: true, + value: { id: 'ord_1', status: 'processing', destAmount: 900 }, + }), + }) + expect(r.setPaid).not.toHaveBeenCalled() + }) + + it('does not ask about an order that has ended', async () => { + const r = repo() + const getOrder = vi.fn() + const synced = await syncOrder( + r, + row({ status: 'paid', providerStatus: 'completed', paidDest: '877.08' }), + { getOrder } + ) expect(getOrder).not.toHaveBeenCalled() expect(synced.reached).toBe(true) }) diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index 8fff818e..b8f7d4b6 100644 --- a/apps/dapp/lib/weave/cashout-view.ts +++ b/apps/dapp/lib/weave/cashout-view.ts @@ -20,6 +20,8 @@ export interface CashoutOrderView { currency: string amount: string estimatedDest: string + /** What a paid order actually paid out, which can be less than the estimate. */ + paidDest?: string | undefined institutionName: string accountName: string accountMask: string diff --git a/apps/dapp/lib/weave/client.ts b/apps/dapp/lib/weave/client.ts index 17bb5d4c..de3bd23c 100644 --- a/apps/dapp/lib/weave/client.ts +++ b/apps/dapp/lib/weave/client.ts @@ -77,6 +77,8 @@ export interface WeaveOrder { export interface WeaveOrderState { id: string status: string + /** What the order paid out, in the payout currency, as Weave records it. Present once known. */ + destAmount?: number | undefined } export interface WeaveClientOptions { @@ -144,7 +146,11 @@ const Placed = z.object({ nextAction: Deposit.nullish(), }) -const OrderState = z.object({ id: z.string().min(1).optional(), status: z.string().min(1) }) +const OrderState = z.object({ + id: z.string().min(1).optional(), + status: z.string().min(1), + destAmount: z.number().finite().nonnegative().optional(), +}) function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -383,7 +389,11 @@ export function createWeaveClient(options: WeaveClientOptions = {}): WeaveClient if (!parsed.success || (parsed.data.id !== undefined && parsed.data.id !== id)) { return { ok: false, code: 'unexpected', message: UNREADABLE } } - return { ok: true, value: { id, status: parsed.data.status } } + const { status, destAmount } = parsed.data + return { + ok: true, + value: { id, status, ...(destAmount !== undefined ? { destAmount } : {}) }, + } }, } } diff --git a/apps/dapp/lib/weave/orders.ts b/apps/dapp/lib/weave/orders.ts index f5906efa..5ee6c8c2 100644 --- a/apps/dapp/lib/weave/orders.ts +++ b/apps/dapp/lib/weave/orders.ts @@ -44,6 +44,8 @@ export const WEAVE_DDL: readonly string[] = [ )`, `CREATE INDEX IF NOT EXISTS weave_orders_email_idx ON weave_orders (email, created_at)`, `CREATE INDEX IF NOT EXISTS weave_orders_account_idx ON weave_orders (account, created_at)`, + // What a paid order actually paid out, which can differ from the estimate it was made with. + `ALTER TABLE weave_orders ADD COLUMN IF NOT EXISTS paid_dest TEXT`, ] /** Said wherever a deposit may already be on its way, so nobody pays twice. */ @@ -67,6 +69,8 @@ export interface WeaveOrderRow { /** USDC to deposit, display units. Decimal text, because this becomes a payment amount. */ amount: string estimatedDest: string + /** What it actually paid out, once Weave says so. */ + paidDest?: string | undefined expiresAt: string | undefined status: WeaveStatus providerStatus: string @@ -88,6 +92,8 @@ export interface WeaveOrderView { currency: string amount: string estimatedDest: string + /** What it actually paid out, once Weave says so. */ + paidDest?: string | undefined institutionName: string accountName: string accountMask: string @@ -107,6 +113,7 @@ export function toPublic(row: WeaveOrderRow): WeaveOrderView { currency: row.currency, amount: row.amount, estimatedDest: row.estimatedDest, + ...(row.paidDest !== undefined ? { paidDest: row.paidDest } : {}), institutionName: row.institutionName, accountName: row.accountName, accountMask: `••••${identifier.length > 4 ? identifier.slice(-4) : ''}`, @@ -148,6 +155,7 @@ function fromDb(r: Record): WeaveOrderRow { depositMemo: cleanMemo(r['deposit_memo']), amount: String(r['amount']), estimatedDest: String(r['estimated_dest']), + paidDest: optional(r['paid_dest']), expiresAt: expires === null || expires === undefined ? undefined @@ -184,6 +192,8 @@ export interface WeaveOrderRepo { /** Not scoped, because the webhook has no requester. Its signature is what admits it. */ getByProviderOrder: (providerOrder: string) => Promise setStatus: (providerOrder: string, status: WeaveStatus, providerStatus: string) => Promise + /** Records what a paid order paid out, once. */ + setPaid: (providerOrder: string, paidDest: string) => Promise /** * `reused` when another order holds this hash, or this order holds a different * one. A wallet that sends for itself only hands over a hash, and an old one that @@ -288,6 +298,14 @@ export function createWeaveOrderRepo(query: QueryFn): WeaveOrderRepo { return row === undefined ? undefined : fromDb(row) }, + async setPaid(providerOrder, paidDest) { + await query( + `UPDATE weave_orders SET paid_dest = $2, updated_at = now() + WHERE provider_order = $1 AND status = 'paid' AND paid_dest IS NULL`, + [providerOrder, paidDest] + ) + }, + async setStatus(providerOrder, status, providerStatus) { // Held here as well as in `nextStatus`, because a webhook and a status read // can each decide from a row the other has since moved. @@ -452,22 +470,37 @@ export interface SyncedOrder { /** * Asks Weave where an order stands and records it. The stored status is only the - * last thing this app was told, and a webhook can be missed. Amounts are never - * written back: what an order expects is what it was made for. + * last thing this app was told, and a webhook can be missed. What an order expects is never + * written back: it is what it was made for. What a paid order paid out is kept beside it. */ export async function syncOrder( - repo: Pick, + repo: Pick, row: WeaveOrderRow, weave: Pick ): Promise { - if (isFinal(row.status)) return { row, reached: true } + // A paid order is asked once more for what it paid out, which a webhook does not carry. + const paidUnread = row.status === 'paid' && row.paidDest === undefined + if (isFinal(row.status) && !paidUnread) return { row, reached: true } const live = await weave.getOrder(row.providerOrder) if (!live.ok) return { row, reached: false } const status = nextStatus(row.status, live.value.status) if (status !== row.status || live.value.status !== row.providerStatus) { await repo.setStatus(row.providerOrder, status, live.value.status) } - return { row: { ...row, status, providerStatus: live.value.status }, reached: true } + const paid = + status === 'paid' && live.value.destAmount !== undefined && row.paidDest === undefined + ? String(live.value.destAmount) + : undefined + if (paid !== undefined) await repo.setPaid(row.providerOrder, paid) + return { + row: { + ...row, + status, + providerStatus: live.value.status, + ...(paid !== undefined ? { paidDest: paid } : {}), + }, + reached: true, + } } export function isPastExpiry(row: WeaveOrderRow, now: number = Date.now()): boolean { diff --git a/packages/db/migrations/015_weave_paid_dest.sql b/packages/db/migrations/015_weave_paid_dest.sql new file mode 100644 index 00000000..c26a2174 --- /dev/null +++ b/packages/db/migrations/015_weave_paid_dest.sql @@ -0,0 +1,5 @@ +-- What a paid Weave order actually paid out, kept beside the estimate it was made with, which +-- fees and the payout rate can leave it below. The application runs the same statement on first +-- use; keep this in sync with WEAVE_DDL in apps/dapp/lib/weave/orders.ts. + +ALTER TABLE weave_orders ADD COLUMN IF NOT EXISTS paid_dest TEXT; From f63d8d0239e9738b7871b12616bfa6ce1b6be8aa Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:57:48 +0100 Subject: [PATCH 4/6] feat(dapp): list weave on the apps page of every chain it cashes out from Weave runs cash-outs on Stellar, Solana, Avalanche, Sui and NEAR, and was listed on none of them. Each now has a Weave row with its logo, marked as running. A cash-out runs through its own route rather than a chain venue, so the checks that tie running rows to wired venues leave Weave out, and a new test holds its rows to exactly the cash-out chains. --- apps/dapp/lib/__tests__/evm-module.test.ts | 1 + apps/dapp/lib/__tests__/near-venues.test.ts | 7 +++++-- apps/dapp/lib/__tests__/solana-venues.test.ts | 6 ++++-- apps/dapp/lib/__tests__/sui-module.test.ts | 12 +++++++++--- .../dapp/lib/__tests__/venue-integration.test.ts | 14 ++++++++++++++ apps/dapp/lib/chains/evm/venues.ts | 12 ++++++++++++ apps/dapp/lib/chains/near/venues.ts | 12 ++++++++++++ apps/dapp/lib/chains/solana/venues.ts | 12 ++++++++++++ apps/dapp/lib/chains/sui/venues.ts | 12 ++++++++++++ apps/dapp/lib/venues.ts | 13 +++++++++++++ .../public/images/venues/weave-avalanche.webp | Bin 0 -> 1458 bytes apps/dapp/public/images/venues/weave-near.webp | Bin 0 -> 1458 bytes apps/dapp/public/images/venues/weave-solana.webp | Bin 0 -> 1458 bytes .../dapp/public/images/venues/weave-stellar.webp | Bin 0 -> 1458 bytes apps/dapp/public/images/venues/weave-sui.webp | Bin 0 -> 1458 bytes 15 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 apps/dapp/public/images/venues/weave-avalanche.webp create mode 100644 apps/dapp/public/images/venues/weave-near.webp create mode 100644 apps/dapp/public/images/venues/weave-solana.webp create mode 100644 apps/dapp/public/images/venues/weave-stellar.webp create mode 100644 apps/dapp/public/images/venues/weave-sui.webp diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts index 0ec39431..544cccb7 100644 --- a/apps/dapp/lib/__tests__/evm-module.test.ts +++ b/apps/dapp/lib/__tests__/evm-module.test.ts @@ -656,6 +656,7 @@ describe('the Apps page rows', () => { it('shows each chain its own row and no venue that does not name it', () => { expect(on('avalanche')).toEqual([ + 'weave-avalanche', '1click-avalanche', 'cow-limit', 'uniswap-avalanche', diff --git a/apps/dapp/lib/__tests__/near-venues.test.ts b/apps/dapp/lib/__tests__/near-venues.test.ts index 85f8908e..2b4986c6 100644 --- a/apps/dapp/lib/__tests__/near-venues.test.ts +++ b/apps/dapp/lib/__tests__/near-venues.test.ts @@ -26,7 +26,7 @@ const CATEGORIES: readonly VenueCategory[] = [ describe('the NEAR venue list', () => { it('has every row of the report, with the fields filled in', () => { - expect(nearVenues).toHaveLength(15) + expect(nearVenues).toHaveLength(16) for (const venue of nearVenues) { expect(venue.family).toBe('near') expect(venue.chains).toEqual(['NEAR']) @@ -42,7 +42,10 @@ describe('the NEAR venue list', () => { }) it('claims execution exactly for the venues the module wires', () => { - const claimed = nearVenues.filter((v) => v.integration === 'executes').map((v) => v.id) + // A cash-out runs through its own route, not a chain venue, so Weave's row is left out here. + const claimed = nearVenues + .filter((v) => v.integration === 'executes' && !v.id.startsWith('weave-')) + .map((v) => v.id) // A wallet transfer is not an app, so the Apps list has no entry for it. const wired = nearModule.venues.map((v) => v.id).filter((id) => id !== 'send') expect(claimed.sort()).toEqual(wired.sort()) diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index 11af1c01..6eed1238 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -748,8 +748,8 @@ describe('module', () => { describe('venues page', () => { it('lists every row of the report', () => { - expect(solanaVenues).toHaveLength(22) - expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(22) + expect(solanaVenues).toHaveLength(23) + expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(23) for (const v of solanaVenues) { expect(v.family).toBe('solana') expect(v.chains).toEqual(['Solana']) @@ -764,6 +764,8 @@ describe('venues page', () => { for (const v of executes) { expect(v.capability).toBeTruthy() // OnRe has no venue of its own: it is bought through the Jupiter swap venue. + // A cash-out runs through its own route, not a chain venue. + if (v.id.startsWith('weave-')) continue expect(v.id === 'onre' ? wired.includes('jupiter') : wired.includes(v.id)).toBe(true) } for (const id of wired) expect(executes.map((v) => v.id)).toContain(id) diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts index 237bd5ef..8cd43cd8 100644 --- a/apps/dapp/lib/__tests__/sui-module.test.ts +++ b/apps/dapp/lib/__tests__/sui-module.test.ts @@ -356,8 +356,8 @@ describe('the module', () => { describe('the venue list', () => { it('has every row of the report', () => { - expect(suiVenues).toHaveLength(17) - expect(new Set(suiVenues.map((v) => v.id)).size).toBe(17) + expect(suiVenues).toHaveLength(18) + expect(new Set(suiVenues.map((v) => v.id)).size).toBe(18) }) it('is all Sui, with a real link and an honest reason', () => { @@ -373,8 +373,14 @@ describe('the venue list', () => { const wired = buildSuiVenues({}) .map((v) => v.id) .sort() + // A cash-out runs through its own route, not a chain venue, so Weave's row is left out here. const claimed = suiVenues.filter((v) => v.integration === 'executes') - expect(claimed.map((v) => v.id).sort()).toEqual(wired) + expect( + claimed + .map((v) => v.id) + .filter((id) => !id.startsWith('weave-')) + .sort() + ).toEqual(wired) for (const venue of claimed) expect(venue.capability).toBeTruthy() }) diff --git a/apps/dapp/lib/__tests__/venue-integration.test.ts b/apps/dapp/lib/__tests__/venue-integration.test.ts index cd448af7..23467ee1 100644 --- a/apps/dapp/lib/__tests__/venue-integration.test.ts +++ b/apps/dapp/lib/__tests__/venue-integration.test.ts @@ -16,6 +16,7 @@ import { venues } from '../venues' */ const byId = (id: string) => venues.find((v) => v.id === id) +const byIdPrefix = (prefix: string) => venues.filter((v) => v.id.startsWith(prefix)) describe('integration status is claimed accurately', () => { it('marks the Stellar DEX as executing', () => { @@ -189,3 +190,16 @@ describe('beta venues', () => { expect(others).toEqual([]) }) }) + +describe('Weave, the cash-out', () => { + it('is listed, running, on every chain a cash-out runs on, and nowhere else', async () => { + const { CASHOUT_CHAINS } = await import('../weave/types') + const weave = byIdPrefix('weave-') + expect(weave.map((v) => v.id).sort()).toEqual(CASHOUT_CHAINS.map((c) => `weave-${c}`).sort()) + for (const v of weave) { + expect(v.category).toBe('offramp') + expect(v.integration).toBe('executes') + expect(v.capability).toMatch(/actually paid/) + } + }) +}) diff --git a/apps/dapp/lib/chains/evm/venues.ts b/apps/dapp/lib/chains/evm/venues.ts index 0cf1ba4d..636ac43b 100644 --- a/apps/dapp/lib/chains/evm/venues.ts +++ b/apps/dapp/lib/chains/evm/venues.ts @@ -17,6 +17,18 @@ const ONECLICK_DOCS = // Listed, as Lulo is on Solana: whether this runs depends on a secret this client-safe file can't read. export const evmVenues: Venue[] = [ + { + id: 'weave-avalanche', + name: 'Weave', + family: 'evm', + category: 'offramp', + chains: ['Avalanche'], + bestFor: 'Cashing out USDC to a bank or mobile money in naira, shillings and more', + url: 'https://paywithweave.com', + integration: 'executes', + capability: + 'Cash out USDC to a bank or mobile money account in Nigeria, Kenya, Uganda or Tanzania. Weave bridges the USDC to Base and pays out through Paycrest, and the receipt shows what it actually paid. Every deposit is checked before you sign.', + }, { id: '1click-avalanche', family: 'evm', diff --git a/apps/dapp/lib/chains/near/venues.ts b/apps/dapp/lib/chains/near/venues.ts index 1c1fa5c3..73bbf520 100644 --- a/apps/dapp/lib/chains/near/venues.ts +++ b/apps/dapp/lib/chains/near/venues.ts @@ -11,6 +11,18 @@ const NEAR = ['NEAR'] * payout has no clean refund path. */ export const nearVenues: Venue[] = [ + { + id: 'weave-near', + name: 'Weave', + family: 'near', + category: 'offramp', + chains: ['NEAR'], + bestFor: 'Cashing out USDC to a bank or mobile money in naira, shillings and more', + url: 'https://paywithweave.com', + integration: 'executes', + capability: + 'Cash out USDC to a bank or mobile money account in Nigeria, Kenya, Uganda or Tanzania. Weave bridges the USDC to Base and pays out through Paycrest, and the receipt shows what it actually paid. Every deposit is checked before you sign.', + }, { id: '1click', family: 'near', diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts index 46b6613f..0eef6634 100644 --- a/apps/dapp/lib/chains/solana/venues.ts +++ b/apps/dapp/lib/chains/solana/venues.ts @@ -12,6 +12,18 @@ import { streamflowVenue } from './streamflow-listing' * extra configuration. Everything else is a listing and claims nothing. Client-safe. */ export const solanaVenues: Venue[] = [ + { + id: 'weave-solana', + name: 'Weave', + family: 'solana', + category: 'offramp', + chains: ['Solana'], + bestFor: 'Cashing out USDC to a bank or mobile money in naira, shillings and more', + url: 'https://paywithweave.com', + integration: 'executes', + capability: + 'Cash out USDC to a bank or mobile money account in Nigeria, Kenya, Uganda or Tanzania. Weave bridges the USDC to Base and pays out through Paycrest, and the receipt shows what it actually paid. Every deposit is checked before you sign.', + }, { id: 'jupiter', family: 'solana', diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts index 5599a80e..f2cd033e 100644 --- a/apps/dapp/lib/chains/sui/venues.ts +++ b/apps/dapp/lib/chains/sui/venues.ts @@ -18,6 +18,18 @@ function listed( } export const suiVenues: Venue[] = [ + { + id: 'weave-sui', + name: 'Weave', + family: 'sui', + category: 'offramp', + chains: ['Sui'], + bestFor: 'Cashing out USDC to a bank or mobile money in naira, shillings and more', + url: 'https://paywithweave.com', + integration: 'executes', + capability: + 'Cash out USDC to a bank or mobile money account in Nigeria, Kenya, Uganda or Tanzania. Weave bridges the USDC to Base and pays out through Paycrest, and the receipt shows what it actually paid. Every deposit is checked before you sign.', + }, listed( 'linq-sui', 'Linq (Sui USDC, USDsui)', diff --git a/apps/dapp/lib/venues.ts b/apps/dapp/lib/venues.ts index f5453b0d..494ab8f1 100644 --- a/apps/dapp/lib/venues.ts +++ b/apps/dapp/lib/venues.ts @@ -249,6 +249,19 @@ export const venues: Venue[] = [ }, // A payout service, not a trading venue: the agents' venue list drops the // off-ramp category, and a payout is a follow-on the user fills in a form. + { + id: 'weave-stellar', + name: 'Weave', + family: 'stellar' as const, + category: 'offramp', + chains: ['Stellar'], + bestFor: 'Cashing out USDC to a bank or mobile money in naira, shillings and more', + url: 'https://paywithweave.com', + integration: 'executes', + networks: ['mainnet'], + capability: + 'Cash out USDC to a bank or mobile money account in Nigeria, Kenya, Uganda or Tanzania. Weave bridges the USDC to Base and pays out through Paycrest, and the receipt shows what it actually paid. Every deposit is checked before you sign.', + }, { id: 'linq', name: 'Linq', diff --git a/apps/dapp/public/images/venues/weave-avalanche.webp b/apps/dapp/public/images/venues/weave-avalanche.webp new file mode 100644 index 0000000000000000000000000000000000000000..63415111ab41878af008330b0dab7c53c62a52ac GIT binary patch literal 1458 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 Date: Fri, 6 Nov 2026 17:08:33 +0100 Subject: [PATCH 5/6] fix(dapp): show only what reached the account once a cash-out is paid The receipt now shows the one figure Weave says reached the account, after every fee, and no estimate beside it. Until Weave gives that figure it reads "Confirming the amount paid", and the card reads the order a few more times for it. While a payout is still on its way the figure is marked approximate, since no provider knows the exact amount before it pays. --- apps/dapp/components/intents/cashout-card.tsx | 16 ++++++---------- apps/dapp/hooks/use-cashout.ts | 12 +++++++++++- .../dapp/lib/__tests__/venue-networks.test.ts | 5 +++-- .../__tests__/weave-cashout-routes.test.ts | 19 ++++++++++++++++--- .../lib/__tests__/weave-cashout-view.test.ts | 10 ++++++++++ apps/dapp/lib/weave/cashout-view.ts | 8 ++++++++ 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx index 7974e259..a11fddce 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -129,7 +129,7 @@ function PayoutProgress({ onRetry: () => void }): JSX.Element { const done = order.status === 'paid' - // What Weave says it paid out. Fees and the payout rate can leave it below the estimate. + // What Weave says reached the account, after every fee. Only this is shown once paid. const paid = done ? order.paidDest : undefined const running = shouldPoll(order.status) const bad = !done && !running @@ -220,19 +220,15 @@ function PayoutProgress({ {bank !== undefined ? : null} - {formatPayout(paid ?? order.estimatedDest, order.currency)} + {paid !== undefined + ? formatPayout(paid, order.currency) + : done + ? 'Confirming the amount paid…' + : `≈ ${formatPayout(order.estimatedDest, order.currency)}`}
- {paid !== undefined && Number(paid) !== Number(order.estimatedDest) ? ( -
-
Estimated when ordered
-
- {formatPayout(order.estimatedDest, order.currency)} -
-
- ) : null}
Account holder
diff --git a/apps/dapp/hooks/use-cashout.ts b/apps/dapp/hooks/use-cashout.ts index 010cb1e7..b8e1a0d7 100644 --- a/apps/dapp/hooks/use-cashout.ts +++ b/apps/dapp/hooks/use-cashout.ts @@ -14,6 +14,8 @@ import { nextAttempt, orderPhase, shouldPoll, + awaitsPaidAmount, + PAID_AMOUNT_READS, type CashoutAttempt, type CashoutCurrency, type CashoutOrderView, @@ -355,10 +357,17 @@ export function useCashout(): Cashout { setOpened((o) => ({ key: (o?.key ?? 0) + 1, seed: {}, chain: slug })) }, [locked, slug]) + // A paid order is read a few more times for what it paid, which is the figure the receipt shows. + const paidReads = useRef(0) + const orderId = state.order?.id + useEffect(() => { + paidReads.current = 0 + }, [orderId]) const trackedId = (state.phase === 'tracking' || state.phase === 'pending' || state.phase === 'check') && state.order !== undefined && - shouldPoll(state.order.status) + (shouldPoll(state.order.status) || + (awaitsPaidAmount(state.order) && paidReads.current < PAID_AMOUNT_READS)) ? state.order.id : undefined @@ -374,6 +383,7 @@ export function useCashout(): Cashout { const body = (await res.json()) as { order?: CashoutOrderView } const next = body.order if (next === undefined || cancelled) return + if (awaitsPaidAmount(next)) paidReads.current += 1 setState((s) => { if (s.order?.id !== next.id) return s const unsure = s.phase === 'pending' || s.phase === 'check' diff --git a/apps/dapp/lib/__tests__/venue-networks.test.ts b/apps/dapp/lib/__tests__/venue-networks.test.ts index dc82a778..2ed585cf 100644 --- a/apps/dapp/lib/__tests__/venue-networks.test.ts +++ b/apps/dapp/lib/__tests__/venue-networks.test.ts @@ -52,6 +52,7 @@ describe('which venues are on mainnet at launch', () => { 'soroswap-aggregator', 'stellar-pools', 'stellarx', + 'weave-stellar', ].sort() ) }) @@ -61,7 +62,7 @@ describe('which venues are on mainnet at launch', () => { // service behind it. Phoenix has no standing testnet deployment at all // (see contract-registry.ts's phoenixFactory). Both are deliberately // absent there rather than listed and unusable. - const mainnetOnly = new Set(['linq', 'phoenix']) + const mainnetOnly = new Set(['linq', 'phoenix', 'weave-stellar']) for (const v of stellar) { expect(isVenueOn(v, 'testnet'), `${v.id} on testnet`).toBe(!mainnetOnly.has(v.id)) } @@ -219,7 +220,7 @@ describe('what the Apps page may call available, decided on the server', () => { it('has every Stellar venue on testnet, except the ones that have no testnet', () => { const ids = availableVenueIds('testnet', {}) - const mainnetOnly = new Set(['linq', 'phoenix']) + const mainnetOnly = new Set(['linq', 'phoenix', 'weave-stellar']) for (const v of stellar) expect(ids.has(v.id), v.id).toBe(!mainnetOnly.has(v.id)) }) diff --git a/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts b/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts index d413eb72..b7f70bf0 100644 --- a/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts +++ b/apps/dapp/lib/__tests__/weave-cashout-routes.test.ts @@ -102,6 +102,7 @@ beforeEach(() => { getByProviderOrder: vi.fn(async () => row()), listForEmail: vi.fn(async () => [row()]), setStatus: vi.fn(async () => undefined), + setPaid: vi.fn(async () => undefined), } vi.stubEnv('WEAVE_CASHOUT_CURRENCIES', 'NGN,KES') vi.stubEnv('WEAVE_WEBHOOK_SECRET', 'whsec') @@ -277,15 +278,27 @@ describe('GET one order', () => { expect(fn(state.repo['setStatus'])).toHaveBeenCalledWith('ord_1', 'processing', 'processing') }) - it('does not ask Weave about a final order', async () => { + it('does not ask Weave about a final order whose paid amount is known', async () => { fn(state.repo['getForEmail']).mockResolvedValue( - row({ status: 'paid', providerStatus: 'completed' }) + row({ status: 'paid', providerStatus: 'completed', paidDest: '877.08' }) ) const res = await one(get(`orders/${ID}`), context) - expect((await res.json()).order.status).toBe('paid') + expect((await res.json()).order).toMatchObject({ status: 'paid', paidDest: '877.08' }) expect(fn(state.weave['getOrder'])).not.toHaveBeenCalled() }) + it('asks Weave once for what a paid order paid, and shows that', async () => { + fn(state.repo['getForEmail']).mockResolvedValue( + row({ status: 'paid', providerStatus: 'completed' }) + ) + fn(state.weave['getOrder']).mockResolvedValue({ + ok: true, + value: { id: ID, status: 'completed', destAmount: 877.08 }, + }) + const res = await one(get(`orders/${ID}`), context) + expect((await res.json()).order).toMatchObject({ status: 'paid', paidDest: '877.08' }) + }) + it('falls back to the stored row when Weave cannot be reached', async () => { fn(state.weave['getOrder']).mockResolvedValue({ ok: false, code: 'unavailable', message: 'x' }) const res = await one(get(`orders/${ID}`), context) diff --git a/apps/dapp/lib/__tests__/weave-cashout-view.test.ts b/apps/dapp/lib/__tests__/weave-cashout-view.test.ts index c1173c7b..88829d68 100644 --- a/apps/dapp/lib/__tests__/weave-cashout-view.test.ts +++ b/apps/dapp/lib/__tests__/weave-cashout-view.test.ts @@ -20,6 +20,7 @@ import { nextAttempt, orderPhase, shouldPoll, + awaitsPaidAmount, statusText, unsupportedCurrencyReply, unsupportedTokenReply, @@ -395,3 +396,12 @@ describe('words', () => { expect(depositExplorerUrl('stellar', 'h')).toBe('https://stellar.expert/explorer/public/tx/h') }) }) + +describe('awaitsPaidAmount', () => { + it('is true only for a paid order whose amount Weave has not given yet', () => { + expect(awaitsPaidAmount({ status: 'paid' })).toBe(true) + expect(awaitsPaidAmount({ status: 'paid', paidDest: '877.08' })).toBe(false) + expect(awaitsPaidAmount({ status: 'processing' })).toBe(false) + expect(awaitsPaidAmount({ status: 'failed' })).toBe(false) + }) +}) diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index b8f7d4b6..de88c0c2 100644 --- a/apps/dapp/lib/weave/cashout-view.ts +++ b/apps/dapp/lib/weave/cashout-view.ts @@ -159,6 +159,14 @@ export function shouldPoll(status: CashoutStatus): boolean { return status === 'waiting' || status === 'processing' } +/** Paid, but Weave hasn't yet said how much reached the account, which is the figure shown. */ +export function awaitsPaidAmount(order: Pick): boolean { + return order.status === 'paid' && order.paidDest === undefined +} + +/** How many more reads a paid order gets for its amount, at the poll's pace, before it stops. */ +export const PAID_AMOUNT_READS = 12 + const MOBILE_PREFIX: Record = { KES: '254712345678', UGX: '256712345678', From 5ffdd1c12ea739cdccf1befee5e48dc030044170 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:18:21 +0100 Subject: [PATCH 6/6] feat(dapp): compare linq and weave naira offers, by what reaches the account nairaOffers asks each provider that can pay naira from a chain, and bestNairaOffer picks the one paying most, preferring Linq's locked amount over a Weave quote within half a percent. Nothing calls it yet, so no screen or step changes. On Stellar only Linq can pay: Weave's Stellar orders route through LI.FI with no deposit address. Linq on Sui and Solana joins once an order on each has been seen, since its docs show no response for them. --- .../lib/__tests__/weave-best-payout.test.ts | 74 +++++++++++++++ apps/dapp/lib/weave/best-payout.ts | 89 +++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/dapp/lib/__tests__/weave-best-payout.test.ts create mode 100644 apps/dapp/lib/weave/best-payout.ts diff --git a/apps/dapp/lib/__tests__/weave-best-payout.test.ts b/apps/dapp/lib/__tests__/weave-best-payout.test.ts new file mode 100644 index 00000000..37c5d0c6 --- /dev/null +++ b/apps/dapp/lib/__tests__/weave-best-payout.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' + +import { bestNairaOffer, nairaOffers, PAYS_NAIRA } from '../weave/best-payout' + +const sources = (linq: number | undefined, weave: number | undefined) => ({ + linqRate: vi.fn(async () => linq), + weaveNaira: vi.fn(async () => weave), +}) + +describe('nairaOffers', () => { + it('asks only the providers that can pay naira from this chain', async () => { + const onStellar = sources(1368.39, 900) + expect(await nairaOffers('stellar', '1', onStellar)).toEqual([ + { provider: 'linq', naira: 1368.39, exact: true }, + ]) + expect(onStellar.weaveNaira).not.toHaveBeenCalled() + + const onSui = sources(1368.39, 916.44) + expect(await nairaOffers('sui', '0.689418', onSui)).toEqual([ + { provider: 'weave', naira: 916.44, exact: false }, + ]) + expect(onSui.linqRate).not.toHaveBeenCalled() + }) + + it('gives no offer for a provider that cannot be read, rather than a guess', async () => { + const down = { + linqRate: vi.fn(async () => Promise.reject(new Error('down'))), + weaveNaira: vi.fn(), + } + expect(await nairaOffers('stellar', '1', down)).toEqual([]) + expect(await nairaOffers('sui', '1', sources(undefined, Number.NaN))).toEqual([]) + }) + + it('gives nothing for an amount that is not one', async () => { + expect(await nairaOffers('stellar', '0', sources(1368, 1))).toEqual([]) + expect(await nairaOffers('stellar', 'abc', sources(1368, 1))).toEqual([]) + }) + + it('pays naira from Linq on Stellar only, and from Weave nowhere on Stellar or NEAR', () => { + expect(PAYS_NAIRA.linq).toEqual(['stellar']) + expect(PAYS_NAIRA.weave).not.toContain('stellar') + expect(PAYS_NAIRA.weave).not.toContain('near') + }) +}) + +describe('bestNairaOffer', () => { + it('picks the most naira', () => { + expect( + bestNairaOffer([ + { provider: 'weave', naira: 916.44, exact: false }, + { provider: 'linq', naira: 943.4, exact: true }, + ])?.provider + ).toBe('linq') + expect( + bestNairaOffer([ + { provider: 'linq', naira: 900, exact: true }, + { provider: 'weave', naira: 950, exact: false }, + ])?.provider + ).toBe('weave') + }) + + it('prefers the exact amount when the two are within half a percent', () => { + expect( + bestNairaOffer([ + { provider: 'weave', naira: 1003, exact: false }, + { provider: 'linq', naira: 1000, exact: true }, + ])?.provider + ).toBe('linq') + }) + + it('has nothing to pick from nothing', () => { + expect(bestNairaOffer([])).toBeUndefined() + }) +}) diff --git a/apps/dapp/lib/weave/best-payout.ts b/apps/dapp/lib/weave/best-payout.ts new file mode 100644 index 00000000..226e393f --- /dev/null +++ b/apps/dapp/lib/weave/best-payout.ts @@ -0,0 +1,89 @@ +import type { CashoutChain } from './types' + +export type PayoutProvider = 'linq' | 'weave' + +/** One provider's offer for a naira cash-out, in the naira that reaches the account. */ +export interface PayoutOffer { + provider: PayoutProvider + /** Naira after the provider's own fees, as it says before paying. */ + naira: number + /** + * Linq states the naira and locks its rate when the order is made. Weave's figure is a quote, + * and its first real payout landed about 3% below it, so only Linq's is exact. + */ + exact: boolean +} + +/** + * Where each provider can take a deposit and pay naira today, as seen on its own orders. + * + * Linq's docs list Sui (USDC, USDSUI) and Solana (USDC) besides Stellar. They are left out until an + * order on each has been seen, since no public reference shows what such an order returns. + * + * Weave's Stellar orders route through LI.FI and come back with no deposit address, which nothing + * here can pay (Weave's own order history, 4 November 2026). NEAR is closed in `types.ts`. + */ +export const PAYS_NAIRA: Readonly> = { + linq: ['stellar'], + weave: ['solana', 'avalanche', 'sui'], +} + +export interface OfferSources { + /** Linq's naira per dollar, or nothing when it can't be read. */ + linqRate: () => Promise + /** Weave's naira for this much USDC from this chain, after its fees, or nothing. */ + weaveNaira: (chain: CashoutChain, usdc: string) => Promise +} + +const usable = (naira: number | undefined): naira is number => + naira !== undefined && Number.isFinite(naira) && naira > 0 + +/** + * Every offer for cashing out this much USDC from this chain into naira, from each provider that + * can pay from it. A provider that can't be read gives no offer rather than a guess. + */ +export async function nairaOffers( + chain: CashoutChain, + usdc: string, + sources: OfferSources +): Promise { + const amount = Number(usdc) + if (!Number.isFinite(amount) || amount <= 0) return [] + const offers: PayoutOffer[] = [] + const reads: Promise[] = [] + if (PAYS_NAIRA.linq.includes(chain)) { + reads.push( + sources + .linqRate() + .catch(() => undefined) + .then((rate) => { + const naira = rate === undefined ? undefined : rate * amount + if (usable(naira)) offers.push({ provider: 'linq', naira, exact: true }) + }) + ) + } + if (PAYS_NAIRA.weave.includes(chain)) { + reads.push( + sources + .weaveNaira(chain, usdc) + .catch(() => undefined) + .then((naira) => { + if (usable(naira)) offers.push({ provider: 'weave', naira, exact: false }) + }) + ) + } + await Promise.all(reads) + return offers.sort((a, b) => b.naira - a.naira) +} + +/** + * The offer that pays the most naira. Within half a percent, the one whose amount is exact wins, + * since a quote that can land lower is worth less than a figure that is locked. + */ +export function bestNairaOffer(offers: readonly PayoutOffer[]): PayoutOffer | undefined { + return [...offers].sort((a, b) => { + const close = Math.abs(a.naira - b.naira) <= Math.max(a.naira, b.naira) * 0.005 + if (close && a.exact !== b.exact) return a.exact ? -1 : 1 + return b.naira - a.naira + })[0] +}