Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dapp/app/api/cashout/orders/[id]/build/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
10 changes: 8 additions & 2 deletions apps/dapp/components/intents/cashout-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ function PayoutProgress({
onRetry: () => void
}): JSX.Element {
const done = order.status === 'paid'
// 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
const reached = order.status === 'waiting' ? 0 : order.status === 'processing' ? 1 : 2
Expand Down Expand Up @@ -218,7 +220,11 @@ function PayoutProgress({
<ArrowRight className="text-muted-foreground h-4 w-4" />
<span className="flex items-center gap-2 text-lg font-semibold">
{bank !== undefined ? <BankMark bank={bank} className="h-6 w-6" /> : null}
{formatPayout(order.estimatedDest, order.currency)}
{paid !== undefined
? formatPayout(paid, order.currency)
: done
? 'Confirming the amount paid…'
: `≈ ${formatPayout(order.estimatedDest, order.currency)}`}
</span>
</div>

Expand Down Expand Up @@ -523,7 +529,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
<div className={cn(SHELL, 'gap-5')}>
<CardHeader
title="Review payout"
subtitle="This is an estimate. The final amount can differ slightly."
subtitle="An estimate after the provider’s fees. What lands can be a few percent lower, and the receipt shows the exact amount paid."
/>

<div className="bg-background/60 flex flex-col items-center gap-1 rounded-2xl px-4 py-5 text-center tabular-nums dark:bg-[#121212]">
Expand Down
12 changes: 11 additions & 1 deletion apps/dapp/hooks/use-cashout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
nextAttempt,
orderPhase,
shouldPoll,
awaitsPaidAmount,
PAID_AMOUNT_READS,
type CashoutAttempt,
type CashoutCurrency,
type CashoutOrderView,
Expand Down Expand Up @@ -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

Expand All @@ -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'
Expand Down
19 changes: 16 additions & 3 deletions apps/dapp/lib/__tests__/evm-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)])],
[
Expand Down
7 changes: 5 additions & 2 deletions apps/dapp/lib/__tests__/near-venues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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())
Expand Down
6 changes: 4 additions & 2 deletions apps/dapp/lib/__tests__/solana-venues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions apps/dapp/lib/__tests__/sui-module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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()
})

Expand Down
14 changes: 14 additions & 0 deletions apps/dapp/lib/__tests__/venue-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/)
}
})
})
5 changes: 3 additions & 2 deletions apps/dapp/lib/__tests__/venue-networks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ describe('which venues are on mainnet at launch', () => {
'soroswap-aggregator',
'stellar-pools',
'stellarx',
'weave-stellar',
].sort()
)
})
Expand All @@ -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))
}
Expand Down Expand Up @@ -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))
})

Expand Down
74 changes: 74 additions & 0 deletions apps/dapp/lib/__tests__/weave-best-payout.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
19 changes: 16 additions & 3 deletions apps/dapp/lib/__tests__/weave-cashout-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions apps/dapp/lib/__tests__/weave-cashout-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
nextAttempt,
orderPhase,
shouldPoll,
awaitsPaidAmount,
statusText,
unsupportedCurrencyReply,
unsupportedTokenReply,
Expand Down Expand Up @@ -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)
})
})
23 changes: 20 additions & 3 deletions apps/dapp/lib/__tests__/weave-deposit-avalanche.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })] })],
Expand Down
Loading
Loading