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__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts
index 8307e0d3..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',
@@ -1259,11 +1260,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__/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/__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-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/__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/__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/__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/__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/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
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/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]
+}
diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts
index 8fff818e..de88c0c2 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
@@ -157,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',
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/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/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/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
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 00000000..63415111
Binary files /dev/null and b/apps/dapp/public/images/venues/weave-avalanche.webp differ
diff --git a/apps/dapp/public/images/venues/weave-near.webp b/apps/dapp/public/images/venues/weave-near.webp
new file mode 100644
index 00000000..63415111
Binary files /dev/null and b/apps/dapp/public/images/venues/weave-near.webp differ
diff --git a/apps/dapp/public/images/venues/weave-solana.webp b/apps/dapp/public/images/venues/weave-solana.webp
new file mode 100644
index 00000000..63415111
Binary files /dev/null and b/apps/dapp/public/images/venues/weave-solana.webp differ
diff --git a/apps/dapp/public/images/venues/weave-stellar.webp b/apps/dapp/public/images/venues/weave-stellar.webp
new file mode 100644
index 00000000..63415111
Binary files /dev/null and b/apps/dapp/public/images/venues/weave-stellar.webp differ
diff --git a/apps/dapp/public/images/venues/weave-sui.webp b/apps/dapp/public/images/venues/weave-sui.webp
new file mode 100644
index 00000000..63415111
Binary files /dev/null and b/apps/dapp/public/images/venues/weave-sui.webp differ
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;