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 b607fc8c..c4fdabb5 100644 --- a/apps/dapp/app/api/cashout/orders/[id]/build/route.ts +++ b/apps/dapp/app/api/cashout/orders/[id]/build/route.ts @@ -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' @@ -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.` }, diff --git a/apps/dapp/app/api/cashout/orders/[id]/route.ts b/apps/dapp/app/api/cashout/orders/[id]/route.ts index de62b9f0..61f296df 100644 --- a/apps/dapp/app/api/cashout/orders/[id]/route.ts +++ b/apps/dapp/app/api/cashout/orders/[id]/route.ts @@ -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' @@ -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) }) } diff --git a/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts b/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts index a60c8396..9d9d3c3a 100644 --- a/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts +++ b/apps/dapp/app/api/cashout/orders/[id]/submit/route.ts @@ -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' @@ -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 diff --git a/apps/dapp/app/api/cashout/orders/route.ts b/apps/dapp/app/api/cashout/orders/route.ts index 44c28425..2e76d287 100644 --- a/apps/dapp/app/api/cashout/orders/route.ts +++ b/apps/dapp/app/api/cashout/orders/route.ts @@ -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' @@ -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, @@ -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' @@ -77,6 +80,9 @@ export async function GET(request: Request): Promise { * 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 { const guard = guardCashout(request) @@ -187,9 +193,83 @@ export async function POST(request: Request): Promise { accountName = verified.value.accountName } + const record = async ( + row: Omit + ): Promise => { + 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.`, @@ -246,12 +326,13 @@ export async function POST(request: Request): Promise { ) } - 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, @@ -267,28 +348,5 @@ export async function POST(request: Request): Promise { 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(), - }), }) } diff --git a/apps/dapp/app/api/cashout/quote/route.ts b/apps/dapp/app/api/cashout/quote/route.ts index 73cb5567..913afe47 100644 --- a/apps/dapp/app/api/cashout/quote/route.ts +++ b/apps/dapp/app/api/cashout/quote/route.ts @@ -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' @@ -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 { const query = new URL(request.url).searchParams const chain = query.get('chain') ?? '' @@ -37,6 +44,28 @@ export async function GET(request: Request): Promise { } 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 }) diff --git a/apps/dapp/components/intents/cashout-card.tsx b/apps/dapp/components/intents/cashout-card.tsx index a11fddce..4006d8e9 100644 --- a/apps/dapp/components/intents/cashout-card.tsx +++ b/apps/dapp/components/intents/cashout-card.tsx @@ -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)}`} @@ -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 (
@@ -546,7 +551,8 @@ export function CashoutCard({ cashout }: { cashout: Cashout }): JSX.Element | nu {isRealHolderName(order.accountName) ? order.accountName : 'This account'} receives - ≈ {formatPayout(destination, order.currency)} + {about} + {formatPayout(destination, order.currency)}
@@ -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', diff --git a/apps/dapp/lib/__tests__/linq-client.test.ts b/apps/dapp/lib/__tests__/linq-client.test.ts index c24c5eb2..d2c23093 100644 --- a/apps/dapp/lib/__tests__/linq-client.test.ts +++ b/apps/dapp/lib/__tests__/linq-client.test.ts @@ -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( diff --git a/apps/dapp/lib/__tests__/linq-sui-cashout.test.ts b/apps/dapp/lib/__tests__/linq-sui-cashout.test.ts new file mode 100644 index 00000000..74857e85 --- /dev/null +++ b/apps/dapp/lib/__tests__/linq-sui-cashout.test.ts @@ -0,0 +1,433 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + weave: {} as Record, + linq: {} as Record, + repo: {} as Record, + deposit: {} as Record, + report: [] as unknown[][], +})) + +vi.mock('../weave/client', async (original) => ({ + ...(await original()), + createWeaveClient: () => state.weave, +})) +vi.mock('../linq/client', async (original) => ({ + ...(await original()), + createLinqClient: () => state.linq, +})) +vi.mock('../weave/orders', async (original) => ({ + ...(await original()), + getWeaveOrderRepo: async () => state.repo, +})) +vi.mock('../weave/deposit', () => ({ depositChain: () => state.deposit })) +vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined })) +vi.mock('../server/report', () => ({ + reportError: (...args: unknown[]) => state.report.push(args), +})) + +import { POST } from '../../app/api/cashout/orders/route' +import { GET as quote } from '../../app/api/cashout/quote/route' +import { linqBank, placeWithLinq, sameHolder } from '../weave/linq-cashout' +import { syncOrder, type WeaveOrderRow } from '../weave/orders' +import { nextLinqStatus } from '../weave/status' +import { WALLET_HEADER } from '../wallet-header' + +const KEY = '3f7c1b2a-84e9-4c11-b3d2-0a9f7e123456' +const WALLET = `0x${'2c'.repeat(32)}` +const LINQ_DEPOSIT = `0x${'ab'.repeat(32)}` +const DIGITS = '8100003682' +const HOLDER = 'JANE ADEYEMI' + +const called = (fn: unknown): ReturnType => fn as ReturnType + +const linqOrder = { + id: 'lq_1', + depositAddress: LINQ_DEPOSIT, + coin: 'usdc', + amountCoin: 1, + amountNGN: 1368.39, + rate: 1368.39, + status: 'initiated', +} + +function linq(over: Record = {}): Record { + return { + configured: true, + getRate: vi.fn(async () => ({ ok: true, value: { ngnPerUsd: 1368.39 } })), + verifyBank: vi.fn(async () => ({ + ok: true, + value: { accountName: 'Jane Adeyemi', bankName: 'OPay' }, + })), + createOrder: vi.fn(async () => ({ ok: true, value: linqOrder })), + getStatus: vi.fn(), + ...over, + } +} + +const weaveOrder = { + id: 'wv_1', + status: 'awaiting_deposit', + sourceAssetKey: 'crypto:SUI:USDC', + destAssetKey: 'fiat:NGN', + deposit: { + address: `0x${'cd'.repeat(32)}`, + memo: undefined, + amount: '1', + token: 'USDC', + chain: 'SUI', + expiresAt: '2026-11-06T13:00:00.000Z', + }, +} + +function weave(over: Record = {}): Record { + return { + configured: true, + institutions: vi.fn(async () => ({ + ok: true, + value: [{ name: 'OPay', code: 'OPAYNGPC', type: 'bank' }], + })), + verifyAccount: vi.fn(async () => ({ ok: true, value: { accountName: HOLDER } })), + quote: vi.fn(async () => ({ + ok: true, + value: { destAmount: 1329.29, netUsd: 0.97, seconds: 90 }, + })), + createOrder: vi.fn(async () => ({ ok: true, value: weaveOrder })), + getOrder: vi.fn(), + ...over, + } +} + +function post(over: Record = {}): Request { + return new Request('http://localhost/api/cashout/orders', { + method: 'POST', + headers: { 'Content-Type': 'application/json', [WALLET_HEADER]: WALLET }, + body: JSON.stringify({ + chain: 'sui', + account: WALLET, + amount: '1', + currency: 'NGN', + institution: 'OPAYNGPC', + accountIdentifier: DIGITS, + idempotencyKey: KEY, + ...over, + }), + }) +} + +beforeEach(() => { + state.report = [] + state.weave = weave() + state.linq = linq() + state.repo = { + getByIdempotencyKey: vi.fn(async () => undefined), + hasUnconfirmedSend: vi.fn(async () => false), + findHolder: vi.fn(async () => undefined), + insert: vi.fn(async () => undefined), + } + state.deposit = { + usdcDecimals: 6, + supportsMemo: false, + isAccount: (a: string) => /^0x[0-9a-f]{64}$/.test(a), + isDepositAddress: (a: string) => /^0x[0-9a-f]{64}$/.test(a), + } + vi.stubEnv('WEAVE_CASHOUT_CURRENCIES', 'NGN,KES') +}) + +describe('linqBank and sameHolder', () => { + it("finds Linq's bank by the name Weave lists", () => { + expect(linqBank('OPay')).toEqual({ code: '100004', name: 'OPay' }) + expect(linqBank('opay')?.code).toBe('100004') + expect(linqBank('Not A Bank')).toBeUndefined() + }) + + it('takes two lookups of a name as one holder, ignoring case and spacing only', () => { + expect(sameHolder('Jane Adeyemi', HOLDER)).toBe(true) + expect(sameHolder('JANE ADEYEMI OKAFOR', HOLDER)).toBe(false) + expect(sameHolder('', '')).toBe(false) + }) +}) + +describe('nextLinqStatus', () => { + it('reads a deposit as processing only once one has been sent', () => { + expect(nextLinqStatus('waiting', 'pending', false)).toBe('waiting') + expect(nextLinqStatus('waiting', 'pending', true)).toBe('processing') + expect(nextLinqStatus('waiting', 'initiated', false)).toBe('waiting') + }) + + it('moves to paid, failed or expired, and never back', () => { + expect(nextLinqStatus('processing', 'disbursed', true)).toBe('paid') + expect(nextLinqStatus('waiting', 'timeout', false)).toBe('expired') + expect(nextLinqStatus('processing', 'failed: bank', true)).toBe('failed') + expect(nextLinqStatus('paid', 'initiated', true)).toBe('paid') + expect(nextLinqStatus('processing', 'initiated', true)).toBe('processing') + }) +}) + +describe('placeWithLinq', () => { + const input = (over: Record = {}) => ({ + linq: state.linq as never, + chain: 'sui' as const, + currency: 'NGN', + amount: '1', + weaveNaira: 1329.29, + institutionName: 'OPay', + accountIdentifier: DIGITS, + accountName: HOLDER, + refundAddress: WALLET, + customerRef: `wallet:${WALLET}`, + idempotencyKey: KEY, + ...over, + }) + + it('places a Sui USDC order with the bank Linq knows, refunding to the wallet', async () => { + expect(await placeWithLinq(input())).toEqual({ placed: linqOrder }) + expect(called(state.linq['createOrder'])).toHaveBeenCalledWith({ + chain: 'sui', + coin: 'usdc', + amountStableCoin: 1, + bankCode: '100004', + bankName: 'OPay', + bankAccount: DIGITS, + accountName: 'Jane Adeyemi', + refundAddress: WALLET, + customerRef: `wallet:${WALLET}`, + idempotencyKey: KEY, + }) + }) + + it('places it when Weave has no usable quote', async () => { + expect(await placeWithLinq(input({ weaveNaira: undefined }))).toEqual({ placed: linqOrder }) + }) + + it.each([ + ['another currency', { currency: 'KES' }, 'not_offered'], + ['another chain', { chain: 'solana' }, 'not_offered'], + ])('asks Linq nothing for %s', async (_name, over, reason) => { + expect(await placeWithLinq(input(over))).toEqual({ skipped: reason }) + expect(called(state.linq['getRate'])).not.toHaveBeenCalled() + }) + + it('asks Linq nothing when it is not configured', async () => { + state.linq = linq({ configured: false }) + expect(await placeWithLinq(input())).toEqual({ skipped: 'not_offered' }) + }) + + it('leaves it with Weave when Weave pays more, or Linq has no rate', async () => { + expect(await placeWithLinq(input({ weaveNaira: 1400 }))).toEqual({ + skipped: 'weave_pays_more', + }) + state.linq = linq({ getRate: vi.fn(async () => ({ ok: false, code: 'unavailable' })) }) + expect(await placeWithLinq(input())).toEqual({ skipped: 'weave_pays_more' }) + expect(called(state.linq['createOrder'])).not.toHaveBeenCalled() + }) + + it('makes no order for a bank Linq does not know, or a holder it names differently', async () => { + expect(await placeWithLinq(input({ institutionName: 'Somewhere Else' }))).toEqual({ + skipped: 'no_bank', + }) + expect(await placeWithLinq(input({ accountName: 'JOHN DOE' }))).toEqual({ + skipped: 'other_holder', + }) + state.linq = linq({ verifyBank: vi.fn(async () => ({ ok: false, code: 'rejected' })) }) + expect(await placeWithLinq(input())).toEqual({ skipped: 'not_verified' }) + expect(called(state.linq['createOrder'])).not.toHaveBeenCalled() + }) + + it.each([ + ['rejected', 'order_refused'], + ['unknown', 'order_unknown'], + ['unexpected_order', 'order_unexpected'], + ])('hands back to Weave when the order is %s', async (code, reason) => { + state.linq = linq({ createOrder: vi.fn(async () => ({ ok: false, code, message: 'x' })) }) + expect(await placeWithLinq(input())).toEqual({ skipped: reason }) + }) + + it('hands back to Weave when the naira Linq locked is below what Weave offers', async () => { + state.linq = linq({ + createOrder: vi.fn(async () => ({ ok: true, value: { ...linqOrder, amountNGN: 1300 } })), + }) + expect(await placeWithLinq(input())).toEqual({ skipped: 'locked_lower' }) + state.linq = linq({ + createOrder: vi.fn(async () => ({ ok: true, value: { ...linqOrder, amountNGN: 0 } })), + }) + expect(await placeWithLinq(input({ weaveNaira: undefined }))).toEqual({ + skipped: 'locked_lower', + }) + }) +}) + +describe('POST order, naira from Sui', () => { + it('places it with Linq when Linq pays more, and stores the naira Linq locked', async () => { + const res = await POST(post()) + expect(res.status).toBe(200) + const { order } = await res.json() + expect(order).toMatchObject({ + depositAddress: LINQ_DEPOSIT, + amount: '1', + estimatedDest: '1368.39', + status: 'waiting', + accountMask: '••••3682', + }) + expect(called(state.repo['insert']).mock.calls[0]?.[0]).toMatchObject({ + provider: 'linq', + providerOrder: 'lq_1', + institutionCode: 'OPAYNGPC', + depositMemo: undefined, + expiresAt: undefined, + }) + expect(called(state.weave['createOrder'])).not.toHaveBeenCalled() + }) + + it('places it with Weave when Weave pays more', async () => { + state.weave = weave({ + quote: vi.fn(async () => ({ + ok: true, + value: { destAmount: 1400, netUsd: 0.99, seconds: 90 }, + })), + }) + expect((await POST(post())).status).toBe(200) + expect(called(state.linq['createOrder'])).not.toHaveBeenCalled() + expect(called(state.repo['insert']).mock.calls[0]?.[0]).toMatchObject({ + provider: 'weave', + providerOrder: 'wv_1', + }) + }) + + it('falls back to Weave, and reports it, when Linq answers with a different order', async () => { + state.linq = linq({ + createOrder: vi.fn(async () => ({ ok: false, code: 'unexpected_order', message: 'x' })), + }) + expect((await POST(post())).status).toBe(200) + expect(called(state.repo['insert']).mock.calls[0]?.[0]).toMatchObject({ provider: 'weave' }) + expect(state.report.map((r) => String(r[1]))).toContain('Error: linq_order_unexpected') + }) + + it('places it with Linq when Weave cannot quote, or would cost too much', async () => { + state.weave = weave({ + quote: vi.fn(async () => ({ ok: false, code: 'unavailable', message: 'x' })), + }) + expect((await POST(post())).status).toBe(200) + expect(called(state.repo['insert']).mock.calls[0]?.[0]).toMatchObject({ provider: 'linq' }) + + state.repo['insert'] = vi.fn(async () => undefined) + state.weave = weave({ + quote: vi.fn(async () => ({ + ok: true, + value: { destAmount: 1500, netUsd: 0.5, seconds: 90 }, + })), + }) + expect((await POST(post())).status).toBe(200) + expect(called(state.repo['insert']).mock.calls[0]?.[0]).toMatchObject({ provider: 'linq' }) + }) + + it('still refuses when neither can pay', async () => { + state.weave = weave({ + quote: vi.fn(async () => ({ ok: false, code: 'unavailable', message: 'x' })), + }) + state.linq = linq({ getRate: vi.fn(async () => ({ ok: false, code: 'unavailable' })) }) + expect((await POST(post())).status).toBe(503) + expect(called(state.repo['insert'])).not.toHaveBeenCalled() + }) + + it('keeps every other chain and currency with Weave, asking Linq nothing', async () => { + expect((await POST(post({ currency: 'KES', institution: 'OPAYNGPC' }))).status).not.toBe(500) + expect(called(state.linq['getRate'])).not.toHaveBeenCalled() + expect(called(state.linq['createOrder'])).not.toHaveBeenCalled() + }) +}) + +describe('GET quote, naira from Sui', () => { + const ask = (chain = 'sui', currency = 'NGN') => + quote( + new Request( + `http://localhost/api/cashout/quote?chain=${chain}¤cy=${currency}&amount=1`, + { + headers: { [WALLET_HEADER]: WALLET }, + } + ) + ) + + it("shows Linq's naira when Linq pays more", async () => { + expect(await (await ask()).json()).toEqual({ destAmount: 1368.39, costPercent: 0, seconds: 90 }) + }) + + it("shows Weave's quote when Weave pays more, or for another currency", async () => { + state.weave = weave({ + quote: vi.fn(async () => ({ + ok: true, + value: { destAmount: 1400, netUsd: 0.99, seconds: 90 }, + })), + }) + expect((await (await ask()).json()).destAmount).toBe(1400) + expect((await (await ask('sui', 'KES')).json()).destAmount).toBe(1400) + expect(called(state.linq['getRate'])).toHaveBeenCalledTimes(1) + }) +}) + +describe('syncOrder for an order Linq holds', () => { + const row = (over: Partial = {}): WeaveOrderRow => ({ + id: KEY, + chain: 'sui', + email: `wallet:${WALLET}`, + account: WALLET, + providerOrder: 'lq_1', + provider: 'linq', + idempotencyKey: KEY, + currency: 'NGN', + institutionCode: 'OPAYNGPC', + institutionName: 'OPay', + accountIdentifier: DIGITS, + accountName: HOLDER, + depositAddress: LINQ_DEPOSIT, + depositMemo: undefined, + amount: '1', + estimatedDest: '1368.39', + expiresAt: undefined, + status: 'waiting', + providerStatus: 'initiated', + depositHash: undefined, + sendStarted: false, + createdAt: '2026-11-06T12:00:00.000Z', + ...over, + }) + const repo = () => ({ + setStatus: vi.fn(async () => undefined), + setPaid: vi.fn(async () => undefined), + }) + const neverWeave = { getOrder: vi.fn() } + + it('asks Linq, not Weave, and keeps what it paid', async () => { + const r = repo() + const getStatus = vi.fn(async () => ({ + ok: true as const, + value: { id: 'lq_1', status: 'disbursed', amountCoin: 1, amountNGN: 1368.39 }, + })) + const synced = await syncOrder( + r, + row({ status: 'processing', depositHash: '0xhash' }), + neverWeave, + { + getStatus, + } + ) + expect(getStatus).toHaveBeenCalledWith('lq_1') + expect(neverWeave.getOrder).not.toHaveBeenCalled() + expect(synced.row).toMatchObject({ status: 'paid', paidDest: '1368.39' }) + expect(r.setPaid).toHaveBeenCalledWith('lq_1', '1368.39') + }) + + it('keeps an order with no deposit sent waiting, whatever Linq calls it', async () => { + const synced = await syncOrder(repo(), row(), neverWeave, { + getStatus: async () => ({ + ok: true, + value: { id: 'lq_1', status: 'pending', amountCoin: 1, amountNGN: 1368.39 }, + }), + }) + expect(synced.row.status).toBe('waiting') + }) + + it('says Linq could not be asked when there is no Linq client', async () => { + expect((await syncOrder(repo(), row(), neverWeave)).reached).toBe(false) + }) +}) diff --git a/apps/dapp/lib/__tests__/weave-best-payout.test.ts b/apps/dapp/lib/__tests__/weave-best-payout.test.ts index 37c5d0c6..204f56fd 100644 --- a/apps/dapp/lib/__tests__/weave-best-payout.test.ts +++ b/apps/dapp/lib/__tests__/weave-best-payout.test.ts @@ -15,11 +15,11 @@ describe('nairaOffers', () => { ]) expect(onStellar.weaveNaira).not.toHaveBeenCalled() - const onSui = sources(1368.39, 916.44) - expect(await nairaOffers('sui', '0.689418', onSui)).toEqual([ + const onSolana = sources(1368.39, 916.44) + expect(await nairaOffers('solana', '0.689418', onSolana)).toEqual([ { provider: 'weave', naira: 916.44, exact: false }, ]) - expect(onSui.linqRate).not.toHaveBeenCalled() + expect(onSolana.linqRate).not.toHaveBeenCalled() }) it('gives no offer for a provider that cannot be read, rather than a guess', async () => { @@ -36,8 +36,15 @@ describe('nairaOffers', () => { 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']) + it('asks both on Sui, where each can pay, best first', async () => { + expect(await nairaOffers('sui', '1', sources(1368.39, 1329.29))).toEqual([ + { provider: 'linq', naira: 1368.39, exact: true }, + { provider: 'weave', naira: 1329.29, exact: false }, + ]) + }) + + it('pays naira from Linq on Stellar and Sui, and from Weave nowhere on Stellar or NEAR', () => { + expect(PAYS_NAIRA.linq).toEqual(['stellar', 'sui']) expect(PAYS_NAIRA.weave).not.toContain('stellar') expect(PAYS_NAIRA.weave).not.toContain('near') }) diff --git a/apps/dapp/lib/linq/client.ts b/apps/dapp/lib/linq/client.ts index c2ca60bb..59e047d3 100644 --- a/apps/dapp/lib/linq/client.ts +++ b/apps/dapp/lib/linq/client.ts @@ -28,7 +28,18 @@ export type LinqResult = { ok: true; value: T } | LinqFailure export type LinqCoin = 'xlm' | 'usdc' +/** + * Where a Linq order takes its deposit. Linq's dashboard docs list `sui` (USDSUI, USDC) and + * `stellar` (USDC, XLM) among others; only these two are ordered from here. + */ +export type LinqChain = 'stellar' | 'sui' + +/** A Sui account or object address: 0x and 64 hex digits. */ +const SUI_ADDRESS = /^0x[0-9a-fA-F]{64}$/ + export interface LinqOrderInput { + /** Stellar unless said. A Sui order is USDC only, sent from the user's own wallet. */ + chain?: LinqChain /** * The asset the user deposits. XLM is ordered by the naira the recipient * should get; USDC by the amount actually in hand, because it follows a swap @@ -269,11 +280,15 @@ export function createLinqClient(options: LinqClientOptions = {}): { return { ok: false, code: 'rejected', message: 'The payout amount is missing.' } } + const chain: LinqChain = input.chain ?? 'stellar' + if (chain === 'sui' && input.coin !== 'usdc') { + return { ok: false, code: 'rejected', message: 'A Sui payout is ordered in USDC only.' } + } const res = await call('/b2b/offramp', { method: 'POST', body: { ...amount, - chain: 'stellar', + chain, coin: input.coin, currency: 'NGN', manualDeposit: true, @@ -294,10 +309,15 @@ export function createLinqClient(options: LinqClientOptions = {}): { const body = res.value const address = str(body['walletAddress']) const amountCoin = num(body['amountStableCoin']) + // On Sui the order must also be for exactly the USDC asked: that is what the wallet signs. + const validAddress = + chain === 'sui' ? SUI_ADDRESS.test(address) : StrKey.isValidEd25519PublicKey(address) + const exactAmount = chain !== 'sui' || amountCoin === input.amountStableCoin const matches = - str(body['chain']).toLowerCase() === 'stellar' && + str(body['chain']).toLowerCase() === chain && str(body['coin']).toLowerCase() === input.coin && - StrKey.isValidEd25519PublicKey(address) && + validAddress && + exactAmount && amountCoin > 0 && str(body['id']) !== '' if (!matches) { @@ -358,3 +378,5 @@ export function verifyLinqSignature( const given = Buffer.from(header) return given.length === expected.length && timingSafeEqual(given, expected) } + +export type LinqClient = ReturnType diff --git a/apps/dapp/lib/weave/best-payout.ts b/apps/dapp/lib/weave/best-payout.ts index 226e393f..b0f374e6 100644 --- a/apps/dapp/lib/weave/best-payout.ts +++ b/apps/dapp/lib/weave/best-payout.ts @@ -17,14 +17,14 @@ export interface PayoutOffer { /** * 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. + * Linq's docs list Sui (USDC, USDSUI) and Solana (USDC) besides Stellar. Linq's team says Sui runs + * as Stellar does, so Sui is in; its orders are held to the shape Stellar's return. Solana waits. * * 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'], + linq: ['stellar', 'sui'], weave: ['solana', 'avalanche', 'sui'], } diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts index de88c0c2..d088ff49 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 + /** The provider locked `estimatedDest` and pays exactly that, so it is no estimate. */ + exactDest?: boolean | undefined /** What a paid order actually paid out, which can be less than the estimate. */ paidDest?: string | undefined institutionName: string diff --git a/apps/dapp/lib/weave/linq-cashout.ts b/apps/dapp/lib/weave/linq-cashout.ts new file mode 100644 index 00000000..df7fa32b --- /dev/null +++ b/apps/dapp/lib/weave/linq-cashout.ts @@ -0,0 +1,124 @@ +import type { LinqClient, LinqOrder } from '../linq/client' +import { NIGERIAN_BANKS } from '../naira/banks' + +import { bestNairaOffer, nairaOffers, type PayoutOffer } from './best-payout' +import type { CashoutChain } from './types' + +const plain = (text: string): string => text.toUpperCase().replace(/[^A-Z0-9]/g, '') + +/** + * Linq's bank for the one Weave's list named. Weave lists Paycrest codes and Linq takes NIP codes, + * so the two meet on the bank's name, which both take from the same register. A bank whose name + * isn't matched exactly has no Linq code, and the cash-out stays with Weave. + */ +export function linqBank(institutionName: string): { code: string; name: string } | undefined { + const wanted = plain(institutionName) + const bank = NIGERIAN_BANKS.find((b) => plain(b.name) === wanted) + return bank === undefined ? undefined : { code: bank.code, name: bank.name } +} + +/** Whether two lookups of an account name the same holder, ignoring case, spacing and punctuation. */ +export function sameHolder(a: string, b: string): boolean { + const left = plain(a) + return left !== '' && left === plain(b) +} + +export interface LinqPlacementInput { + linq: Pick + chain: CashoutChain + currency: string + /** USDC, decimal text, already read to the chain's decimals. */ + amount: string + /** Weave's naira for the same cash-out after its fees, or nothing when it has no usable quote. */ + weaveNaira: number | undefined + institutionName: string + accountIdentifier: string + /** The holder Weave's lookup named. Linq's lookup must name the same one. */ + accountName: string + /** The person's own wallet, where Linq returns the deposit if the payout fails. */ + refundAddress: string + customerRef: string + idempotencyKey: string +} + +export type LinqPlacement = + | { placed: LinqOrder } + | { + /** Why the cash-out stays with Weave. Never shown; reported when it is a fault. */ + skipped: + | 'not_offered' + | 'weave_pays_more' + | 'no_bank' + | 'not_verified' + | 'other_holder' + | 'order_refused' + | 'order_unknown' + | 'order_unexpected' + | 'locked_lower' + } + +/** + * Places a Sui naira cash-out with Linq when Linq pays more than Weave, and says why not otherwise. + * + * Every step that can't be confirmed hands the cash-out back to Weave. An order Linq made but this + * app doesn't keep is never shown to anyone, so nothing can be paid into it. + */ +export async function placeWithLinq(input: LinqPlacementInput): Promise { + const { linq } = input + if (input.currency !== 'NGN' || input.chain !== 'sui' || !linq.configured) { + return { skipped: 'not_offered' } + } + + const offers = await nairaOffers(input.chain, input.amount, { + linqRate: async () => { + const rate = await linq.getRate() + return rate.ok ? rate.value.ngnPerUsd : undefined + }, + weaveNaira: async () => input.weaveNaira, + }) + if (bestNairaOffer(offers)?.provider !== 'linq') return { skipped: 'weave_pays_more' } + + const bank = linqBank(input.institutionName) + if (bank === undefined) return { skipped: 'no_bank' } + const verified = await linq.verifyBank({ + bankCode: bank.code, + accountNumber: input.accountIdentifier, + }) + if (!verified.ok) return { skipped: 'not_verified' } + if (!sameHolder(verified.value.accountName, input.accountName)) { + return { skipped: 'other_holder' } + } + + const made = await linq.createOrder({ + chain: 'sui', + coin: 'usdc', + amountStableCoin: Number(input.amount), + bankCode: bank.code, + bankName: bank.name, + bankAccount: input.accountIdentifier, + accountName: verified.value.accountName, + refundAddress: input.refundAddress, + customerRef: input.customerRef, + idempotencyKey: input.idempotencyKey, + }) + if (!made.ok) { + return { + skipped: + made.code === 'unknown' + ? 'order_unknown' + : made.code === 'unexpected_order' + ? 'order_unexpected' + : 'order_refused', + } + } + + // The order locks its own naira, which is what the person is shown and paid. It must still beat Weave. + const locked: PayoutOffer[] = [{ provider: 'linq', naira: made.value.amountNGN, exact: true }] + if (input.weaveNaira !== undefined) { + locked.push({ provider: 'weave', naira: input.weaveNaira, exact: false }) + } + if (made.value.amountNGN <= 0 || bestNairaOffer(locked)?.provider !== 'linq') { + return { skipped: 'locked_lower' } + } + return { placed: made.value } +} diff --git a/apps/dapp/lib/weave/orders.ts b/apps/dapp/lib/weave/orders.ts index 5ee6c8c2..6ec696b2 100644 --- a/apps/dapp/lib/weave/orders.ts +++ b/apps/dapp/lib/weave/orders.ts @@ -1,6 +1,8 @@ import { getPool, withTimeout, type QueryFn } from '../server/db' +import type { LinqClient } from '../linq/client' +import type { PayoutProvider as CashoutProvider } from './best-payout' import type { WeaveClient, WeaveOrder } from './client' -import { isFinal, mapProviderStatus, nextStatus, type WeaveStatus } from './status' +import { isFinal, mapProviderStatus, nextLinqStatus, nextStatus, type WeaveStatus } from './status' import { weaveAssetKey, weaveChainId, @@ -46,6 +48,8 @@ export const WEAVE_DDL: readonly string[] = [ `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`, + // Who holds the order: Weave, or Linq where it pays more naira. Rows from before are Weave's. + `ALTER TABLE weave_orders ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'weave'`, ] /** Said wherever a deposit may already be on its way, so nobody pays twice. */ @@ -58,6 +62,8 @@ export interface WeaveOrderRow { email: string account: string providerOrder: string + /** Who holds the order. Older rows have none, and are Weave's. */ + provider?: CashoutProvider | undefined idempotencyKey: string currency: string institutionCode: string @@ -92,6 +98,8 @@ export interface WeaveOrderView { currency: string amount: string estimatedDest: string + /** Linq locks the naira when the order is made, so its figure is what is paid. */ + exactDest?: boolean | undefined /** What it actually paid out, once Weave says so. */ paidDest?: string | undefined institutionName: string @@ -113,6 +121,7 @@ export function toPublic(row: WeaveOrderRow): WeaveOrderView { currency: row.currency, amount: row.amount, estimatedDest: row.estimatedDest, + ...(row.provider === 'linq' ? { exactDest: true } : {}), ...(row.paidDest !== undefined ? { paidDest: row.paidDest } : {}), institutionName: row.institutionName, accountName: row.accountName, @@ -145,6 +154,7 @@ function fromDb(r: Record): WeaveOrderRow { email: String(r['email']), account: String(r['account']), providerOrder: String(r['provider_order']), + provider: r['provider'] === 'linq' ? 'linq' : 'weave', idempotencyKey: String(r['idempotency_key']), currency: String(r['currency']), institutionCode: String(r['institution_code']), @@ -225,8 +235,8 @@ export function createWeaveOrderRepo(query: QueryFn): WeaveOrderRepo { `INSERT INTO weave_orders (id, chain, email, account, provider_order, idempotency_key, currency, institution_code, institution_name, account_identifier, account_name, deposit_address, deposit_memo, - amount, estimated_dest, expires_at, status, provider_status) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + amount, estimated_dest, expires_at, status, provider_status, provider) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, [ o.id, o.chain, @@ -246,6 +256,7 @@ export function createWeaveOrderRepo(query: QueryFn): WeaveOrderRepo { o.expiresAt ?? null, o.status, o.providerStatus, + o.provider ?? 'weave', ] ) }, @@ -473,17 +484,45 @@ export interface SyncedOrder { * 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. */ +/** An order's live status and paid amount, from whichever provider holds it. */ +async function readLive( + row: WeaveOrderRow, + weave: Pick, + linq: Pick | undefined +): Promise< + { ok: true; value: { status: string; destAmount?: number | undefined } } | { ok: false } +> { + if (row.provider !== 'linq') return weave.getOrder(row.providerOrder) + if (linq === undefined) return { ok: false } + const res = await linq.getStatus(row.providerOrder) + if (!res.ok) return { ok: false } + // Linq states the naira when the order is made and pays exactly that. + const naira = res.value.amountNGN + return { + ok: true, + value: { status: res.value.status, ...(naira > 0 ? { destAmount: naira } : {}) }, + } +} + export async function syncOrder( repo: Pick, row: WeaveOrderRow, - weave: Pick + weave: Pick, + linq?: Pick ): Promise { // 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) + const live = await readLive(row, weave, linq) if (!live.ok) return { row, reached: false } - const status = nextStatus(row.status, live.value.status) + const status = + row.provider === 'linq' + ? nextLinqStatus( + row.status, + live.value.status, + row.depositHash !== undefined || row.sendStarted + ) + : nextStatus(row.status, live.value.status) if (status !== row.status || live.value.status !== row.providerStatus) { await repo.setStatus(row.providerOrder, status, live.value.status) } diff --git a/apps/dapp/lib/weave/status.ts b/apps/dapp/lib/weave/status.ts index b824d683..b2779b58 100644 --- a/apps/dapp/lib/weave/status.ts +++ b/apps/dapp/lib/weave/status.ts @@ -1,3 +1,5 @@ +import { normaliseStatus } from '../naira/status' + export type WeaveStatus = 'waiting' | 'processing' | 'paid' | 'failed' | 'expired' | 'refunded' const FROM_PROVIDER: Readonly> = { @@ -34,3 +36,22 @@ export function nextStatus(current: WeaveStatus, provider: string): WeaveStatus if (mapped === 'waiting' && current !== 'waiting') return current return mapped } + +/** + * The same, for an order Linq holds, read with the Stellar naira flow's own reading of Linq's + * free-text statuses. Linq reports no refund of its own, so a failure is where it ends. + * + * That reading takes any text it doesn't know as "processing". Before this app has sent the + * deposit, nothing can be processing, and reading it so would refuse the deposit the order waits for. + */ +export function nextLinqStatus( + current: WeaveStatus, + provider: string, + deposited: boolean +): WeaveStatus { + if (isFinal(current)) return current + const mapped = normaliseStatus(provider) + if (mapped === 'waiting' && current !== 'waiting') return current + if (mapped === 'processing' && !deposited) return current + return mapped +} diff --git a/packages/db/migrations/016_weave_orders_provider.sql b/packages/db/migrations/016_weave_orders_provider.sql new file mode 100644 index 00000000..7782d491 --- /dev/null +++ b/packages/db/migrations/016_weave_orders_provider.sql @@ -0,0 +1,5 @@ +-- Who holds a cash-out order: Weave, or Linq where it pays more naira from Sui. Rows from before +-- are Weave's. 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 provider TEXT NOT NULL DEFAULT 'weave';