Skip to content
Draft
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
17 changes: 14 additions & 3 deletions packages/solver-arkade/src/arkade/offerTerms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@
*/
import { asset, type RelativeTimelock } from '@arkade-os/sdk'
import { isEncodableRelativeDelay, relativeDelayFrom } from '@arkade-os/solver-core/core/timelocks.js'
import { RFQ_ASSET_SWAP_V3 } from '@arkade-os/solver-core/core/rfqCovenant.js'
import { offerVtxoScript, type Offer } from '@arkade-os/swap/protocol'
import { hex } from '@scure/base'

// Everything but v3 — baseline and absent included — is the PREVIOUS tree: swap
// #1147 flipped the SDK default to v3, so a no-generation derivation would strand
// a v2 client's deposit at a v3 address it never derived.
export const assetOfferGeneration = (covenantVersion?: string): 'current' | 'previous' =>
covenantVersion === RFQ_ASSET_SWAP_V3 ? 'current' : 'previous'

/**
* The covenant parameters, all of them already decided.
*
Expand Down Expand Up @@ -105,10 +112,14 @@ export const offerFromTerms = (
*/
export const offerScriptFrom = (
deps: OfferDerivation,
): ((terms: QuotedOfferTerms) => { pkScript: string; address: string }) => {
): ((terms: QuotedOfferTerms, covenantVersion?: string) => { pkScript: string; address: string }) => {
const emulatorPubkey = xOnlyPubkey(deps.emulatorPubkey)
return (terms) => {
const script = offerVtxoScript(offerFromTerms(terms, emulatorPubkey, deps.exitDelay), deps.serverPubkey)
return (terms, covenantVersion) => {
const script = offerVtxoScript(
offerFromTerms(terms, emulatorPubkey, deps.exitDelay),
deps.serverPubkey,
assetOfferGeneration(covenantVersion),
)
// Both from the SAME compiled script: the address is what the client is
// told and the pkScript is what the deposit is recognised by, so two
// compilations could disagree about one covenant.
Expand Down
25 changes: 15 additions & 10 deletions packages/solver-core/src/core/corridor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface CorridorRfqOutcome {
*/
export interface QuoteOptions {
requesterKey?: string
/** The covenant the host negotiated for this quote; absent derives the corridor's baseline. */
covenantVersion?: string
}

/**
Expand Down Expand Up @@ -344,17 +346,20 @@ export const createCorridorSet = (corridors: readonly Corridor[], options: Corri
* byte-identical. The field is stripped before the corridor's strict schema and echoed only if asked.
*/
const negotiatingCovenant = (corridor: Corridor, supported: ReadonlySet<string> | undefined): Corridor => {
const { pair, covenantVersion: version } = corridor.descriptor
const served = supported === undefined || supported.has(version)
const { pair, covenantVersion: baseline } = corridor.descriptor
// What this corridor can build (baseline alone unless it declares more), kept to what the deployment serves.
const buildable = corridor.descriptor.covenantVersions ?? [baseline]
const servedVersions = buildable.filter((v) => supported === undefined || supported.has(v))
const baselineServed = servedVersions.includes(baseline)
const quote: Corridor['quote'] = async (payload, options) => {
const rfqId = extractRfqId(payload)
const asked = typeof payload === 'object' && payload !== null && 'covenant_versions' in payload
if (!asked) {
if (served) return corridor.quote(payload, options)
if (baselineServed) return corridor.quote(payload, options)
return {
kind: 'refused',
payload: rfqRefusalPayload(rfqId, 'unsupported_pair'),
detail: `${pair} speaks covenant ${version}, which rfq.covenantVersions disables`,
detail: `${pair} speaks covenant ${baseline}, which rfq.covenantVersions disables`,
}
}
const { covenant_versions: listed, ...rest } = payload as Record<string, unknown>
Expand All @@ -366,21 +371,21 @@ const negotiatingCovenant = (corridor: Corridor, supported: ReadonlySet<string>
detail: 'covenant_versions is malformed',
}
}
if (!served || !parsed.data.includes(version)) {
// The client's most-preferred version this corridor serves — its order is the preference.
const chosen = parsed.data.find((v) => servedVersions.includes(v))
if (chosen === undefined) {
return {
kind: 'refused',
payload: rfqRefusalPayload(rfqId, 'unsupported_payload', {
error_code: 'covenant_version_unsupported',
field: 'covenant_versions',
}),
detail: served
? `${pair} speaks covenant ${version}, which covenant_versions does not list`
: `${pair} speaks covenant ${version}, which rfq.covenantVersions disables`,
detail: `${pair} serves ${servedVersions.join(', ') || 'no covenant'}; covenant_versions names none of them`,
}
}
const outcome = await corridor.quote(rest, options)
const outcome = await corridor.quote(rest, { ...options, covenantVersion: chosen })
return outcome.kind === 'quote'
? { ...outcome, payload: { ...outcome.payload, covenant_version: version } }
? { ...outcome, payload: { ...outcome.payload, covenant_version: chosen } }
: outcome
}
const wrapped = withQuote(corridor, quote)
Expand Down
4 changes: 4 additions & 0 deletions packages/solver-core/src/core/corridorDescriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export interface CorridorDescriptor<State extends string = string> {
* be opt-in per request instead.
*/
readonly covenantVersion: RfqCovenantVersion
/** Every covenant this corridor can BUILD, most preferred first and baseline
* included; absent means the baseline alone. Negotiation serves the client's
* top member the deployment also enables. */
readonly covenantVersions?: readonly RfqCovenantVersion[]
/** Which rail's balance funds this corridor's payout — the destination leg. */
readonly payoutRail: PayoutRail
/**
Expand Down
4 changes: 4 additions & 0 deletions packages/solver-core/src/core/rfqCovenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const RFQ_LIGHTNING_SEND_V2 = 'lightning-send-v2'
export const RFQ_LIGHTNING_RECEIVE_V2 = 'lightning-receive-v2'
export const RFQ_ONCHAIN_SEND_V2 = 'onchain-send-v2'
export const RFQ_ASSET_SWAP_V2 = 'asset-swap-v2'
// ts-sdk #1147's arkadec program; fixes v2's pinned payout-leaf index. Served
// alongside v2 but never the baseline — a v2 client names no version. @see assetOfferGeneration
export const RFQ_ASSET_SWAP_V3 = 'asset-swap-v3'
export const RFQ_EVM_SEND_V1 = 'evm-send-v1'
export const RFQ_EVM_RECEIVE_V1 = 'evm-receive-v1'

Expand All @@ -20,6 +23,7 @@ export const RFQ_COVENANT_VERSIONS = [
RFQ_LIGHTNING_RECEIVE_V2,
RFQ_ONCHAIN_SEND_V2,
RFQ_ASSET_SWAP_V2,
RFQ_ASSET_SWAP_V3,
RFQ_EVM_SEND_V1,
RFQ_EVM_RECEIVE_V1,
] as const
Expand Down
21 changes: 13 additions & 8 deletions packages/solver-corridors/src/asset/assetRfqOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export interface AssetRfqDeps {
* The offer covenant this solver will watch, derived from terms it has
* already fixed. `offerVtxoScript` in the composition root.
*/
deriveOffer: (terms: OfferTerms) => { pkScript: string; address: string }
deriveOffer: (terms: OfferTerms, covenantVersion?: string) => { pkScript: string; address: string }
/** What is funded at the offer's script, or null while nothing is. */
depositAt: (offerPkScript: string, depositLeg: AssetLeg | null) => Promise<ObservedDeposit | null>
/** Spendable balance per asset id — `available`, never `total`. */
Expand Down Expand Up @@ -169,6 +169,8 @@ export interface AssetRfqQuoteRequest {
amountSide: 'from' | 'to'
makerPkScript: string
makerPublicKey: string
/** The negotiated covenant, if any. Absent derives the corridor's baseline. */
covenantVersion?: string
}

/** How much of one leg a deposit holds — sats when the leg is BTC. */
Expand Down Expand Up @@ -302,13 +304,16 @@ export class AssetRfqSwapService {
return { accepted: false, reason: 'insufficient_inventory' }
}

const offer = this.deps.deriveOffer({
wantAmount: resolved.toAmount,
wantAssetId: pair.to,
offerAssetId: pair.from,
makerPkScript: request.makerPkScript,
makerPublicKey: request.makerPublicKey,
})
const offer = this.deps.deriveOffer(
{
wantAmount: resolved.toAmount,
wantAssetId: pair.to,
offerAssetId: pair.from,
makerPkScript: request.makerPkScript,
makerPublicKey: request.makerPublicKey,
},
request.covenantVersion,
)

try {
const swap = await this.deps.store.insertQuote({
Expand Down
5 changes: 4 additions & 1 deletion packages/solver-corridors/src/corridors/assetRfq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

import { parkVia, type Corridor, type CorridorReader } from '@arkade-os/solver-core/core/corridor.js'
import type { CorridorDescriptor } from '@arkade-os/solver-core/core/corridorDescriptor.js'
import { RFQ_ASSET_SWAP_V2 } from '@arkade-os/solver-core/core/rfqCovenant.js'
import { RFQ_ASSET_SWAP_V2, RFQ_ASSET_SWAP_V3 } from '@arkade-os/solver-core/core/rfqCovenant.js'
import { diagnose, phaseOfStates, type AdminSwap } from '@arkade-os/solver-core/core/swapView.js'
import type { CorridorRfqOutcome as RfqOutcome, QuoteOptions } from '@arkade-os/solver-core/core/corridor.js'
import { rfqRefusalPayload } from '../wire/payloads.js'
Expand Down Expand Up @@ -104,6 +104,8 @@ export const assetRfqDescriptor = (
pair: assetRfqPairFor(legs.from, legs.to),
envStem: assetRfqEnvStem(market, direction),
covenantVersion: RFQ_ASSET_SWAP_V2,
// v2 baseline for old wallets; v3 (the #1147 fix) opt-in via negotiation.
covenantVersions: [RFQ_ASSET_SWAP_V2, RFQ_ASSET_SWAP_V3],
// Both legs are Arkade, so the payout always comes out of the Arkade float
// — whether it is paid in sats or in an asset.
payoutRail: 'arkade',
Expand Down Expand Up @@ -240,6 +242,7 @@ export const respondToAssetRfqRequest = async (
makerPkScript: request.profile.maker_pk_script,
makerPublicKey: request.profile.maker_public_key,
requesterKey: options?.requesterKey,
covenantVersion: options?.covenantVersion,
})
if (outcome.accepted) {
// Off the OUTCOME, not the service: the serialiser wraps `quote` alone, and nothing revalidates this
Expand Down
3 changes: 2 additions & 1 deletion test/arkade/offerAddressAgreement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const clientOffer = async (unilateralExitDelay: number) => {
wantAmount: terms.wantAmount,
wantAsset: asset.AssetId.fromString(USDA),
emulatorPubkey: '02' + hex.encode(EMULATOR),
generation: 'previous', // a 0.0.12 client derives v2; the SDK default is now v3
})
}

Expand Down Expand Up @@ -109,7 +110,7 @@ describe('offer address agreement with a 0.0.12 client', () => {
const rebuilt = offerFromTerms(terms, xOnlyPubkey(EMULATOR), exitDelay)
const theirs = await clientOffer(605_184)

expect(hex.encode(offerVtxoScript(rebuilt, SERVER).pkScript)).toBe(hex.encode(theirs.swapPkScript))
expect(hex.encode(offerVtxoScript(rebuilt, SERVER, 'previous').pkScript)).toBe(hex.encode(theirs.swapPkScript))
})

it('moves the address when the advertised delay does', async () => {
Expand Down
38 changes: 38 additions & 0 deletions test/arkade/offerTerms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import { asset } from '@arkade-os/sdk'
import { offerVtxoScript } from '@arkade-os/swap/protocol'
import { hex } from '@scure/base'
import {
assetOfferGeneration,
offerExitDelay,
offerFromTerms,
offerScriptFrom,
xOnlyPubkey,
type QuotedOfferTerms,
} from '@arkade-os/solver-arkade/arkade/offerTerms.js'
import { RFQ_ASSET_SWAP_V2, RFQ_ASSET_SWAP_V3 } from '@arkade-os/solver-core/core/rfqCovenant.js'

const xonly = (fill: number): Uint8Array => schnorr.getPublicKey(new Uint8Array(32).fill(fill))
const SERVER = xonly(2)
Expand All @@ -46,6 +48,7 @@ const derive = offerScriptFrom(derivation)

describe('offerScriptFrom', () => {
it('compiles the same covenant the SDK does from the equivalent offer', () => {
// 'previous' = the v2 baseline; the SDK default is now v3. @see assetOfferGeneration
const direct = offerVtxoScript(
{
wantAmount: 1_000n,
Expand All @@ -56,6 +59,7 @@ describe('offerScriptFrom', () => {
exitDelay: EXIT,
},
SERVER,
'previous',
)
expect(derive(terms()).pkScript).toBe(hex.encode(direct.pkScript))
expect(derive(terms()).address).toBe(direct.address('tark', SERVER).encode())
Expand Down Expand Up @@ -112,6 +116,40 @@ describe('offerScriptFrom', () => {
})
})

describe('covenant version', () => {
const sdk = (generation: 'current' | 'previous'): string =>
hex.encode(
offerVtxoScript(
{
wantAmount: 1_000n,
wantAsset: asset.AssetId.fromString(USDA),
makerPkScript: hex.decode(MAKER_SCRIPT),
makerPublicKey: MAKER_KEY,
emulatorPubkey: EMULATOR,
exitDelay: EXIT,
},
SERVER,
generation,
).pkScript,
)

it('maps v3 to the current tree and everything else to the previous one', () => {
expect(assetOfferGeneration(RFQ_ASSET_SWAP_V3)).toBe('current')
expect(assetOfferGeneration(RFQ_ASSET_SWAP_V2)).toBe('previous')
expect(assetOfferGeneration(undefined)).toBe('previous')
})

it('derives v2 for the baseline and v3 only when it is named', () => {
expect(derive(terms()).pkScript).toBe(sdk('previous'))
expect(derive(terms(), RFQ_ASSET_SWAP_V2).pkScript).toBe(sdk('previous'))
expect(derive(terms(), RFQ_ASSET_SWAP_V3).pkScript).toBe(sdk('current'))
})

it('puts a v3 deposit at a different address than a v2 one', () => {
expect(derive(terms(), RFQ_ASSET_SWAP_V3).pkScript).not.toBe(derive(terms()).pkScript)
})
})

describe('xOnlyPubkey', () => {
it('drops the compressed prefix and leaves an x-only key alone', () => {
const compressed = new Uint8Array([0x02, ...EMULATOR])
Expand Down
12 changes: 12 additions & 0 deletions test/asset/assetRfqOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,18 @@ describe('quote', () => {
})
})

it('passes the negotiated covenant version through to the derivation', async () => {
const seen: (string | undefined)[] = []
const { service } = await harness({
deriveOffer: (_terms, covenantVersion) => {
seen.push(covenantVersion)
return { pkScript: OFFER_SCRIPT, address: 'ark1qoffer' }
},
})
await service.quote(request({ covenantVersion: 'asset-swap-v3' }))
expect(seen[0]).toBe('asset-swap-v3')
})

it.each([
['a pair on another corridor', { pair: 'arkade:BTC->lightning:BTC' }],
[
Expand Down
6 changes: 5 additions & 1 deletion test/core/rfqCovenant.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
RFQ_ASSET_SWAP_V2,
RFQ_ASSET_SWAP_V3,
RFQ_COVENANT_VERSIONS,
RFQ_EVM_RECEIVE_V1,
RFQ_EVM_SEND_V1,
Expand All @@ -11,12 +12,13 @@ import {
} from '@arkade-os/solver-core/core/rfqCovenant.js'

describe('RFQ covenant versions', () => {
it('ships one supported version for every built-in RFQ method', () => {
it('serves every built-in method, asset-swap on both v2 and v3', () => {
expect(RFQ_COVENANT_VERSIONS).toEqual([
RFQ_LIGHTNING_SEND_V2,
RFQ_LIGHTNING_RECEIVE_V2,
RFQ_ONCHAIN_SEND_V2,
RFQ_ASSET_SWAP_V2,
RFQ_ASSET_SWAP_V3,
RFQ_EVM_SEND_V1,
RFQ_EVM_RECEIVE_V1,
])
Expand All @@ -29,13 +31,15 @@ describe('RFQ covenant versions', () => {
'lightning-receive-v2',
'onchain-send-v2',
'asset-swap-v2',
'asset-swap-v3',
'evm-send-v1',
'evm-receive-v1',
])
})

it('serves the build defaults while unset, and only the operator list once set', () => {
expect(rfqCovenantEnabled(null, RFQ_ASSET_SWAP_V2)).toBe(true)
expect(rfqCovenantEnabled(null, RFQ_ASSET_SWAP_V3)).toBe(true)
expect(rfqCovenantEnabled(null, 'custom-v1')).toBe(false)
expect(rfqCovenantEnabled(['custom-v1'], RFQ_ASSET_SWAP_V2)).toBe(false)
})
Expand Down
37 changes: 37 additions & 0 deletions test/corridors/corridorInterface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,43 @@ describe('covenant negotiation', () => {
},
)

const multi = () => {
const seen: (string | undefined)[] = []
const corridor: Corridor = {
...stub('a->b', 'A', 'v2'),
descriptor: { ...descriptor('a->b', 'A', 'v2'), covenantVersions: ['v2', 'v3'] },
quote: async (_payload, options) => {
seen.push(options?.covenantVersion)
return { kind: 'quote', payload: { v: 1, type: 'rfq_quote', rfq_id: RFQ.rfq_id } }
},
}
return { corridor, seen }
}

it('serves the client most-preferred version both sides build, and passes it down', async () => {
const { corridor, seen } = multi()
const set = createCorridorSet([corridor], { supportedCovenantVersions: new Set(['v2', 'v3']) })
const outcome = await set.get('a->b')!.quote({ ...RFQ, covenant_versions: ['v3', 'v2'] })
expect(seen).toEqual(['v3'])
expect(outcome).toMatchObject({ payload: { covenant_version: 'v3' } })
})

it('falls to the next version the client lists when the top one is disabled', async () => {
const { corridor, seen } = multi()
const set = createCorridorSet([corridor], { supportedCovenantVersions: new Set(['v2']) })
const outcome = await set.get('a->b')!.quote({ ...RFQ, covenant_versions: ['v3', 'v2'] })
expect(seen).toEqual(['v2'])
expect(outcome).toMatchObject({ payload: { covenant_version: 'v2' } })
})

it('derives the baseline and names no version when the request does not negotiate', async () => {
const { corridor, seen } = multi()
const set = createCorridorSet([corridor], { supportedCovenantVersions: new Set(['v2', 'v3']) })
const outcome = await set.get('a->b')!.quote(RFQ)
expect(seen).toEqual([undefined])
expect(outcome.payload).not.toHaveProperty('covenant_version')
})

it('keeps a class-based corridor whole, prototype methods and private fields included', async () => {
class Counted {
readonly descriptor = descriptor('a->b', 'A', 'example-v1')
Expand Down
Loading