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
3 changes: 2 additions & 1 deletion apps/dapp/app/api/cashout/orders/[id]/build/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { ChainActionRefused } from '../../../../../../lib/chains/types'
import { enforceRateLimit } from '../../../../../../lib/server/rate-limit'
import { reportError } from '../../../../../../lib/server/report'
import { createLinqClient } from '../../../../../../lib/linq/client'
import { createWeaveClient } from '../../../../../../lib/weave/client'
import { depositChain } from '../../../../../../lib/weave/deposit'
import { chainRefusal, guardCashout } from '../../../../../../lib/weave/guard'
Expand Down Expand Up @@ -43,7 +44,7 @@ export async function POST(
return NextResponse.json({ error: 'send_started', message: SEND_STARTED }, { status: 409 })
}

const refused = payRefusal(await syncOrder(repo, row, createWeaveClient()))
const refused = payRefusal(await syncOrder(repo, row, createWeaveClient(), createLinqClient()))
if (refused !== undefined) {
return NextResponse.json(
{ error: refused.code, message: `${refused.reason} Nothing was signed or sent.` },
Expand Down
3 changes: 2 additions & 1 deletion apps/dapp/app/api/cashout/orders/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'

import { createLinqClient } from '../../../../../lib/linq/client'
import { createWeaveClient } from '../../../../../lib/weave/client'
import { guardCashout } from '../../../../../lib/weave/guard'
import { getWeaveOrderRepo, syncOrder, toPublic } from '../../../../../lib/weave/orders'
Expand All @@ -24,6 +25,6 @@ export async function GET(
const row = await repo.getForEmail((await context.params).id, guard.email)
if (row === undefined) return NextResponse.json({ error: 'not_found' }, { status: 404 })

const synced = await syncOrder(repo, row, createWeaveClient())
const synced = await syncOrder(repo, row, createWeaveClient(), createLinqClient())
return NextResponse.json({ order: toPublic(synced.row) })
}
3 changes: 2 additions & 1 deletion apps/dapp/app/api/cashout/orders/[id]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ChainActionRefused, type SignedPayload } from '../../../../../../lib/ch
import { logExecution } from '../../../../../../lib/server/analytics'
import { enforceRateLimit } from '../../../../../../lib/server/rate-limit'
import { reportError } from '../../../../../../lib/server/report'
import { createLinqClient } from '../../../../../../lib/linq/client'
import { createWeaveClient } from '../../../../../../lib/weave/client'
import { depositChain } from '../../../../../../lib/weave/deposit'
import { chainRefusal, guardCashout } from '../../../../../../lib/weave/guard'
Expand Down Expand Up @@ -113,7 +114,7 @@ export async function POST(
}

const sentAlready = walletSent(signed)
const synced = await syncOrder(repo, row, createWeaveClient())
const synced = await syncOrder(repo, row, createWeaveClient(), createLinqClient())
// A wallet that sends for itself has already moved the money, and Weave may have
// seen it by now, so this only confirms and records it whatever the order's status.
const refused = !sentAlready || row.depositHash !== undefined ? payRefusal(synced) : undefined
Expand Down
108 changes: 83 additions & 25 deletions apps/dapp/app/api/cashout/orders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { randomUUID } from 'node:crypto'
import { z } from 'zod'

import { createLinqClient } from '../../../../lib/linq/client'
import { databaseConfigured } from '../../../../lib/server/db'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
import { reportError } from '../../../../lib/server/report'
Expand All @@ -10,6 +11,7 @@ import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/clien
import { isCurrencyAllowed } from '../../../../lib/weave/corridors'
import { depositChain } from '../../../../lib/weave/deposit'
import { chainRefusal, guardCashout } from '../../../../lib/weave/guard'
import { placeWithLinq } from '../../../../lib/weave/linq-cashout'
import {
cleanMemo,
getWeaveOrderRepo,
Expand All @@ -19,6 +21,7 @@ import {
readUsdcAmount,
safeForReport,
toPublic,
type WeaveOrderRow,
} from '../../../../lib/weave/orders'
import { nextStatus } from '../../../../lib/weave/status'
import { closedCashoutReply, isCashoutChain } from '../../../../lib/weave/types'
Expand Down Expand Up @@ -77,6 +80,9 @@ export async function GET(request: Request): Promise<Response> {
* is checked against what was asked before it is stored or shown, and the row is
* stored before the response, so a deposit address is never in a person's hands
* without a record of it.
*
* A naira cash-out from Sui goes to Linq instead when Linq pays more, at a rate it locks.
* Anything about Linq's order that can't be confirmed leaves the cash-out with Weave.
*/
export async function POST(request: Request): Promise<Response> {
const guard = guardCashout(request)
Expand Down Expand Up @@ -187,9 +193,83 @@ export async function POST(request: Request): Promise<Response> {
accountName = verified.value.accountName
}

const record = async (
row: Omit<WeaveOrderRow, 'depositHash' | 'sendStarted' | 'createdAt'>
): Promise<Response> => {
try {
await repo.insert(row)
} catch (e) {
reportError('cashout/orders', safeForReport(e, row), {
chain,
currency,
providerOrder: row.providerOrder,
})
return refusal(
'not_recorded',
'This cash-out could not be recorded, so nothing was signed or sent. Review it again to start a new one.',
503
)
}
return NextResponse.json({
order: toPublic({
...row,
depositHash: undefined,
sendStarted: false,
createdAt: new Date().toISOString(),
}),
})
}

const quote = await weave.quote({ chain, currency, amount })
const weaveCost = quote.ok ? quoteCostPercent(amount, quote.value.netUsd) : undefined
const viaLinq = await placeWithLinq({
linq: createLinqClient(),
chain,
currency,
amount,
weaveNaira:
quote.ok && weaveCost !== undefined && weaveCost <= MAX_COST_PERCENT
? quote.value.destAmount
: undefined,
institutionName: institution.name,
accountIdentifier: input.accountIdentifier,
accountName,
refundAddress: input.account,
customerRef: guard.email,
idempotencyKey: input.idempotencyKey,
})
if ('placed' in viaLinq) {
const order = viaLinq.placed
return record({
id: randomUUID(),
chain,
email: guard.email,
account: input.account,
providerOrder: order.id,
provider: 'linq',
idempotencyKey: input.idempotencyKey,
currency,
institutionCode: input.institution,
institutionName: institution.name,
accountIdentifier: input.accountIdentifier,
accountName,
depositAddress: order.depositAddress,
depositMemo: undefined,
// Linq's client has held the order to exactly this amount of USDC.
amount,
// The naira Linq locked for this order: what reaches the account, not an estimate.
estimatedDest: String(order.amountNGN),
expiresAt: undefined,
status: 'waiting',
providerStatus: order.status,
})
}
if (viaLinq.skipped === 'order_unknown' || viaLinq.skipped === 'order_unexpected') {
reportError('cashout/orders', new Error(`linq_${viaLinq.skipped}`), { chain, currency })
}

if (!quote.ok) return fail(quote.code, quote.message)
if (quoteCostPercent(amount, quote.value.netUsd) > MAX_COST_PERCENT) {
if (weaveCost !== undefined && weaveCost > MAX_COST_PERCENT) {
return refusal(
'too_costly',
`This cash-out would cost more than ${MAX_COST_PERCENT}% of the amount right now, so it was not started. Nothing was signed or sent.`,
Expand Down Expand Up @@ -246,12 +326,13 @@ export async function POST(request: Request): Promise<Response> {
)
}

const row = {
return record({
id: randomUUID(),
chain,
email: guard.email,
account: input.account,
providerOrder: order.id,
provider: 'weave',
idempotencyKey: input.idempotencyKey,
currency,
institutionCode: input.institution,
Expand All @@ -267,28 +348,5 @@ export async function POST(request: Request): Promise<Response> {
expiresAt: order.deposit.expiresAt,
status: nextStatus('waiting', order.status),
providerStatus: order.status,
}
try {
await repo.insert(row)
} catch (e) {
reportError('cashout/orders', safeForReport(e, row), {
chain,
currency,
providerOrder: order.id,
})
return refusal(
'not_recorded',
'This cash-out could not be recorded, so nothing was signed or sent. Review it again to start a new one.',
503
)
}

return NextResponse.json({
order: toPublic({
...row,
depositHash: undefined,
sendStarted: false,
createdAt: new Date().toISOString(),
}),
})
}
33 changes: 31 additions & 2 deletions apps/dapp/app/api/cashout/quote/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { NextResponse } from 'next/server'

import { createLinqClient } from '../../../../lib/linq/client'
import { enforceRateLimit } from '../../../../lib/server/rate-limit'
import { bestNairaOffer, nairaOffers } from '../../../../lib/weave/best-payout'
import { createWeaveClient, quoteCostPercent } from '../../../../lib/weave/client'
import { isCurrencyAllowed } from '../../../../lib/weave/corridors'
import { depositChain } from '../../../../lib/weave/deposit'
import { guardCashout } from '../../../../lib/weave/guard'
import { readUsdcAmount } from '../../../../lib/weave/orders'
import { MAX_COST_PERCENT, readUsdcAmount } from '../../../../lib/weave/orders'
import { isCashoutChain } from '../../../../lib/weave/types'

export const runtime = 'nodejs'
Expand All @@ -15,7 +17,12 @@ function refuse(error: string, message: string): Response {
return NextResponse.json({ error, message }, { status: 400 })
}

/** What a cash-out would pay, what it would cost, and how long it should take. */
/**
* What a cash-out would pay, what it would cost, and how long it should take.
*
* A naira cash-out from Sui is priced by whichever of Weave and Linq pays more, as the order route
* places it. Linq's figure is the naira it pays; it states no fee of its own.
*/
export async function GET(request: Request): Promise<Response> {
const query = new URL(request.url).searchParams
const chain = query.get('chain') ?? ''
Expand All @@ -37,6 +44,28 @@ export async function GET(request: Request): Promise<Response> {
}

const result = await createWeaveClient().quote({ chain, currency, amount })
const linq = createLinqClient()
if (chain === 'sui' && currency === 'NGN' && linq.configured) {
const weaveCost = result.ok ? quoteCostPercent(amount, result.value.netUsd) : undefined
const offers = await nairaOffers(chain, amount, {
linqRate: async () => {
const rate = await linq.getRate()
return rate.ok ? rate.value.ngnPerUsd : undefined
},
weaveNaira: async () =>
result.ok && weaveCost !== undefined && weaveCost <= MAX_COST_PERCENT
? result.value.destAmount
: undefined,
})
const best = bestNairaOffer(offers)
if (best?.provider === 'linq') {
return NextResponse.json({
destAmount: Math.floor(best.naira * 100) / 100,
costPercent: 0,
seconds: result.ok ? result.value.seconds : 0,
})
}
}
if (!result.ok) {
const status = result.code === 'rejected' ? 422 : 503
return NextResponse.json({ error: result.code, message: result.message }, { status })
Expand Down
14 changes: 10 additions & 4 deletions apps/dapp/components/intents/cashout-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ function PayoutProgress({
? formatPayout(paid, order.currency)
: done
? 'Confirming the amount paid…'
: `≈ ${formatPayout(order.estimatedDest, order.currency)}`}
: `${order.exactDest === true ? '' : '≈ '}${formatPayout(order.estimatedDest, order.currency)}`}
</span>
</div>

Expand Down Expand Up @@ -525,11 +525,16 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
) {
const busy = phase !== 'review'
const destination = Number(order.estimatedDest)
const about = order.exactDest === true ? '' : '≈ '
return (
<div className={cn(SHELL, 'gap-5')}>
<CardHeader
title="Review payout"
subtitle="An estimate after the provider’s fees. What lands can be a few percent lower, and the receipt shows the exact amount paid."
subtitle={
order.exactDest === true
? 'The exact amount this account receives, after the provider’s fees. The provider has locked it.'
: '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 All @@ -546,7 +551,8 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
{isRealHolderName(order.accountName) ? order.accountName : 'This account'} receives
</span>
<span className="text-3xl font-semibold tracking-tight">
≈ {formatPayout(destination, order.currency)}
{about}
{formatPayout(destination, order.currency)}
</span>
</div>

Expand All @@ -555,7 +561,7 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu
[
[
'Rate',
`≈ ${formatPayout(destination / Number(order.amount), order.currency)} per USDC`,
`${about}${formatPayout(destination / Number(order.amount), order.currency)} per USDC`,
],
[
'Account holder',
Expand Down
57 changes: 57 additions & 0 deletions apps/dapp/lib/__tests__/linq-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,63 @@ describe('linq client', () => {
expect(none.calls).toHaveLength(0)
})

it('orders USDC on Sui, held to the chain, a Sui address and exactly the USDC asked', async () => {
const SUI_DEPOSIT = `0x${'ab'.repeat(32)}`
const sui = { ...usdcBase, chain: 'sui' as const, amountStableCoin: 1.25 }
const good = server(() => ({
status: 201,
body: goodOrder({
chain: 'sui',
coin: 'usdc',
walletAddress: SUI_DEPOSIT,
amountStableCoin: 1.25,
amountNGN: 1710.48,
}),
}))
expect(
await createLinqClient({ ...config, fetchImpl: good.fetchImpl }).createOrder(sui)
).toMatchObject({
ok: true,
value: { depositAddress: SUI_DEPOSIT, amountCoin: 1.25, amountNGN: 1710.48 },
})
expect(JSON.parse(good.calls[0]?.init.body ?? '{}')).toMatchObject({
chain: 'sui',
coin: 'usdc',
amountStableCoin: 1.25,
manualDeposit: true,
})

for (const wrong of [
{ chain: 'stellar', walletAddress: SUI_DEPOSIT },
{ walletAddress: DEPOSIT },
{ walletAddress: `0x${'ab'.repeat(31)}` },
{ amountStableCoin: 1.26 },
]) {
const bad = server(() => ({
status: 201,
body: goodOrder({
chain: 'sui',
coin: 'usdc',
walletAddress: SUI_DEPOSIT,
amountStableCoin: 1.25,
...wrong,
}),
}))
expect(
await createLinqClient({ ...config, fetchImpl: bad.fetchImpl }).createOrder(sui)
).toMatchObject({ ok: false, code: 'unexpected_order' })
}

const xlm = server(() => ({ status: 201, body: goodOrder() }))
expect(
await createLinqClient({ ...config, fetchImpl: xlm.fetchImpl }).createOrder({
...orderInput,
chain: 'sui',
})
).toMatchObject({ ok: false, code: 'rejected' })
expect(xlm.calls).toHaveLength(0)
})

it('checks a USDC trustline, and treats an unreachable ledger as unknown', async () => {
const yes = server(() => ({ status: 200, body: { valid: true, trustsUSDC: true } }))
expect(
Expand Down
Loading
Loading