diff --git a/src/components/RoutesTester.tsx b/src/components/RoutesTester.tsx index 966704e65..ea075e3b4 100644 --- a/src/components/RoutesTester.tsx +++ b/src/components/RoutesTester.tsx @@ -59,6 +59,7 @@ import { subsidizedDelivery, type TestMethod, type TestMode, + type TestPriority, type TestRoute, } from '../lib/routes-test' import { @@ -360,6 +361,7 @@ function RouteLifecycle({ onNeedKey: () => void }) { const [mode, setMode] = useState(route.capabilities.transfer?.modes[0] ?? 'exactSource') + const [priority, setPriority] = useState('price') const [amount, setAmount] = useState('') const [sender, setSender] = useState('') const [recipient, setRecipient] = useState('') @@ -461,7 +463,9 @@ function RouteLifecycle({ }) const tempoAccount = tempoWallet && connection.connector?.id === tempoWallet.id ? connection.address : undefined - const subsidyAvailable = canSubsidize(route, method, mode) + // The API refuses 1:1 delivery for speed-priority transfers, so choosing speed drops it. + const speed = method === 'transfer' && priority === 'speed' + const subsidyAvailable = !speed && canSubsidize(route, method, mode) const input = { method, mode, @@ -471,6 +475,7 @@ function RouteLifecycle({ refundAddress, // Without a key, the docs demo key quotes 1:1 delivery as a preview; it cannot create routes. subsidize: subsidyAvailable && !subsidyRefused, + priority: speed ? priority : undefined, } let quoteParams: ReturnType | undefined let quoteValidation = '' @@ -1394,6 +1399,19 @@ function RouteLifecycle({ placeholder: walletLabel(route.sourceChain, 'source'), })} {recipientField(frozen, true)} + {/* Best price is the API default; speed is an opt-in shown last. */} + )} {!isTransfer && ( diff --git a/src/lib/routes-quote-api.test.ts b/src/lib/routes-quote-api.test.ts index c7999e153..b1208b579 100644 --- a/src/lib/routes-quote-api.test.ts +++ b/src/lib/routes-quote-api.test.ts @@ -68,6 +68,7 @@ describe('Routes quote proxy', () => { [{ kind: 'transfers', mode: 'exactSource', subsidize: false }, 'false'], [{ kind: 'deposit-addresses', subsidize: false }, 'false'], [{ kind: 'transfers', mode: 'exactDestination' }, null], + [{ kind: 'transfers', mode: 'exactSource', priority: 'speed' }, null], ])('previews 1:1 delivery by default and keeps an explicit choice: %j', async (body, subsidize) => { expect((await POST(request(body))).status).toBe(200) expect(new URL(String(fetchMock.mock.calls[0][0])).searchParams.get('subsidize')).toBe( diff --git a/src/lib/routes-test.test.ts b/src/lib/routes-test.test.ts index fbdc5177d..55db15af5 100644 --- a/src/lib/routes-test.test.ts +++ b/src/lib/routes-test.test.ts @@ -82,6 +82,21 @@ describe('route test requests', () => { ).toThrow('1:1') expect(() => routeTestRequest(route, { ...input, subsidize: true })).toThrow('1:1') }) + it('sends a speed priority on transfers only, without 1:1 delivery', () => { + expect(routeTestRequest(route, input)).not.toHaveProperty('priority') + expect(routeTestRequest(route, { ...input, priority: 'price' })).not.toHaveProperty('priority') + expect(routeQuoteRequest(route, { ...input, priority: 'speed' })).toMatchObject({ + priority: 'speed', + subsidize: false, + }) + const subsidized = { ...route, subsidies: { transfer: true, depositAddress: false } } + expect(() => + routeTestRequest(subsidized, { ...input, priority: 'speed', subsidize: true }), + ).toThrow('Speed') + expect(() => + routeTestRequest(route, { ...input, method: 'depositAddress', priority: 'speed' }), + ).toThrow('transfers') + }) it('requires Stellar account addresses on Stellar sources', () => { const stellar = { id: 'stellar:pubnet', name: 'Stellar', addressFormat: 'strkey' } const fromStellar = { ...route, sourceChain: stellar } diff --git a/src/lib/routes-test.ts b/src/lib/routes-test.ts index e3d66f1b6..8a295e8e2 100644 --- a/src/lib/routes-test.ts +++ b/src/lib/routes-test.ts @@ -31,6 +31,8 @@ export const directorySchema = z.object({ export type TestRoute = z.infer export type TestMethod = 'transfer' | 'depositAddress' export type TestMode = 'exactSource' | 'exactDestination' +/** What a transfer quote optimizes for: the best guaranteed amount, or a faster route at near-best terms. */ +export type TestPriority = 'price' | 'speed' export type RoutePick = { sourceChain: string @@ -173,6 +175,7 @@ type RouteInput = { recipient: string refundAddress: string subsidize?: boolean + priority?: TestPriority } /** Fields every quote and creation shares, after checking the route supports the request. */ @@ -181,6 +184,12 @@ function pricing(route: TestRoute, input: RouteInput) { if (input.subsidize && !canSubsidize(route, method, mode)) { throw new Error('This route does not offer 1:1 delivery for that funding method.') } + if (input.priority === 'speed' && method !== 'transfer') { + throw new Error('Only transfers can prioritize speed.') + } + if (input.priority === 'speed' && input.subsidize) { + throw new Error('Speed priority cannot be combined with 1:1 delivery.') + } if (method === 'depositAddress' && !route.capabilities.depositAddress) { throw new Error('This route does not support deposit addresses.') } @@ -223,6 +232,8 @@ export function routeTestRequest(route: TestRoute, input: RouteInput) { ...common, destinationChain: route.destinationChain.id, mode: input.mode, + // Price is the API default, so only a speed choice is sent. + ...(input.priority === 'speed' && { priority: input.priority }), sender: address(input.sender, route.sourceChain.addressFormat, 'sender'), } : { diff --git a/src/pages/_api/api/routes-quote.ts b/src/pages/_api/api/routes-quote.ts index a36cd212a..bf615f3c3 100644 --- a/src/pages/_api/api/routes-quote.ts +++ b/src/pages/_api/api/routes-quote.ts @@ -8,6 +8,7 @@ const params = new Set([ 'destinationChain', 'destinationToken', 'mode', + 'priority', 'recipient', 'refundAddress', 'sender', @@ -49,10 +50,15 @@ export async function POST(request: Request): Promise { } // The docs key's organization is approved for 1:1 delivery and cannot create routes, so its // quotes preview subsidized terms by default. An explicit `subsidize=false` (the demo's fallback - // when a route has no subsidy) is kept; exact-destination transfers cannot be subsidized. + // when a route has no subsidy) is kept; exact-destination and speed-priority transfers cannot be + // subsidized. if ( !target.searchParams.has('subsidize') && - !(kind === 'transfers' && target.searchParams.get('mode') === 'exactDestination') + !( + kind === 'transfers' && + (target.searchParams.get('mode') === 'exactDestination' || + target.searchParams.get('priority') === 'speed') + ) ) target.searchParams.set('subsidize', 'true') try {