From 932dcb622244355ff96b4417168b2999ae38ea86 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:41:57 +1000 Subject: [PATCH 01/27] feat(tempo): add funding requirements and policies --- .changeset/tempo-funding.md | 5 + src/tempo/FundingPolicy.test.ts | 61 +++++ src/tempo/FundingPolicy.ts | 357 +++++++++++++++++++++++++ src/tempo/FundingRequirement.test-d.ts | 47 ++++ src/tempo/FundingRequirement.test.ts | 227 ++++++++++++++++ src/tempo/FundingRequirement.ts | 247 +++++++++++++++++ src/tempo/KeyAuthorization.ts | 59 +++- src/tempo/NativeDexFunding.test.ts | 16 ++ src/tempo/NativeDexFunding.ts | 50 ++++ src/tempo/Transaction.ts | 11 + src/tempo/TransactionRequest.ts | 12 + src/tempo/TxEnvelopeTempo.ts | 56 ++-- src/tempo/index.ts | 21 +- 13 files changed, 1143 insertions(+), 26 deletions(-) create mode 100644 .changeset/tempo-funding.md create mode 100644 src/tempo/FundingPolicy.test.ts create mode 100644 src/tempo/FundingPolicy.ts create mode 100644 src/tempo/FundingRequirement.test-d.ts create mode 100644 src/tempo/FundingRequirement.test.ts create mode 100644 src/tempo/FundingRequirement.ts create mode 100644 src/tempo/NativeDexFunding.test.ts create mode 100644 src/tempo/NativeDexFunding.ts diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md new file mode 100644 index 000000000..5b0a46e20 --- /dev/null +++ b/.changeset/tempo-funding.md @@ -0,0 +1,5 @@ +--- +"ox": patch +--- + +Added Tempo funding transaction codecs, committed policy rules, native DEX funding payloads, and access key funding authorization. diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts new file mode 100644 index 000000000..aff96e088 --- /dev/null +++ b/src/tempo/FundingPolicy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest' +import * as FundingPolicy from './FundingPolicy.js' + +const token = '0x0101010101010101010101010101010101010101' as const +const target = '0x0202020202020202020202020202020202020202' as const +const requirement = { + token, + amount: 50n, + sources: [{ target, data: '0xab' as const }], + slippageBps: 100, +} + +const rules = { + maxSlippageBps: 100, + sources: { [token]: requirement.sources }, +} +describe('encode', () => { + test('ABI encodes, decodes and hashes canonical rules', () => { + expect(FundingPolicy.decode(FundingPolicy.encode(rules))).toEqual(rules) + expect(FundingPolicy.hash(rules)).toMatch(/^0x[0-9a-f]{64}$/) + expect(() => + FundingPolicy.decode(`${FundingPolicy.encode(rules)}00`), + ).toThrow() + }) +}) + +describe('hash', () => { + test('canonical token order preserves significant source order', () => { + const second = '0x0303030303030303030303030303030303030303' + const first = { + ...rules, + sources: { [second]: requirement.sources, [token]: requirement.sources }, + } + const reordered = { + ...rules, + sources: { [token]: requirement.sources, [second]: requirement.sources }, + } + expect(FundingPolicy.encode(first)).toBe(FundingPolicy.encode(reordered)) + const sources = [...requirement.sources, { target, data: '0xcd' as const }] + expect( + FundingPolicy.hash({ ...rules, sources: { [token]: sources } }), + ).not.toBe( + FundingPolicy.hash({ + ...rules, + sources: { [token]: [...sources].reverse() }, + }), + ) + }) +}) + +describe('toTuple', () => { + test('rejects invalid policy IDs and admins', () => { + for (const value of [ + 0n, + 2n ** 64n, + { admins: [], rules }, + { admins: [token, token], rules }, + ]) + expect(() => FundingPolicy.toTuple(value)).toThrow() + }) +}) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts new file mode 100644 index 000000000..be1880728 --- /dev/null +++ b/src/tempo/FundingPolicy.ts @@ -0,0 +1,357 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import * as Address from '../core/Address.js' +import * as Errors from '../core/Errors.js' +import * as Hash from '../core/Hash.js' +import * as Hex from '../core/Hex.js' +import type * as FundingRequirement from './FundingRequirement.js' + +/** + * Funding permissions, represented by output token. */ +export type Rules = { + /** + * Maximum aggregate slippage in basis points. */ + maxSlippageBps: number + /** + * Ordered source permissions for each output token. */ + sources: Readonly< + Record + > +} + +/** + * Inline policy creation parameters. */ +export type Inline = { + /** + * Accounts authorized to modify the policy. */ + admins: readonly Address.Address[] + /** + * Committed funding permissions. */ + rules: Rules +} + +/** + * Policy authorization: an existing nonzero uint64 ID or inline creation. */ +export type Authorization = bigintType | Inline + +/** + * Policy state returned by the precompile; rules are available in events. */ +export type Policy = { + /** + * Accounts authorized to modify the policy. */ + admins: readonly Address.Address[] + /** + * Domain-separated commitment to canonical rules. */ + rulesHash: Hex.Hex +} + +/** + * ABI representation of one output route. */ +export type Route = { + /** + * Output token. */ + token: Address.Address + /** + * Ordered source permissions. */ + sources: readonly FundingRequirement.Source[] +} + +/** + * RLP policy authorization. */ +export type Tuple = + | Hex.Hex + | readonly [ + admins: readonly Hex.Hex[], + rules: readonly [ + Hex.Hex, + readonly (readonly [ + Hex.Hex, + readonly (readonly [Hex.Hex, Hex.Hex])[], + ])[], + ], + ] + +const parameters = [ + { + type: 'tuple', + components: [ + { name: 'maxSlippageBps', type: 'uint16' }, + { + name: 'routes', + type: 'tuple[]', + components: [ + { name: 'token', type: 'address' }, + { + name: 'sources', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'data', type: 'bytes' }, + ], + }, + ], + }, + ], + }, +] as const + +const domain = Hash.keccak256(Hex.fromString('tempo.funding-policy.rules.v1')) + +/** + * Converts a token map to canonical ascending routes without reordering sources. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * const rules = { maxSlippageBps: 100, sources: {} } + * FundingPolicy.toRoutes(rules) + * ``` + */ +export function toRoutes(rules: Rules): readonly Route[] { + if ( + !Number.isInteger(rules.maxSlippageBps) || + rules.maxSlippageBps < 0 || + rules.maxSlippageBps > 10_000 + ) + throw new InvalidPolicyError( + 'Slippage must be between 0 and 10,000 basis points.', + ) + const seen = new Set() + const routes = Object.entries(rules.sources).map(([token, sources]) => { + Address.assert(token, { strict: false }) + if (BigInt(token) === 0n || seen.has(token.toLowerCase())) + throw new InvalidPolicyError('Output tokens must be unique and nonzero.') + seen.add(token.toLowerCase()) + for (const { target, data } of sources) { + Address.assert(target, { strict: false }) + if ( + BigInt(target) === 0n || + !Hex.validate(data, { strict: true }) || + data.length % 2 !== 0 + ) + throw new InvalidPolicyError('Invalid source target or data.') + } + return { token, sources } + }) + return routes.sort((a, b) => (BigInt(a.token) < BigInt(b.token) ? -1 : 1)) +} + +/** + * ABI-encodes complete rules for a transaction's `policyRules` field. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * const rules = { maxSlippageBps: 100, sources: {} } + * FundingPolicy.encode(rules) + * ``` + */ +export function encode(rules: Rules): Hex.Hex { + return AbiParameters.encode(parameters, [ + { maxSlippageBps: rules.maxSlippageBps, routes: toRoutes(rules) }, + ]) +} + +/** + * Decodes canonical ABI rules, rejecting noncanonical or trailing bytes. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * const rules = { maxSlippageBps: 100, sources: {} } + * FundingPolicy.decode(FundingPolicy.encode(rules)) + * ``` + */ +export function decode(data: Hex.Hex): Rules { + const [value] = AbiParameters.decode(parameters, data) + const rules: Rules = { + maxSlippageBps: value.maxSlippageBps, + sources: Object.fromEntries( + value.routes.map(({ token, sources }) => [token, sources]), + ), + } + if (encode(rules).toLowerCase() !== data.toLowerCase()) + throw new InvalidPolicyError( + 'Policy rules must use canonical ABI encoding.', + ) + return rules +} + +/** + * Computes the protocol's domain-separated commitment to rules. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * const rules = { maxSlippageBps: 100, sources: {} } + * FundingPolicy.hash(rules) + * ``` + */ +export function hash(rules: Rules): Hex.Hex { + return Hash.keccak256( + AbiParameters.encode(AbiParameters.from('bytes32, bytes'), [ + domain, + encode(rules), + ]), + ) +} + +/** + * Encodes an existing policy ID or an inline policy as an RLP tuple. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * FundingPolicy.toTuple(7n) + * ``` + */ +export function toTuple(value: Authorization): Tuple { + if (typeof value === 'bigint') { + if (value <= 0n || value >= 2n ** 64n) + throw new InvalidPolicyError('Policy ID must be a nonzero uint64.') + return Hex.fromNumber(value) + } + const seen = new Set() + if (!value.admins.length) + throw new InvalidPolicyError('At least one admin is required.') + for (const admin of value.admins) { + Address.assert(admin, { strict: false }) + if (BigInt(admin) === 0n || seen.has(admin.toLowerCase())) + throw new InvalidPolicyError('Admins must be unique and nonzero.') + seen.add(admin.toLowerCase()) + } + return [ + value.admins, + [ + value.rules.maxSlippageBps === 0 + ? '0x' + : Hex.fromNumber(value.rules.maxSlippageBps), + toRoutes(value.rules).map( + ({ token, sources }) => + [ + token, + sources.map(({ target, data }) => [target, data] as const), + ] as const, + ), + ], + ] +} + +/** + * Decodes a canonical policy authorization tuple. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * FundingPolicy.fromTuple('0x07') + * ``` + */ +export function fromTuple(value: Tuple): Authorization { + if (typeof value === 'string') { + if ( + !Hex.validate(value, { strict: true }) || + value === '0x' || + value.startsWith('0x00') + ) + throw new InvalidPolicyError('Invalid policy ID encoding.') + const id = BigInt(value) + toTuple(id) + return id + } + if ( + !Array.isArray(value) || + value.length !== 2 || + !Array.isArray(value[0]) || + !Array.isArray(value[1]) || + value[1].length !== 2 + ) + throw new InvalidPolicyError('Invalid inline policy tuple.') + const [admins, [slippage, routes]] = value + if ( + typeof slippage !== 'string' || + !Hex.validate(slippage, { strict: true }) || + slippage.startsWith('0x00') || + !Array.isArray(routes) + ) + throw new InvalidPolicyError('Invalid policy rules tuple.') + const sources: Record = + {} + let previous = -1n + for (const route of routes) { + if (!Array.isArray(route) || route.length !== 2 || !Array.isArray(route[1])) + throw new InvalidPolicyError('Invalid route tuple.') + const [token, entries] = route + Address.assert(token, { strict: false }) + if (BigInt(token) <= previous) + throw new InvalidPolicyError('Routes must be in ascending token order.') + previous = BigInt(token) + sources[token] = entries.map((source) => { + if (!Array.isArray(source) || source.length !== 2) + throw new InvalidPolicyError('Invalid source tuple.') + return { target: source[0], data: source[1] } + }) + } + const policy = { + admins, + rules: { + maxSlippageBps: slippage === '0x' ? 0 : Number(slippage), + sources, + }, + } + toTuple(policy) + return policy +} + +/** + * Converts an RPC policy authorization to its domain representation. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * FundingPolicy.fromRpc('0x7') + * ``` + */ +export function fromRpc(value: Authorization): Authorization { + const result = typeof value === 'string' ? BigInt(value) : value + toTuple(result) + return result +} + +/** + * Converts a policy authorization to its RPC representation. + * + * @example + * ```ts + * import { FundingPolicy } from 'ox/tempo' + * + * FundingPolicy.toRpc(7n) + * ``` + */ +export function toRpc( + value: Authorization, +): Authorization { + if (typeof value !== 'object') { + if (typeof value === 'number' && !Number.isSafeInteger(value)) + throw new InvalidPolicyError('Numeric policy IDs must be safe integers.') + const id = BigInt(value) + toTuple(id) + return Hex.fromNumber(id) + } + toTuple(value) + return value +} + +/** + * Thrown when a funding policy cannot be canonically encoded. */ +export class InvalidPolicyError extends Errors.BaseError { + override readonly name = 'FundingPolicy.InvalidPolicyError' + constructor(message: string) { + super(message) + } +} diff --git a/src/tempo/FundingRequirement.test-d.ts b/src/tempo/FundingRequirement.test-d.ts new file mode 100644 index 000000000..2692de4a1 --- /dev/null +++ b/src/tempo/FundingRequirement.test-d.ts @@ -0,0 +1,47 @@ +import { expectTypeOf, test } from 'vitest' +import type * as FundingPolicy from './FundingPolicy.js' +import * as FundingRequirement from './FundingRequirement.js' +import type * as KeyAuthorization from './KeyAuthorization.js' +import type * as TxEnvelopeTempo from './TxEnvelopeTempo.js' + +test('funding requirements use executable values', () => { + expectTypeOf< + FundingRequirement.FundingRequirement['amount'] + >().toEqualTypeOf() + expectTypeOf< + FundingRequirement.FundingRequirement['slippageBps'] + >().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + readonly FundingRequirement.FundingRequirement[] | undefined + >() + expectTypeOf< + FundingRequirement.Rpc['amount'] + >().toEqualTypeOf<`0x${string}`>() + // @ts-expect-error Inference is a relay-only operation. + const invalid: TxEnvelopeTempo.TxEnvelopeTempo['requireFunds'] = true + void invalid +}) + +test('policy authorization preserves ID and inline types', () => { + expectTypeOf().toEqualTypeOf< + FundingPolicy.Authorization | undefined + >() + expectTypeOf< + FundingPolicy.Policy['rulesHash'] + >().toEqualTypeOf<`0x${string}`>() +}) + +test('from', () => { + const requirement = FundingRequirement.from({ + token: '0x0101010101010101010101010101010101010101', + amount: 50n, + slippageBps: 0, + sources: [ + { target: '0x0202020202020202020202020202020202020202', data: '0xab' }, + ], + }) + expectTypeOf(requirement.amount).toEqualTypeOf<50n>() + expectTypeOf(requirement.slippageBps).toEqualTypeOf<0>() + expectTypeOf(requirement.sources[0].data).toEqualTypeOf<'0xab'>() + expectTypeOf(requirement).toExtend() +}) diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts new file mode 100644 index 000000000..fe49339b5 --- /dev/null +++ b/src/tempo/FundingRequirement.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from 'vitest' +import type * as Hex from '../core/Hex.js' +import * as Rlp from '../core/Rlp.js' +import * as FundingPolicy from './FundingPolicy.js' +import * as FundingRequirement from './FundingRequirement.js' +import * as KeyAuthorization from './KeyAuthorization.js' +import * as TransactionRequest from './TransactionRequest.js' +import * as TxEnvelopeTempo from './TxEnvelopeTempo.js' + +const token = '0x0101010101010101010101010101010101010101' as const +const target = '0x0202020202020202020202020202020202020202' as const +const requirement = { + token, + amount: 50n, + sources: [{ target, data: '0xab' as const }], + slippageBps: 100, +} +const envelope = TxEnvelopeTempo.from({ + chainId: 1, + calls: [{ to: token }], + requireFunds: [requirement], +}) + +describe('toTuple', () => { + test('matches the Rust requirement golden vector', () => { + // tempo 4926397: funding::funding_requirement_golden. + expect(Rlp.fromHex(FundingRequirement.toTuple(requirement))).toBe( + '0xf194010101010101010101010101010101010101010132d8d794020202020202020202020202020202020202020281abc164', + ) + }) + + test('distinguishes omitted and explicit zero slippage', () => { + expect( + FundingRequirement.toTuple({ ...requirement, slippageBps: undefined })[3], + ).toEqual([]) + expect( + FundingRequirement.toTuple({ ...requirement, slippageBps: 0 })[3], + ).toEqual(['0x']) + }) +}) + +describe('behavior', () => { + const rules = { + maxSlippageBps: 100, + sources: { [token]: requirement.sources }, + } + + test('every funding field is covered by sender and sponsor signatures', () => { + for (const changed of [ + { ...requirement, token: target }, + { ...requirement, amount: 51n }, + { ...requirement, slippageBps: 0 }, + { ...requirement, policyRules: '0xab' as const }, + { ...requirement, sources: [{ target: token, data: '0xab' as const }] }, + { ...requirement, sources: [{ target, data: '0xcd' as const }] }, + ]) { + const altered = { ...envelope, requireFunds: [changed] } + expect(TxEnvelopeTempo.getSignPayload(altered)).not.toBe( + TxEnvelopeTempo.getSignPayload(envelope), + ) + expect( + TxEnvelopeTempo.getFeePayerSignPayload(altered, { sender: token }), + ).not.toBe( + TxEnvelopeTempo.getFeePayerSignPayload(envelope, { sender: token }), + ) + } + }) + + test('round-trips and preserves legacy bytes for omitted and empty arrays', () => { + expect( + TxEnvelopeTempo.deserialize(TxEnvelopeTempo.serialize(envelope)) + .requireFunds, + ).toEqual([requirement]) + expect(TxEnvelopeTempo.serialize({ ...envelope, requireFunds: [] })).toBe( + TxEnvelopeTempo.serialize({ ...envelope, requireFunds: undefined }), + ) + const rpc = TransactionRequest.toRpc(envelope) + expect(rpc.requireFunds?.[0]?.amount).toBe('0x32') + expect(TransactionRequest.fromRpc(rpc).requireFunds).toEqual([requirement]) + }) + + test('commits funding to the sender signature', () => { + expect(TxEnvelopeTempo.getSignPayload(envelope)).not.toBe( + TxEnvelopeTempo.getSignPayload({ + ...envelope, + requireFunds: [{ ...requirement, amount: 51n }], + }), + ) + }) + + test('rejects invalid values and malformed extensions', () => { + expect(() => + FundingRequirement.toTuple({ ...requirement, amount: -1n }), + ).toThrow() + expect(() => + FundingRequirement.toTuple({ ...requirement, slippageBps: 10_001 }), + ).toThrow() + expect(() => + FundingRequirement.toTuple({ ...requirement, policyRules: '0x' }), + ).toThrow() + const raw = Rlp.toHex( + TxEnvelopeTempo.serialize(envelope).replace( + /^0x76/, + '0x', + ) as `0x${string}`, + ) as readonly unknown[] + expect(() => + TxEnvelopeTempo.deserialize( + `0x76${Rlp.fromHex([...raw.slice(0, 14), []] as never).slice(2)}`, + ), + ).toThrow() + }) + + test('rejects a stray authorization placeholder and trailing fields', () => { + const base = Rlp.toHex( + TxEnvelopeTempo.serialize({ + ...envelope, + requireFunds: undefined, + }).replace(/^0x76/, '0x') as `0x${string}`, + ) as readonly Hex.Hex[] + for (const trailing of [ + ['0x'], + ['0x', []], + ['0x', [FundingRequirement.toTuple(requirement)], [], '0x'], + ]) + expect(() => + TxEnvelopeTempo.deserialize( + `0x76${Rlp.fromHex([...base, ...trailing] as never).slice(2)}`, + ), + ).toThrow() + }) + + test('extends key authorization with policy ID and inline rules', () => { + for (const fundingPolicy of [7n, { admins: [token], rules }]) { + const authorization = { + address: target, + chainId: 1n, + type: 'secp256k1' as const, + fundingPolicy, + } + expect( + KeyAuthorization.deserialize(KeyAuthorization.serialize(authorization)), + ).toEqual(authorization) + } + }) + + test('matches independent Rust key authorization vector', () => { + // tempo 4926397: funding_policy::policy_reference_has_canonical_trailing_encoding. + const authorization = { + address: '0x1111111111111111111111111111111111111111' as const, + chainId: 1n, + type: 'secp256k1' as const, + fundingPolicy: 7n, + } + expect(Rlp.fromHex(KeyAuthorization.toTuple(authorization)[0])).toBe( + '0xde018094111111111111111111111111111111111111111180808080808007', + ) + }) +}) + +describe('toRpc', () => { + test('rejects unsafe numeric RPC inputs', () => { + expect(() => + FundingRequirement.toRpc({ + ...requirement, + amount: Number.MAX_SAFE_INTEGER + 1, + }), + ).toThrow() + expect(() => FundingPolicy.toRpc(Number.MAX_SAFE_INTEGER + 1)).toThrow() + }) +}) + +describe('fromTuple', () => { + test('rejects malformed tuples and noncanonical quantities', () => { + for (const tuple of [ + [token, '0x0032', [], []], + [token, '0x32', [], ['0x00']], + [token, '0x32', [], ['0x', '0x']], + [token, '0x32', [[target]], []], + [token, '0x32', [], [], '0x'], + [token, '0x32', [], [], '0xab', '0xcd'], + ]) + expect(() => FundingRequirement.fromTuple(tuple as never)).toThrow() + }) +}) + +describe('assert', () => { + test('accepts uint256 maximum and rejects overflow', () => { + const value = { ...requirement, amount: 2n ** 256n - 1n } + expect( + FundingRequirement.fromTuple(FundingRequirement.toTuple(value)), + ).toEqual(value) + expect(() => + FundingRequirement.toTuple({ ...value, amount: 2n ** 256n }), + ).toThrow() + }) +}) + +describe('from', () => { + test('default', () => { + expect( + FundingRequirement.from({ token, amount: 50n, sources: [] }), + ).toEqual({ + token, + amount: 50n, + sources: [], + }) + }) + + test('preserves optional fields and source order', () => { + const value = { + ...requirement, + policyRules: '0xab' as const, + slippageBps: 0, + } + expect(FundingRequirement.from(value)).toEqual(value) + }) + + test('rejects invalid requirements', () => { + expect(() => + FundingRequirement.from({ ...requirement, amount: -1n }), + ).toThrow(FundingRequirement.InvalidRequirementError) + expect(() => + FundingRequirement.from({ ...requirement, slippageBps: 10001 }), + ).toThrow(FundingRequirement.InvalidRequirementError) + }) +}) diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts new file mode 100644 index 000000000..eaedcc467 --- /dev/null +++ b/src/tempo/FundingRequirement.ts @@ -0,0 +1,247 @@ +import * as Address from '../core/Address.js' +import * as Errors from '../core/Errors.js' +import * as Hex from '../core/Hex.js' +import * as Quantity from '../core/internal/quantity.js' + +/** + * A concrete call to a funding source. */ +export type Source = { + /** + * Source-specific ABI-encoded request. */ + data: Hex.Hex + /** + * Funding source address. */ + target: Address.Address +} + +/** + * Target balance to satisfy before application calls. */ +export type FundingRequirement = { + /** + * Requested output token. */ + token: Address.Address + /** + * Target balance, including existing funds, in token base units. */ + amount: bigintType + /** + * Canonical policy rules for access key funding. Owners omit this field. */ + policyRules?: Hex.Hex | undefined + /** + * Aggregate slippage in basis points. Omission uses the protocol default. */ + slippageBps?: numberType | undefined + /** + * Sources attempted in order. */ + sources: readonly Source[] +} + +/** + * RPC funding requirement. */ +export type Rpc = FundingRequirement + +/** + * RLP funding requirement tuple. */ +export type Tuple = readonly [ + token: Hex.Hex, + amount: Hex.Hex, + sources: readonly (readonly [Hex.Hex, Hex.Hex])[], + slippage: readonly Hex.Hex[], + ...policyRules: [] | [Hex.Hex], +] + +/** + * Validates a funding requirement's signed fields. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.assert({ token, amount: 50n, sources: [] }) + * ``` + */ +export function assert(value: FundingRequirement): void { + Address.assert(value.token, { strict: false }) + if (value.amount < 0n || value.amount >= 2n ** 256n) + throw new InvalidRequirementError('Amount must fit uint256.') + if ( + value.slippageBps !== undefined && + (!Number.isInteger(value.slippageBps) || + value.slippageBps < 0 || + value.slippageBps > 10_000) + ) + throw new InvalidRequirementError( + 'Slippage must be between 0 and 10,000 basis points.', + ) + if ( + value.policyRules !== undefined && + (!Hex.validate(value.policyRules, { strict: true }) || + value.policyRules === '0x' || + value.policyRules.length % 2 !== 0) + ) + throw new InvalidRequirementError('Policy rules must be nonempty bytes.') + for (const source of value.sources) { + Address.assert(source.target, { strict: false }) + if ( + !Hex.validate(source.data, { strict: true }) || + source.data.length % 2 !== 0 + ) + throw new InvalidRequirementError('Source data must be bytes.') + } +} + +/** + * Creates and validates a funding requirement, preserving inferred literal types. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * const requirement = FundingRequirement.from({ + * token: '0x20c0000000000000000000000000000000000001', + * amount: 50n, + * sources: [], + * }) + * ``` + */ +export function from( + requirement: requirement, +): requirement { + assert(requirement) + return requirement +} + +/** + * Converts a funding requirement to its RLP tuple. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.toTuple({ token, amount: 50n, sources: [] }) + * ``` + */ +export function toTuple(value: FundingRequirement): Tuple { + assert(value) + return [ + value.token, + value.amount === 0n ? '0x' : Hex.fromNumber(BigInt(value.amount)), + value.sources.map(({ target, data }) => [target, data] as const), + value.slippageBps === undefined + ? [] + : [ + value.slippageBps === 0 + ? '0x' + : Hex.fromNumber(Number(value.slippageBps)), + ], + ...((value.policyRules === undefined ? [] : [value.policyRules]) as + | [] + | [Hex.Hex]), + ] +} + +/** + * Decodes a funding requirement from its RLP tuple. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.fromTuple([token, '0x32', [], []]) + * ``` + */ +export function fromTuple(value: Tuple): FundingRequirement { + if (!Array.isArray(value) || (value.length !== 4 && value.length !== 5)) + throw new InvalidRequirementError('Expected four or five funding fields.') + const [token, amount, sources, slippage, policyRules] = value + if ( + !Array.isArray(sources) || + !Array.isArray(slippage) || + slippage.length > 1 + ) + throw new InvalidRequirementError('Invalid source or slippage list.') + const decode = (hex: Hex.Hex) => { + if ( + typeof hex !== 'string' || + !Hex.validate(hex, { strict: true }) || + hex.startsWith('0x00') + ) + throw new InvalidRequirementError('Noncanonical funding integer.') + return hex === '0x' ? 0n : BigInt(hex) + } + const requirement: FundingRequirement = { + token, + amount: decode(amount), + sources: sources.map((source) => { + if (!Array.isArray(source) || source.length !== 2) + throw new InvalidRequirementError('Expected source target and data.') + return { target: source[0], data: source[1] } + }), + ...(slippage.length ? { slippageBps: Number(decode(slippage[0]!)) } : {}), + ...(policyRules !== undefined ? { policyRules } : {}), + } + assert(requirement) + return requirement +} + +/** + * Converts RPC quantities to a funding requirement. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.fromRpc({ token, amount: '0x32', sources: [] }) + * ``` + */ +export function fromRpc(value: Rpc): FundingRequirement { + const { amount, slippageBps, ...rest } = value + const result: FundingRequirement = { + ...rest, + amount: BigInt(amount), + ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), + } + assert(result) + return result +} + +/** + * Converts a funding requirement to RPC quantities. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.toRpc({ token, amount: 50n, sources: [] }) + * ``` + */ +export function toRpc( + value: FundingRequirement, +): Rpc { + assert({ + ...value, + amount: BigInt(value.amount), + slippageBps: + value.slippageBps === undefined ? undefined : Number(value.slippageBps), + }) + const { amount, slippageBps, ...rest } = value + return { + ...rest, + amount: Quantity.fromNumberish(amount), + ...(slippageBps === undefined + ? {} + : { slippageBps: Quantity.fromNumberish(slippageBps) }), + } +} + +/** + * Thrown when funding fields violate the protocol encoding. */ +export class InvalidRequirementError extends Errors.BaseError { + override readonly name = 'FundingRequirement.InvalidRequirementError' + constructor(message: string) { + super(message) + } +} diff --git a/src/tempo/KeyAuthorization.ts b/src/tempo/KeyAuthorization.ts index 4bbd21415..01f74302d 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -9,6 +9,7 @@ import type { UnionPartialBy, } from '../core/internal/types.js' import * as Rlp from '../core/Rlp.js' +import * as FundingPolicy from './FundingPolicy.js' import * as SignatureEnvelope from './SignatureEnvelope.js' import * as TempoAddress from './TempoAddress.js' @@ -45,6 +46,8 @@ export type KeyAuthorization< chainId: bigintType /** Unix timestamp when key expires (undefined = never expires). */ expiry?: numberType | null | undefined + /** Existing funding policy ID or inline policy creation. */ + fundingPolicy?: FundingPolicy.Authorization | undefined /** Whether this authorization provisions an admin access key. */ isAdmin?: boolean | undefined /** TIP20 spending limits for this key. */ @@ -122,6 +125,8 @@ export type Rpc = { chainId: Hex.Hex /** Expiry timestamp (hex quantity or null). */ expiry: Hex.Hex | null | undefined + /** Existing policy ID or inline creation. */ + fundingPolicy?: FundingPolicy.Authorization | undefined /** Whether this authorization provisions an admin access key (TIP-1049). */ isAdmin?: boolean | null | undefined /** Key identifier. */ @@ -201,6 +206,16 @@ type CallScopeTuple = readonly [ ] type AuthorizationTuple = + | readonly [ + ...BaseTuple, + expiry: Hex.Hex, + limits: readonly TokenLimitTuple[] | Hex.Hex, + calls: readonly CallScopeTuple[] | Hex.Hex, + witness: Hex.Hex, + isAdmin: Hex.Hex, + account: Hex.Hex, + fundingPolicy: FundingPolicy.Tuple, + ] | BaseTuple | readonly [...BaseTuple, expiry: Hex.Hex] | readonly [...BaseTuple, expiry: Hex.Hex, limits: readonly TokenLimitTuple[]] @@ -446,6 +461,8 @@ export function from< recipients?: readonly TempoAddress.Address[] }[] } + if (auth.fundingPolicy !== undefined) + FundingPolicy.toTuple(auth.fundingPolicy) if (auth.witness !== undefined) assertWitness(auth.witness) if (auth.signature) assertSignature(auth.signature) const resolved = { @@ -599,6 +616,9 @@ export function fromRpc(authorization: Rpc): Signed { ? { account: account!, type: keyType } : { type: keyType }), ...(witness !== undefined ? { witness } : {}), + ...(authorization.fundingPolicy !== undefined + ? { fundingPolicy: FundingPolicy.fromRpc(authorization.fundingPolicy) } + : {}), ...(account !== undefined ? { account } : {}), ...(isAdmin ? { isAdmin: true as const } : {}), } @@ -679,8 +699,19 @@ export function fromTuple( // Trailing optional fields in wire order. Each entry pulls one slot off the // trailing array and decodes it (treating absent or RLP-null placeholders as // missing). To add a new optional trailing field, append a single entry. - const [rawExpiry, rawLimits, rawScopes, rawWitness, rawIsAdmin, rawAccount] = - trailing + const [ + rawExpiry, + rawLimits, + rawScopes, + rawWitness, + rawIsAdmin, + rawAccount, + rawFundingPolicy, + ] = trailing + if (trailing.length > 7) + throw new FundingPolicy.InvalidPolicyError( + 'Unexpected key authorization fields.', + ) const expiry = isAbsent(rawExpiry) ? undefined : hexToNumber(rawExpiry as Hex.Hex) || undefined @@ -738,6 +769,13 @@ export function fromTuple( ...(limits !== undefined ? { limits } : {}), ...(scopes !== undefined ? { scopes } : {}), ...(witness !== undefined ? { witness } : {}), + ...(rawFundingPolicy !== undefined + ? { + fundingPolicy: FundingPolicy.fromTuple( + rawFundingPolicy as FundingPolicy.Tuple, + ), + } + : {}), ...(account !== undefined ? { account } : {}), ...(isAdmin ? { isAdmin: true as const } : {}), } @@ -1005,6 +1043,9 @@ export function toRpc(authorization: Signed): Rpc { ...(allowedCalls ? { allowedCalls } : {}), ...(witness !== undefined ? { witness } : {}), ...(isAdmin ? { isAdmin: true } : {}), + ...(authorization.fundingPolicy !== undefined + ? { fundingPolicy: FundingPolicy.toRpc(authorization.fundingPolicy) } + : {}), ...(account !== undefined ? { account } : {}), } } @@ -1127,7 +1168,10 @@ export function toTuple( // To add a new optional trailing field (e.g. from a future TIP): append a // single entry to this list with `placeholder: '0x'`. const hasTip1053Plus = - witness !== undefined || isAdmin || account !== undefined + witness !== undefined || + isAdmin || + account !== undefined || + authorization.fundingPolicy !== undefined const optionals: readonly { placeholder: unknown; value: unknown }[] = [ { value: @@ -1145,8 +1189,15 @@ export function toTuple( // TIP-1049: admin marker. Present = `0x01` (RLP integer 1); absent // skipped or omitted. Any other value is a hard decode error on the node. { value: isAdmin ? '0x01' : undefined, placeholder: '0x' }, - // TIP-1049: optional account binding. Last field — never a placeholder. + // Optional account binding precedes the funding policy. { value: account, placeholder: '0x' }, + { + value: + authorization.fundingPolicy === undefined + ? undefined + : FundingPolicy.toTuple(authorization.fundingPolicy), + placeholder: '0x', + }, ] let lastPresent = -1 for (let i = optionals.length - 1; i >= 0; i--) diff --git a/src/tempo/NativeDexFunding.test.ts b/src/tempo/NativeDexFunding.test.ts new file mode 100644 index 000000000..719db57ac --- /dev/null +++ b/src/tempo/NativeDexFunding.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest' +import * as NativeDexFunding from './NativeDexFunding.js' + +const token = '0x0101010101010101010101010101010101010101' as const +describe('encode', () => { + test('preserves zero and defaults to unlimited input', () => { + expect( + NativeDexFunding.decode(NativeDexFunding.encode({ tokenIn: token })), + ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + expect( + NativeDexFunding.decode( + NativeDexFunding.encode({ tokenIn: token, maxAmountIn: 0n }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 0n }) + }) +}) diff --git a/src/tempo/NativeDexFunding.ts b/src/tempo/NativeDexFunding.ts new file mode 100644 index 000000000..6bb09591c --- /dev/null +++ b/src/tempo/NativeDexFunding.ts @@ -0,0 +1,50 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import type * as Address from '../core/Address.js' +import type * as Hex from '../core/Hex.js' + +/** + * Concrete native DEX funding request or policy entry. */ +export type Request = { + /** + * Input token to exchange for the required token. */ + tokenIn: Address.Address + /** + * Input cap in base units. Omission is unlimited; zero permits no input. */ + maxAmountIn?: bigint | undefined +} + +const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') + +/** + * Encodes a native DEX funding request or policy entry. + * + * @example + * ```ts + * import { NativeDexFunding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * NativeDexFunding.encode({ tokenIn: token, maxAmountIn: 30_000_000n }) + * ``` + */ +export function encode(request: Request): Hex.Hex { + return AbiParameters.encode(parameters, [ + request.tokenIn, + request.maxAmountIn ?? 2n ** 256n - 1n, + ]) +} + +/** + * Decodes native DEX funding data, returning the concrete input cap. + * + * @example + * ```ts + * import { NativeDexFunding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * NativeDexFunding.decode(NativeDexFunding.encode({ tokenIn: token })) + * ``` + */ +export function decode(data: Hex.Hex): Required { + const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) + return { tokenIn, maxAmountIn } +} diff --git a/src/tempo/Transaction.ts b/src/tempo/Transaction.ts index e8f417d1d..0c0a4ad58 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -6,6 +6,7 @@ import type { Compute, OneOf, UnionCompute } from '../core/internal/types.js' import * as Signature from '../core/Signature.js' import * as ox_Transaction from '../core/Transaction.js' import * as AuthorizationTempo from './AuthorizationTempo.js' +import * as FundingRequirement from './FundingRequirement.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as SignatureEnvelope from './SignatureEnvelope.js' import type { Call } from './TxEnvelopeTempo.js' @@ -78,6 +79,10 @@ export type Tempo< /** Effective gas price paid by the sender in wei. */ gasPrice?: bigintType | undefined /** Key authorization for provisioning a new access key. */ + /** Required balances before application calls. */ + requireFunds?: + | readonly FundingRequirement.FundingRequirement[] + | undefined keyAuthorization?: | KeyAuthorization.KeyAuthorization | undefined @@ -220,6 +225,10 @@ export function fromRpc< transaction_.validAfter = Number(transaction.validAfter) if (transaction.validBefore) transaction_.validBefore = Number(transaction.validBefore) + if (transaction.requireFunds) + transaction_.requireFunds = transaction.requireFunds.map( + FundingRequirement.fromRpc, + ) if (transaction.keyAuthorization) transaction_.keyAuthorization = KeyAuthorization.fromRpc( transaction.keyAuthorization, @@ -310,6 +319,8 @@ export function toRpc( data: call.data, })) if (transaction.feeToken) rpc.feeToken = transaction.feeToken + if (transaction.requireFunds) + rpc.requireFunds = transaction.requireFunds.map(FundingRequirement.toRpc) if (transaction.keyAuthorization) rpc.keyAuthorization = KeyAuthorization.toRpc(transaction.keyAuthorization) if (transaction.feePayerSignature) { diff --git a/src/tempo/TransactionRequest.ts b/src/tempo/TransactionRequest.ts index 9062346e2..83ce2918d 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -3,6 +3,7 @@ import * as Hex from '../core/Hex.js' import type { Compute } from '../core/internal/types.js' import * as ox_TransactionRequest from '../core/TransactionRequest.js' import * as AuthorizationTempo from './AuthorizationTempo.js' +import * as FundingRequirement from './FundingRequirement.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as MultisigSimulation from './MultisigSimulation.js' import type * as SignatureEnvelope from './SignatureEnvelope.js' @@ -34,6 +35,10 @@ export type TransactionRequest< authorizationList?: | AuthorizationTempo.ListSigned | undefined + /** Required balances before application calls. */ + requireFunds?: + | readonly FundingRequirement.FundingRequirement[] + | undefined calls?: readonly Call[] | undefined feePayer?: boolean | undefined feeToken?: TokenId.TokenIdOrAddress | undefined @@ -118,6 +123,8 @@ export function fromRpc(request: Rpc): TransactionRequest { }) if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken + if (request.requireFunds) + request_.requireFunds = request.requireFunds.map(FundingRequirement.fromRpc) if (request.keyAuthorization) request_.keyAuthorization = KeyAuthorization.fromRpc( request.keyAuthorization, @@ -224,6 +231,10 @@ export function toRpc(request: TransactionRequest): Rpc { ] if (typeof request.feeToken !== 'undefined') request_rpc.feeToken = TokenId.toAddress(request.feeToken) + if (request.requireFunds) + request_rpc.requireFunds = request.requireFunds.map( + FundingRequirement.toRpc, + ) if (request.keyAuthorization) request_rpc.keyAuthorization = KeyAuthorization.toRpc( request.keyAuthorization, @@ -250,6 +261,7 @@ export function toRpc(request: TransactionRequest): Rpc { if (nonceKey) request_rpc.nonceKey = nonceKey if ( + typeof request.requireFunds !== 'undefined' || typeof request.calls !== 'undefined' || typeof request.feePayer !== 'undefined' || typeof request.feeToken !== 'undefined' || diff --git a/src/tempo/TxEnvelopeTempo.ts b/src/tempo/TxEnvelopeTempo.ts index e9ed16d6b..98791207c 100644 --- a/src/tempo/TxEnvelopeTempo.ts +++ b/src/tempo/TxEnvelopeTempo.ts @@ -15,6 +15,7 @@ import * as Secp256k1 from '../core/Secp256k1.js' import * as Signature from '../core/Signature.js' import * as TransactionEnvelope from '../core/TxEnvelope.js' import * as AuthorizationTempo from './AuthorizationTempo.js' +import * as FundingRequirement from './FundingRequirement.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as SignatureEnvelope from './SignatureEnvelope.js' import * as TempoAddress from './TempoAddress.js' @@ -103,6 +104,10 @@ export type TxEnvelopeTempo< keyAuthorization?: | KeyAuthorization.Signed | undefined + /** Required balances processed before application calls. */ + requireFunds?: + | readonly FundingRequirement.FundingRequirement[] + | undefined /** Total fee per gas in wei (gasPrice/baseFeePerGas + maxPriorityFeePerGas). */ maxFeePerGas?: bigintType | undefined /** Max priority fee per gas (in wei). */ @@ -184,6 +189,9 @@ export function assert(envelope: PartialBy) { validAfter, } = envelope + for (const requirement of envelope.requireFunds ?? []) + FundingRequirement.assert(requirement) + // Calls must not be empty if (!calls || calls.length === 0) throw new CallsEmptyError() @@ -270,23 +278,24 @@ export function deserialize(serialized: Serialized): Compute { feeToken, feePayerSignatureOrSender, authorizationList, - keyAuthorizationOrSignature, - maybeSignature, + ...trailing ] = transactionArray as readonly Hex.Hex[] - const keyAuthorization = Array.isArray(keyAuthorizationOrSignature) - ? keyAuthorizationOrSignature + const fields: unknown[] = [...trailing] + const keyAuthorization = Array.isArray(fields[0]) ? fields.shift() : undefined + const placeholder = fields[0] === '0x' + if (placeholder) fields.shift() + const funding = Array.isArray(fields[0]) + ? (fields.shift() as unknown[]) : undefined - const signature = keyAuthorization - ? maybeSignature - : keyAuthorizationOrSignature - + const signature = fields.shift() as Hex.Hex | undefined if ( - !( - transactionArray.length === 13 || - transactionArray.length === 14 || - transactionArray.length === 15 - ) + transactionArray.length < 13 || + fields.length || + (placeholder && (!funding || keyAuthorization !== undefined)) || + (funding && !keyAuthorization && !placeholder) || + (funding && funding.length === 0) || + (signature !== undefined && typeof signature !== 'string') ) throw new TransactionEnvelope.InvalidSerializedError({ attributes: { @@ -304,15 +313,14 @@ export function deserialize(serialized: Serialized): Compute { validAfter, feeToken, feePayerSignatureOrSender, - ...(transactionArray.length > 12 - ? { - signature, - } - : {}), + ...(transactionArray.length > 12 ? { signature } : {}), }, serialized, type, }) + const requireFunds = funding?.map((value) => + FundingRequirement.fromTuple(value as FundingRequirement.Tuple), + ) let transaction = { chainId: Number(chainId), @@ -373,6 +381,8 @@ export function deserialize(serialized: Serialized): Compute { ) } + if (requireFunds) transaction.requireFunds = requireFunds + if (keyAuthorization) transaction.keyAuthorization = KeyAuthorization.fromTuple( keyAuthorization as never, @@ -641,6 +651,7 @@ export function serialize( feeToken, gas, keyAuthorization, + requireFunds, nonce, nonceKey, maxFeePerGas, @@ -722,7 +733,14 @@ export function serialize( : '0x', feePayerSignatureOrSender, authorizationTupleList, - ...(keyAuthorization ? [KeyAuthorization.toTuple(keyAuthorization)] : []), + ...(keyAuthorization + ? [KeyAuthorization.toTuple(keyAuthorization)] + : requireFunds?.length + ? ['0x' as const] + : []), + ...(requireFunds?.length + ? [requireFunds.map(FundingRequirement.toTuple)] + : []), ...(signature ? [SignatureEnvelope.serialize(SignatureEnvelope.from(signature))] : []), diff --git a/src/tempo/index.ts b/src/tempo/index.ts index b6ec4361e..4ab4265db 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -84,6 +84,18 @@ export * as Channel from './Channel.js' * @category Reference */ export * as EarnShares from './EarnShares.js' +/** + * Funding policy commitments and authorization codecs. + * + * @category Reference + */ +export * as FundingPolicy from './FundingPolicy.js' +/** + * Funding requirements and source request types. + * + * @category Reference + */ +export * as FundingRequirement from './FundingRequirement.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * @@ -170,6 +182,12 @@ export * as MultisigOperation from './MultisigOperation.js' * @category Reference */ export * as MultisigSimulation from './MultisigSimulation.js' +/** + * Native DEX funding payload codecs. + * + * @category Reference + */ +export * as NativeDexFunding from './NativeDexFunding.js' /** * Utilities for constructing period durations (in seconds) for recurring spending limits. * @@ -199,7 +217,6 @@ export * as MultisigSimulation from './MultisigSimulation.js' * @category Reference */ export * as Period from './Period.js' - /** * Pool ID utilities for computing pool identifiers from token pairs. * @@ -264,7 +281,6 @@ export * as ReceivePolicyReceipt from './ReceivePolicyReceipt.js' * @category Reference */ export * as RpcSchemaTempo from './RpcSchemaTempo.js' - /** * Signature envelope utilities for secp256k1, P256, WebAuthn, and keychain signatures. * @@ -541,7 +557,6 @@ export * as VirtualAddress from './VirtualAddress.js' * @category Reference */ export * as VirtualMaster from './VirtualMaster.js' - /** * Zone ID utilities for converting between zone IDs and zone chain IDs. * From 8377cd0b8edf45cda31fe2866e8abd081a23abe8 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:42:14 +1000 Subject: [PATCH 02/27] refactor(tempo): remove redundant error constructors --- src/tempo/FundingPolicy.ts | 3 --- src/tempo/FundingRequirement.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index be1880728..408f15fbc 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -351,7 +351,4 @@ export function toRpc( * Thrown when a funding policy cannot be canonically encoded. */ export class InvalidPolicyError extends Errors.BaseError { override readonly name = 'FundingPolicy.InvalidPolicyError' - constructor(message: string) { - super(message) - } } diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index eaedcc467..0ec471946 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -241,7 +241,4 @@ export function toRpc( * Thrown when funding fields violate the protocol encoding. */ export class InvalidRequirementError extends Errors.BaseError { override readonly name = 'FundingRequirement.InvalidRequirementError' - constructor(message: string) { - super(message) - } } From b391bbf482331ab540e7e88699ba2a76504e3de6 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:44:22 +1000 Subject: [PATCH 03/27] test(tempo): move behavior cases last --- src/tempo/FundingRequirement.test.ts | 136 +++++++++++++-------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index fe49339b5..16ad1b4e6 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -39,6 +39,74 @@ describe('toTuple', () => { }) }) +describe('toRpc', () => { + test('rejects unsafe numeric RPC inputs', () => { + expect(() => + FundingRequirement.toRpc({ + ...requirement, + amount: Number.MAX_SAFE_INTEGER + 1, + }), + ).toThrow() + expect(() => FundingPolicy.toRpc(Number.MAX_SAFE_INTEGER + 1)).toThrow() + }) +}) + +describe('fromTuple', () => { + test('rejects malformed tuples and noncanonical quantities', () => { + for (const tuple of [ + [token, '0x0032', [], []], + [token, '0x32', [], ['0x00']], + [token, '0x32', [], ['0x', '0x']], + [token, '0x32', [[target]], []], + [token, '0x32', [], [], '0x'], + [token, '0x32', [], [], '0xab', '0xcd'], + ]) + expect(() => FundingRequirement.fromTuple(tuple as never)).toThrow() + }) +}) + +describe('assert', () => { + test('accepts uint256 maximum and rejects overflow', () => { + const value = { ...requirement, amount: 2n ** 256n - 1n } + expect( + FundingRequirement.fromTuple(FundingRequirement.toTuple(value)), + ).toEqual(value) + expect(() => + FundingRequirement.toTuple({ ...value, amount: 2n ** 256n }), + ).toThrow() + }) +}) + +describe('from', () => { + test('default', () => { + expect( + FundingRequirement.from({ token, amount: 50n, sources: [] }), + ).toEqual({ + token, + amount: 50n, + sources: [], + }) + }) + + test('preserves optional fields and source order', () => { + const value = { + ...requirement, + policyRules: '0xab' as const, + slippageBps: 0, + } + expect(FundingRequirement.from(value)).toEqual(value) + }) + + test('rejects invalid requirements', () => { + expect(() => + FundingRequirement.from({ ...requirement, amount: -1n }), + ).toThrow(FundingRequirement.InvalidRequirementError) + expect(() => + FundingRequirement.from({ ...requirement, slippageBps: 10001 }), + ).toThrow(FundingRequirement.InvalidRequirementError) + }) +}) + describe('behavior', () => { const rules = { maxSlippageBps: 100, @@ -157,71 +225,3 @@ describe('behavior', () => { ) }) }) - -describe('toRpc', () => { - test('rejects unsafe numeric RPC inputs', () => { - expect(() => - FundingRequirement.toRpc({ - ...requirement, - amount: Number.MAX_SAFE_INTEGER + 1, - }), - ).toThrow() - expect(() => FundingPolicy.toRpc(Number.MAX_SAFE_INTEGER + 1)).toThrow() - }) -}) - -describe('fromTuple', () => { - test('rejects malformed tuples and noncanonical quantities', () => { - for (const tuple of [ - [token, '0x0032', [], []], - [token, '0x32', [], ['0x00']], - [token, '0x32', [], ['0x', '0x']], - [token, '0x32', [[target]], []], - [token, '0x32', [], [], '0x'], - [token, '0x32', [], [], '0xab', '0xcd'], - ]) - expect(() => FundingRequirement.fromTuple(tuple as never)).toThrow() - }) -}) - -describe('assert', () => { - test('accepts uint256 maximum and rejects overflow', () => { - const value = { ...requirement, amount: 2n ** 256n - 1n } - expect( - FundingRequirement.fromTuple(FundingRequirement.toTuple(value)), - ).toEqual(value) - expect(() => - FundingRequirement.toTuple({ ...value, amount: 2n ** 256n }), - ).toThrow() - }) -}) - -describe('from', () => { - test('default', () => { - expect( - FundingRequirement.from({ token, amount: 50n, sources: [] }), - ).toEqual({ - token, - amount: 50n, - sources: [], - }) - }) - - test('preserves optional fields and source order', () => { - const value = { - ...requirement, - policyRules: '0xab' as const, - slippageBps: 0, - } - expect(FundingRequirement.from(value)).toEqual(value) - }) - - test('rejects invalid requirements', () => { - expect(() => - FundingRequirement.from({ ...requirement, amount: -1n }), - ).toThrow(FundingRequirement.InvalidRequirementError) - expect(() => - FundingRequirement.from({ ...requirement, slippageBps: 10001 }), - ).toThrow(FundingRequirement.InvalidRequirementError) - }) -}) From 50411d0c6be632e9133dcf1b337906443865655f Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:26:11 +1000 Subject: [PATCH 04/27] feat(tempo): use to for funding requirement sources --- src/tempo/FundingPolicy.ts | 18 +++++++++------ src/tempo/FundingRequirement.test-d.ts | 2 +- src/tempo/FundingRequirement.test.ts | 31 ++++++++++++++++++++++---- src/tempo/FundingRequirement.ts | 18 +++++++++------ src/tempo/Transaction.ts | 7 +++++- src/tempo/TransactionRequest.ts | 2 ++ 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index 408f15fbc..1bc832740 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -3,7 +3,14 @@ import * as Address from '../core/Address.js' import * as Errors from '../core/Errors.js' import * as Hash from '../core/Hash.js' import * as Hex from '../core/Hex.js' -import type * as FundingRequirement from './FundingRequirement.js' + +/** An approved funding source and its policy data. */ +export type Source = { + /** Source-specific policy data. */ + data: Hex.Hex + /** Funding source address. */ + target: Address.Address +} /** * Funding permissions, represented by output token. */ @@ -13,9 +20,7 @@ export type Rules = { maxSlippageBps: number /** * Ordered source permissions for each output token. */ - sources: Readonly< - Record - > + sources: Readonly> } /** @@ -52,7 +57,7 @@ export type Route = { token: Address.Address /** * Ordered source permissions. */ - sources: readonly FundingRequirement.Source[] + sources: readonly Source[] } /** @@ -279,8 +284,7 @@ export function fromTuple(value: Tuple): Authorization { !Array.isArray(routes) ) throw new InvalidPolicyError('Invalid policy rules tuple.') - const sources: Record = - {} + const sources: Record = {} let previous = -1n for (const route of routes) { if (!Array.isArray(route) || route.length !== 2 || !Array.isArray(route[1])) diff --git a/src/tempo/FundingRequirement.test-d.ts b/src/tempo/FundingRequirement.test-d.ts index 2692de4a1..da095f5b1 100644 --- a/src/tempo/FundingRequirement.test-d.ts +++ b/src/tempo/FundingRequirement.test-d.ts @@ -37,7 +37,7 @@ test('from', () => { amount: 50n, slippageBps: 0, sources: [ - { target: '0x0202020202020202020202020202020202020202', data: '0xab' }, + { to: '0x0202020202020202020202020202020202020202', data: '0xab' }, ], }) expectTypeOf(requirement.amount).toEqualTypeOf<50n>() diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index 16ad1b4e6..e09a35b4a 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -12,7 +12,7 @@ const target = '0x0202020202020202020202020202020202020202' as const const requirement = { token, amount: 50n, - sources: [{ target, data: '0xab' as const }], + sources: [{ to: target, data: '0xab' as const }], slippageBps: 100, } const envelope = TxEnvelopeTempo.from({ @@ -40,6 +40,12 @@ describe('toTuple', () => { }) describe('toRpc', () => { + test('maps to to the RPC target field', () => { + expect(FundingRequirement.toRpc(requirement).sources).toEqual([ + { target, data: '0xab' }, + ]) + }) + test('rejects unsafe numeric RPC inputs', () => { expect(() => FundingRequirement.toRpc({ @@ -107,10 +113,27 @@ describe('from', () => { }) }) +describe('fromRpc', () => { + test('maps the RPC target field to to', () => { + expect( + FundingRequirement.fromRpc({ + token, + amount: '0x32', + sources: [{ target, data: '0xab' }], + }), + ).toEqual({ token, amount: 50n, sources: [{ to: target, data: '0xab' }] }) + }) +}) + describe('behavior', () => { const rules = { maxSlippageBps: 100, - sources: { [token]: requirement.sources }, + sources: { + [token]: requirement.sources.map(({ to, data }) => ({ + target: to, + data, + })), + }, } test('every funding field is covered by sender and sponsor signatures', () => { @@ -119,8 +142,8 @@ describe('behavior', () => { { ...requirement, amount: 51n }, { ...requirement, slippageBps: 0 }, { ...requirement, policyRules: '0xab' as const }, - { ...requirement, sources: [{ target: token, data: '0xab' as const }] }, - { ...requirement, sources: [{ target, data: '0xcd' as const }] }, + { ...requirement, sources: [{ to: token, data: '0xab' as const }] }, + { ...requirement, sources: [{ to: target, data: '0xcd' as const }] }, ]) { const altered = { ...envelope, requireFunds: [changed] } expect(TxEnvelopeTempo.getSignPayload(altered)).not.toBe( diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index 0ec471946..88e328653 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -11,7 +11,7 @@ export type Source = { data: Hex.Hex /** * Funding source address. */ - target: Address.Address + to: Address.Address } /** @@ -36,7 +36,9 @@ export type FundingRequirement = { /** * RPC funding requirement. */ -export type Rpc = FundingRequirement +export type Rpc = Omit, 'sources'> & { + sources: readonly { data: Hex.Hex; target: Address.Address }[] +} /** * RLP funding requirement tuple. */ @@ -80,7 +82,7 @@ export function assert(value: FundingRequirement): void { ) throw new InvalidRequirementError('Policy rules must be nonempty bytes.') for (const source of value.sources) { - Address.assert(source.target, { strict: false }) + Address.assert(source.to, { strict: false }) if ( !Hex.validate(source.data, { strict: true }) || source.data.length % 2 !== 0 @@ -126,7 +128,7 @@ export function toTuple(value: FundingRequirement): Tuple { return [ value.token, value.amount === 0n ? '0x' : Hex.fromNumber(BigInt(value.amount)), - value.sources.map(({ target, data }) => [target, data] as const), + value.sources.map(({ to, data }) => [to, data] as const), value.slippageBps === undefined ? [] : [ @@ -176,7 +178,7 @@ export function fromTuple(value: Tuple): FundingRequirement { sources: sources.map((source) => { if (!Array.isArray(source) || source.length !== 2) throw new InvalidRequirementError('Expected source target and data.') - return { target: source[0], data: source[1] } + return { to: source[0], data: source[1] } }), ...(slippage.length ? { slippageBps: Number(decode(slippage[0]!)) } : {}), ...(policyRules !== undefined ? { policyRules } : {}), @@ -197,10 +199,11 @@ export function fromTuple(value: Tuple): FundingRequirement { * ``` */ export function fromRpc(value: Rpc): FundingRequirement { - const { amount, slippageBps, ...rest } = value + const { amount, slippageBps, sources, ...rest } = value const result: FundingRequirement = { ...rest, amount: BigInt(amount), + sources: sources.map(({ target, data }) => ({ to: target, data })), ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), } assert(result) @@ -227,10 +230,11 @@ export function toRpc( slippageBps: value.slippageBps === undefined ? undefined : Number(value.slippageBps), }) - const { amount, slippageBps, ...rest } = value + const { amount, slippageBps, sources, ...rest } = value return { ...rest, amount: Quantity.fromNumberish(amount), + sources: sources.map(({ to, data }) => ({ target: to, data })), ...(slippageBps === undefined ? {} : { slippageBps: Quantity.fromNumberish(slippageBps) }), diff --git a/src/tempo/Transaction.ts b/src/tempo/Transaction.ts index 0c0a4ad58..97c8ec3c0 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -107,8 +107,13 @@ export type Tempo< export type TempoRpc = Compute< Omit< Tempo, - 'authorizationList' | 'calls' | 'keyAuthorization' | 'signature' + | 'authorizationList' + | 'calls' + | 'keyAuthorization' + | 'requireFunds' + | 'signature' > & { + requireFunds?: readonly FundingRequirement.Rpc[] | undefined aaAuthorizationList?: AuthorizationTempo.ListRpc | undefined calls: | readonly { diff --git a/src/tempo/TransactionRequest.ts b/src/tempo/TransactionRequest.ts index 83ce2918d..f2522d7a7 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -57,11 +57,13 @@ export type TransactionRequest< export type Rpc = Omit< TransactionRequest, | 'authorizationList' + | 'requireFunds' | 'feeToken' | 'keyAuthorization' | 'multisigSimulation' | 'keyAuthorizationSimulation' > & { + requireFunds?: readonly FundingRequirement.Rpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feeToken?: Hex.Hex | undefined keyAuthorization?: KeyAuthorization.Rpc | undefined From 18ddbff72269e93650b2917397615bb871ac1290 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:17 +1000 Subject: [PATCH 05/27] refactor(tempo): rename dex funding source --- ...{NativeDexFunding.test.ts => DexFundingSource.test.ts} | 8 ++++---- src/tempo/{NativeDexFunding.ts => DexFundingSource.ts} | 8 ++++---- src/tempo/index.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) rename src/tempo/{NativeDexFunding.test.ts => DexFundingSource.test.ts} (63%) rename src/tempo/{NativeDexFunding.ts => DexFundingSource.ts} (84%) diff --git a/src/tempo/NativeDexFunding.test.ts b/src/tempo/DexFundingSource.test.ts similarity index 63% rename from src/tempo/NativeDexFunding.test.ts rename to src/tempo/DexFundingSource.test.ts index 719db57ac..9fb2ef96e 100644 --- a/src/tempo/NativeDexFunding.test.ts +++ b/src/tempo/DexFundingSource.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from 'vitest' -import * as NativeDexFunding from './NativeDexFunding.js' +import * as DexFundingSource from './DexFundingSource.js' const token = '0x0101010101010101010101010101010101010101' as const describe('encode', () => { test('preserves zero and defaults to unlimited input', () => { expect( - NativeDexFunding.decode(NativeDexFunding.encode({ tokenIn: token })), + DexFundingSource.decode(DexFundingSource.encode({ tokenIn: token })), ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) expect( - NativeDexFunding.decode( - NativeDexFunding.encode({ tokenIn: token, maxAmountIn: 0n }), + DexFundingSource.decode( + DexFundingSource.encode({ tokenIn: token, maxAmountIn: 0n }), ), ).toEqual({ tokenIn: token, maxAmountIn: 0n }) }) diff --git a/src/tempo/NativeDexFunding.ts b/src/tempo/DexFundingSource.ts similarity index 84% rename from src/tempo/NativeDexFunding.ts rename to src/tempo/DexFundingSource.ts index 6bb09591c..575091a09 100644 --- a/src/tempo/NativeDexFunding.ts +++ b/src/tempo/DexFundingSource.ts @@ -20,10 +20,10 @@ const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') * * @example * ```ts - * import { NativeDexFunding } from 'ox/tempo' + * import { DexFundingSource } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * NativeDexFunding.encode({ tokenIn: token, maxAmountIn: 30_000_000n }) + * DexFundingSource.encode({ tokenIn: token, maxAmountIn: 30_000_000n }) * ``` */ export function encode(request: Request): Hex.Hex { @@ -38,10 +38,10 @@ export function encode(request: Request): Hex.Hex { * * @example * ```ts - * import { NativeDexFunding } from 'ox/tempo' + * import { DexFundingSource } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * NativeDexFunding.decode(NativeDexFunding.encode({ tokenIn: token })) + * DexFundingSource.decode(DexFundingSource.encode({ tokenIn: token })) * ``` */ export function decode(data: Hex.Hex): Required { diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 4ab4265db..609b6c429 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -187,7 +187,7 @@ export * as MultisigSimulation from './MultisigSimulation.js' * * @category Reference */ -export * as NativeDexFunding from './NativeDexFunding.js' +export * as DexFundingSource from './DexFundingSource.js' /** * Utilities for constructing period durations (in seconds) for recurring spending limits. * From c4fb80bf275ab9d52a95e903ad2c4e3b778397a6 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:37 +1000 Subject: [PATCH 06/27] style(tempo): sort funding source export --- src/tempo/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 609b6c429..902692669 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -62,6 +62,12 @@ export * as AuthorizationTempo from './AuthorizationTempo.js' * @category Reference */ export * as Channel from './Channel.js' +/** + * Native DEX funding payload codecs. + * + * @category Reference + */ +export * as DexFundingSource from './DexFundingSource.js' /** * Tempo Earn `VaultAdapter` share math: raw vault-share and venue-share conversions * at the anchor rate, the dilution-correct fee-share formula, and the @@ -182,12 +188,6 @@ export * as MultisigOperation from './MultisigOperation.js' * @category Reference */ export * as MultisigSimulation from './MultisigSimulation.js' -/** - * Native DEX funding payload codecs. - * - * @category Reference - */ -export * as DexFundingSource from './DexFundingSource.js' /** * Utilities for constructing period durations (in seconds) for recurring spending limits. * From d67d87a7775a7f178db940df273b8d50c38b1ab1 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:00:23 +1000 Subject: [PATCH 07/27] feat: consolidate tempo funding source API --- src/tempo/DexFundingSource.test.ts | 16 ------ src/tempo/DexFundingSource.ts | 50 ------------------ src/tempo/FundingRequirement.ts | 14 +---- src/tempo/FundingSource.test-d.ts | 12 +++++ src/tempo/FundingSource.test.ts | 26 +++++++++ src/tempo/FundingSource.ts | 84 ++++++++++++++++++++++++++++++ src/tempo/index.ts | 12 ++--- 7 files changed, 130 insertions(+), 84 deletions(-) delete mode 100644 src/tempo/DexFundingSource.test.ts delete mode 100644 src/tempo/DexFundingSource.ts create mode 100644 src/tempo/FundingSource.test-d.ts create mode 100644 src/tempo/FundingSource.test.ts create mode 100644 src/tempo/FundingSource.ts diff --git a/src/tempo/DexFundingSource.test.ts b/src/tempo/DexFundingSource.test.ts deleted file mode 100644 index 9fb2ef96e..000000000 --- a/src/tempo/DexFundingSource.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from 'vitest' -import * as DexFundingSource from './DexFundingSource.js' - -const token = '0x0101010101010101010101010101010101010101' as const -describe('encode', () => { - test('preserves zero and defaults to unlimited input', () => { - expect( - DexFundingSource.decode(DexFundingSource.encode({ tokenIn: token })), - ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) - expect( - DexFundingSource.decode( - DexFundingSource.encode({ tokenIn: token, maxAmountIn: 0n }), - ), - ).toEqual({ tokenIn: token, maxAmountIn: 0n }) - }) -}) diff --git a/src/tempo/DexFundingSource.ts b/src/tempo/DexFundingSource.ts deleted file mode 100644 index 575091a09..000000000 --- a/src/tempo/DexFundingSource.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as AbiParameters from '../core/AbiParameters.js' -import type * as Address from '../core/Address.js' -import type * as Hex from '../core/Hex.js' - -/** - * Concrete native DEX funding request or policy entry. */ -export type Request = { - /** - * Input token to exchange for the required token. */ - tokenIn: Address.Address - /** - * Input cap in base units. Omission is unlimited; zero permits no input. */ - maxAmountIn?: bigint | undefined -} - -const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') - -/** - * Encodes a native DEX funding request or policy entry. - * - * @example - * ```ts - * import { DexFundingSource } from 'ox/tempo' - * - * const token = '0x20c0000000000000000000000000000000000001' as const - * DexFundingSource.encode({ tokenIn: token, maxAmountIn: 30_000_000n }) - * ``` - */ -export function encode(request: Request): Hex.Hex { - return AbiParameters.encode(parameters, [ - request.tokenIn, - request.maxAmountIn ?? 2n ** 256n - 1n, - ]) -} - -/** - * Decodes native DEX funding data, returning the concrete input cap. - * - * @example - * ```ts - * import { DexFundingSource } from 'ox/tempo' - * - * const token = '0x20c0000000000000000000000000000000000001' as const - * DexFundingSource.decode(DexFundingSource.encode({ tokenIn: token })) - * ``` - */ -export function decode(data: Hex.Hex): Required { - const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) - return { tokenIn, maxAmountIn } -} diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index 88e328653..86da478b8 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -2,17 +2,7 @@ import * as Address from '../core/Address.js' import * as Errors from '../core/Errors.js' import * as Hex from '../core/Hex.js' import * as Quantity from '../core/internal/quantity.js' - -/** - * A concrete call to a funding source. */ -export type Source = { - /** - * Source-specific ABI-encoded request. */ - data: Hex.Hex - /** - * Funding source address. */ - to: Address.Address -} +import type * as FundingSource from './FundingSource.js' /** * Target balance to satisfy before application calls. */ @@ -31,7 +21,7 @@ export type FundingRequirement = { slippageBps?: numberType | undefined /** * Sources attempted in order. */ - sources: readonly Source[] + sources: readonly FundingSource.Source[] } /** diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts new file mode 100644 index 000000000..644f80bc3 --- /dev/null +++ b/src/tempo/FundingSource.test-d.ts @@ -0,0 +1,12 @@ +import { expectTypeOf, test } from 'vitest' +import * as FundingSource from './FundingSource.js' + +test('dex', () => { + const source = FundingSource.dex({ + tokenIn: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.to, + ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() + expectTypeOf(source).toExtend() +}) diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts new file mode 100644 index 000000000..eebee9563 --- /dev/null +++ b/src/tempo/FundingSource.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest' +import * as FundingSource from './FundingSource.js' + +const token = '0x0101010101010101010101010101010101010101' as const + +describe('dex', () => { + test('creates a funding source with the native DEX address', () => { + expect(FundingSource.dex({ tokenIn: token, maxAmountIn: 30n })).toEqual({ + data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', + to: '0x1120000000000000000000000000000000000001', + }) + }) +}) + +describe('encodeDex', () => { + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSource.decodeDex(FundingSource.encodeDex({ tokenIn: token })), + ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + expect( + FundingSource.decodeDex( + FundingSource.encodeDex({ tokenIn: token, maxAmountIn: 0n }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 0n }) + }) +}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts new file mode 100644 index 000000000..13ea17322 --- /dev/null +++ b/src/tempo/FundingSource.ts @@ -0,0 +1,84 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import type * as Address from '../core/Address.js' +import type * as Hex from '../core/Hex.js' + +/** A source invoked to satisfy a funding requirement. */ +export type Source = { + /** Source-specific ABI-encoded request. */ + data: Hex.Hex + /** Funding source address. */ + to: Address.Address +} + +/** + * Concrete native DEX funding request or policy entry. */ +export type DexRequest = { + /** Input cap in base units. Omission is unlimited; zero permits no input. */ + maxAmountIn?: bigint | undefined + /** Input token to exchange for the required token. */ + tokenIn: Address.Address +} + +const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') +const nativeDexAddress = '0x1120000000000000000000000000000000000001' + +/** + * Creates a native DEX source for a funding requirement. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.dex({ + * tokenIn: '0x20c0000000000000000000000000000000000000', + * maxAmountIn: 30_000_000n, + * }) + * ``` + */ +export function dex(request: DexRequest) { + return { + data: encodeDex(request), + to: nativeDexAddress, + } satisfies Source +} + +/** + * Encodes a native DEX funding request or policy entry. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingSource.encodeDex({ + * tokenIn: token, + * maxAmountIn: 30_000_000n + * }) + * ``` + */ +export function encodeDex(request: DexRequest): Hex.Hex { + return AbiParameters.encode(parameters, [ + request.tokenIn, + request.maxAmountIn ?? 2n ** 256n - 1n, + ]) +} + +/** + * Decodes native DEX funding data, returning the concrete input cap. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingSource.decodeDex( + * FundingSource.encodeDex({ tokenIn: token }) + * ) + * ``` + */ +export function decodeDex(data: Hex.Hex): Required { + const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) + return { tokenIn, maxAmountIn } +} diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 902692669..8d65edec9 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -62,12 +62,6 @@ export * as AuthorizationTempo from './AuthorizationTempo.js' * @category Reference */ export * as Channel from './Channel.js' -/** - * Native DEX funding payload codecs. - * - * @category Reference - */ -export * as DexFundingSource from './DexFundingSource.js' /** * Tempo Earn `VaultAdapter` share math: raw vault-share and venue-share conversions * at the anchor rate, the dilution-correct fee-share formula, and the @@ -102,6 +96,12 @@ export * as FundingPolicy from './FundingPolicy.js' * @category Reference */ export * as FundingRequirement from './FundingRequirement.js' +/** + * Funding source shapes and native DEX payload codecs. + * + * @category Reference + */ +export * as FundingSource from './FundingSource.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * From 0b6162ea450f058b6b64a8343dc8c59f39a8c28e Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:50 +1000 Subject: [PATCH 08/27] refactor: split tempo dex funding source --- src/tempo/FundingSource.test.ts | 26 ------- src/tempo/FundingSource.ts | 74 ------------------ ...e.test-d.ts => FundingSourceDex.test-d.ts} | 7 +- src/tempo/FundingSourceDex.test.ts | 28 +++++++ src/tempo/FundingSourceDex.ts | 77 +++++++++++++++++++ src/tempo/index.ts | 8 +- 6 files changed, 116 insertions(+), 104 deletions(-) delete mode 100644 src/tempo/FundingSource.test.ts rename src/tempo/{FundingSource.test-d.ts => FundingSourceDex.test-d.ts} (59%) create mode 100644 src/tempo/FundingSourceDex.test.ts create mode 100644 src/tempo/FundingSourceDex.ts diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts deleted file mode 100644 index eebee9563..000000000 --- a/src/tempo/FundingSource.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from 'vitest' -import * as FundingSource from './FundingSource.js' - -const token = '0x0101010101010101010101010101010101010101' as const - -describe('dex', () => { - test('creates a funding source with the native DEX address', () => { - expect(FundingSource.dex({ tokenIn: token, maxAmountIn: 30n })).toEqual({ - data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', - to: '0x1120000000000000000000000000000000000001', - }) - }) -}) - -describe('encodeDex', () => { - test('preserves zero and defaults to unlimited input', () => { - expect( - FundingSource.decodeDex(FundingSource.encodeDex({ tokenIn: token })), - ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) - expect( - FundingSource.decodeDex( - FundingSource.encodeDex({ tokenIn: token, maxAmountIn: 0n }), - ), - ).toEqual({ tokenIn: token, maxAmountIn: 0n }) - }) -}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts index 13ea17322..c7a92a50a 100644 --- a/src/tempo/FundingSource.ts +++ b/src/tempo/FundingSource.ts @@ -1,4 +1,3 @@ -import * as AbiParameters from '../core/AbiParameters.js' import type * as Address from '../core/Address.js' import type * as Hex from '../core/Hex.js' @@ -9,76 +8,3 @@ export type Source = { /** Funding source address. */ to: Address.Address } - -/** - * Concrete native DEX funding request or policy entry. */ -export type DexRequest = { - /** Input cap in base units. Omission is unlimited; zero permits no input. */ - maxAmountIn?: bigint | undefined - /** Input token to exchange for the required token. */ - tokenIn: Address.Address -} - -const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') -const nativeDexAddress = '0x1120000000000000000000000000000000000001' - -/** - * Creates a native DEX source for a funding requirement. - * - * @example - * ```ts - * import { FundingSource } from 'ox/tempo' - * - * FundingSource.dex({ - * tokenIn: '0x20c0000000000000000000000000000000000000', - * maxAmountIn: 30_000_000n, - * }) - * ``` - */ -export function dex(request: DexRequest) { - return { - data: encodeDex(request), - to: nativeDexAddress, - } satisfies Source -} - -/** - * Encodes a native DEX funding request or policy entry. - * - * @example - * ```ts - * import { FundingSource } from 'ox/tempo' - * - * const token = - * '0x20c0000000000000000000000000000000000001' as const - * FundingSource.encodeDex({ - * tokenIn: token, - * maxAmountIn: 30_000_000n - * }) - * ``` - */ -export function encodeDex(request: DexRequest): Hex.Hex { - return AbiParameters.encode(parameters, [ - request.tokenIn, - request.maxAmountIn ?? 2n ** 256n - 1n, - ]) -} - -/** - * Decodes native DEX funding data, returning the concrete input cap. - * - * @example - * ```ts - * import { FundingSource } from 'ox/tempo' - * - * const token = - * '0x20c0000000000000000000000000000000000001' as const - * FundingSource.decodeDex( - * FundingSource.encodeDex({ tokenIn: token }) - * ) - * ``` - */ -export function decodeDex(data: Hex.Hex): Required { - const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) - return { tokenIn, maxAmountIn } -} diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSourceDex.test-d.ts similarity index 59% rename from src/tempo/FundingSource.test-d.ts rename to src/tempo/FundingSourceDex.test-d.ts index 644f80bc3..1795719be 100644 --- a/src/tempo/FundingSource.test-d.ts +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -1,8 +1,9 @@ import { expectTypeOf, test } from 'vitest' -import * as FundingSource from './FundingSource.js' +import type * as FundingSource from './FundingSource.js' +import * as FundingSourceDex from './FundingSourceDex.js' -test('dex', () => { - const source = FundingSource.dex({ +test('from', () => { + const source = FundingSourceDex.from({ tokenIn: '0x0101010101010101010101010101010101010101', }) expectTypeOf( diff --git a/src/tempo/FundingSourceDex.test.ts b/src/tempo/FundingSourceDex.test.ts new file mode 100644 index 000000000..f78ae716c --- /dev/null +++ b/src/tempo/FundingSourceDex.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest' +import * as FundingSourceDex from './FundingSourceDex.js' + +const token = '0x0101010101010101010101010101010101010101' as const + +describe('from', () => { + test('creates a funding source with the native DEX address', () => { + expect(FundingSourceDex.from({ tokenIn: token, maxAmountIn: 30n })).toEqual( + { + data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', + to: '0x1120000000000000000000000000000000000001', + }, + ) + }) +}) + +describe('encode', () => { + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSourceDex.decode(FundingSourceDex.encode({ tokenIn: token })), + ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + expect( + FundingSourceDex.decode( + FundingSourceDex.encode({ tokenIn: token, maxAmountIn: 0n }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 0n }) + }) +}) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts new file mode 100644 index 000000000..d26bb1525 --- /dev/null +++ b/src/tempo/FundingSourceDex.ts @@ -0,0 +1,77 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import type * as Address from '../core/Address.js' +import type * as Hex from '../core/Hex.js' +import type * as FundingSource from './FundingSource.js' + +/** + * Concrete native DEX funding request or policy entry. */ +export type Request = { + /** Input cap in base units. Omission is unlimited; zero permits no input. */ + maxAmountIn?: bigint | undefined + /** Input token to exchange for the required token. */ + tokenIn: Address.Address +} + +const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') +const nativeDexAddress = '0x1120000000000000000000000000000000000001' + +/** + * Creates a native DEX source for a funding requirement. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.from({ + * tokenIn: '0x20c0000000000000000000000000000000000000', + * maxAmountIn: 30_000_000n, + * }) + * ``` + */ +export function from(request: Request) { + return { + data: encode(request), + to: nativeDexAddress, + } satisfies FundingSource.Source +} + +/** + * Encodes a native DEX funding request or policy entry. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingSourceDex.encode({ + * tokenIn: token, + * maxAmountIn: 30_000_000n + * }) + * ``` + */ +export function encode(request: Request): Hex.Hex { + return AbiParameters.encode(parameters, [ + request.tokenIn, + request.maxAmountIn ?? 2n ** 256n - 1n, + ]) +} + +/** + * Decodes native DEX funding data, returning the concrete input cap. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingSourceDex.decode( + * FundingSourceDex.encode({ tokenIn: token }) + * ) + * ``` + */ +export function decode(data: Hex.Hex): Required { + const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) + return { tokenIn, maxAmountIn } +} diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 8d65edec9..cae252542 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -97,11 +97,17 @@ export * as FundingPolicy from './FundingPolicy.js' */ export * as FundingRequirement from './FundingRequirement.js' /** - * Funding source shapes and native DEX payload codecs. + * Funding source shapes. * * @category Reference */ export * as FundingSource from './FundingSource.js' +/** + * Native DEX funding source constructors and payload codecs. + * + * @category Reference + */ +export * as FundingSourceDex from './FundingSourceDex.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * From 5e5799ffaaf4fce5e5dadb9118aa93a06b77c7f4 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:14:29 +1000 Subject: [PATCH 09/27] docs: format dex funding source examples --- src/tempo/FundingSourceDex.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts index d26bb1525..5a6e14362 100644 --- a/src/tempo/FundingSourceDex.ts +++ b/src/tempo/FundingSourceDex.ts @@ -23,8 +23,8 @@ const nativeDexAddress = '0x1120000000000000000000000000000000000001' * import { FundingSourceDex } from 'ox/tempo' * * FundingSourceDex.from({ - * tokenIn: '0x20c0000000000000000000000000000000000000', * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000000' * }) * ``` */ @@ -45,8 +45,8 @@ export function from(request: Request) { * const token = * '0x20c0000000000000000000000000000000000001' as const * FundingSourceDex.encode({ - * tokenIn: token, - * maxAmountIn: 30_000_000n + * maxAmountIn: 30_000_000n, + * tokenIn: token * }) * ``` */ From 4c9c1f520d48347306260600c2701d4470c11fc5 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:16:25 +1000 Subject: [PATCH 10/27] refactor: name dex funding data encoder --- src/tempo/FundingSourceDex.test.ts | 6 +++--- src/tempo/FundingSourceDex.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tempo/FundingSourceDex.test.ts b/src/tempo/FundingSourceDex.test.ts index f78ae716c..cdbe61088 100644 --- a/src/tempo/FundingSourceDex.test.ts +++ b/src/tempo/FundingSourceDex.test.ts @@ -14,14 +14,14 @@ describe('from', () => { }) }) -describe('encode', () => { +describe('encodeData', () => { test('preserves zero and defaults to unlimited input', () => { expect( - FundingSourceDex.decode(FundingSourceDex.encode({ tokenIn: token })), + FundingSourceDex.decode(FundingSourceDex.encodeData({ tokenIn: token })), ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) expect( FundingSourceDex.decode( - FundingSourceDex.encode({ tokenIn: token, maxAmountIn: 0n }), + FundingSourceDex.encodeData({ tokenIn: token, maxAmountIn: 0n }), ), ).toEqual({ tokenIn: token, maxAmountIn: 0n }) }) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts index 5a6e14362..aeaf07dd7 100644 --- a/src/tempo/FundingSourceDex.ts +++ b/src/tempo/FundingSourceDex.ts @@ -30,7 +30,7 @@ const nativeDexAddress = '0x1120000000000000000000000000000000000001' */ export function from(request: Request) { return { - data: encode(request), + data: encodeData(request), to: nativeDexAddress, } satisfies FundingSource.Source } @@ -44,13 +44,13 @@ export function from(request: Request) { * * const token = * '0x20c0000000000000000000000000000000000001' as const - * FundingSourceDex.encode({ + * FundingSourceDex.encodeData({ * maxAmountIn: 30_000_000n, * tokenIn: token * }) * ``` */ -export function encode(request: Request): Hex.Hex { +export function encodeData(request: Request): Hex.Hex { return AbiParameters.encode(parameters, [ request.tokenIn, request.maxAmountIn ?? 2n ** 256n - 1n, @@ -67,7 +67,7 @@ export function encode(request: Request): Hex.Hex { * const token = * '0x20c0000000000000000000000000000000000001' as const * FundingSourceDex.decode( - * FundingSourceDex.encode({ tokenIn: token }) + * FundingSourceDex.encodeData({ tokenIn: token }) * ) * ``` */ From 7ef35235b949ef361da31885259e7388c4598d60 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:26:26 +1000 Subject: [PATCH 11/27] feat: expose native dex source on FundingSource --- ...eDex.test-d.ts => FundingSource.test-d.ts} | 7 +- src/tempo/FundingSource.test.ts | 26 +++++++ src/tempo/FundingSource.ts | 71 +++++++++++++++++ src/tempo/FundingSourceDex.test.ts | 28 ------- src/tempo/FundingSourceDex.ts | 77 ------------------- src/tempo/index.ts | 8 +- 6 files changed, 101 insertions(+), 116 deletions(-) rename src/tempo/{FundingSourceDex.test-d.ts => FundingSource.test-d.ts} (59%) create mode 100644 src/tempo/FundingSource.test.ts delete mode 100644 src/tempo/FundingSourceDex.test.ts delete mode 100644 src/tempo/FundingSourceDex.ts diff --git a/src/tempo/FundingSourceDex.test-d.ts b/src/tempo/FundingSource.test-d.ts similarity index 59% rename from src/tempo/FundingSourceDex.test-d.ts rename to src/tempo/FundingSource.test-d.ts index 1795719be..644f80bc3 100644 --- a/src/tempo/FundingSourceDex.test-d.ts +++ b/src/tempo/FundingSource.test-d.ts @@ -1,9 +1,8 @@ import { expectTypeOf, test } from 'vitest' -import type * as FundingSource from './FundingSource.js' -import * as FundingSourceDex from './FundingSourceDex.js' +import * as FundingSource from './FundingSource.js' -test('from', () => { - const source = FundingSourceDex.from({ +test('dex', () => { + const source = FundingSource.dex({ tokenIn: '0x0101010101010101010101010101010101010101', }) expectTypeOf( diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts new file mode 100644 index 000000000..c549fb43d --- /dev/null +++ b/src/tempo/FundingSource.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest' +import * as FundingSource from './FundingSource.js' + +const token = '0x0101010101010101010101010101010101010101' as const + +describe('dex', () => { + test('creates a funding source with the native DEX address', () => { + expect(FundingSource.dex({ tokenIn: token, maxAmountIn: 30n })).toEqual({ + data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', + to: '0x1120000000000000000000000000000000000001', + }) + }) +}) + +describe('encodeData', () => { + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSource.decode(FundingSource.encodeData({ tokenIn: token })), + ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + expect( + FundingSource.decode( + FundingSource.encodeData({ tokenIn: token, maxAmountIn: 0n }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 0n }) + }) +}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts index c7a92a50a..77927f219 100644 --- a/src/tempo/FundingSource.ts +++ b/src/tempo/FundingSource.ts @@ -1,3 +1,4 @@ +import * as AbiParameters from '../core/AbiParameters.js' import type * as Address from '../core/Address.js' import type * as Hex from '../core/Hex.js' @@ -8,3 +9,73 @@ export type Source = { /** Funding source address. */ to: Address.Address } + +/** Native DEX funding request or policy entry. */ +export type DexRequest = { + /** Input cap in base units. Omission is unlimited; zero permits no input. */ + maxAmountIn?: bigint | undefined + /** Input token to exchange for the required token. */ + tokenIn: Address.Address +} + +const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') +const nativeDexAddress = '0x1120000000000000000000000000000000000001' + +/** + * Creates a native DEX source for a funding requirement. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.dex({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000000' + * }) + * ``` + */ +export function dex(request: DexRequest) { + return { + data: encodeData(request), + to: nativeDexAddress, + } satisfies Source +} + +/** + * Encodes a native DEX funding request or policy entry. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.encodeData({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ``` + */ +export function encodeData(request: DexRequest): Hex.Hex { + return AbiParameters.encode(parameters, [ + request.tokenIn, + request.maxAmountIn ?? 2n ** 256n - 1n, + ]) +} + +/** + * Decodes native DEX funding data, returning the concrete input cap. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.decode( + * FundingSource.encodeData({ + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ) + * ``` + */ +export function decode(data: Hex.Hex): Required { + const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) + return { tokenIn, maxAmountIn } +} diff --git a/src/tempo/FundingSourceDex.test.ts b/src/tempo/FundingSourceDex.test.ts deleted file mode 100644 index cdbe61088..000000000 --- a/src/tempo/FundingSourceDex.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'vitest' -import * as FundingSourceDex from './FundingSourceDex.js' - -const token = '0x0101010101010101010101010101010101010101' as const - -describe('from', () => { - test('creates a funding source with the native DEX address', () => { - expect(FundingSourceDex.from({ tokenIn: token, maxAmountIn: 30n })).toEqual( - { - data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', - to: '0x1120000000000000000000000000000000000001', - }, - ) - }) -}) - -describe('encodeData', () => { - test('preserves zero and defaults to unlimited input', () => { - expect( - FundingSourceDex.decode(FundingSourceDex.encodeData({ tokenIn: token })), - ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) - expect( - FundingSourceDex.decode( - FundingSourceDex.encodeData({ tokenIn: token, maxAmountIn: 0n }), - ), - ).toEqual({ tokenIn: token, maxAmountIn: 0n }) - }) -}) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts deleted file mode 100644 index aeaf07dd7..000000000 --- a/src/tempo/FundingSourceDex.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as AbiParameters from '../core/AbiParameters.js' -import type * as Address from '../core/Address.js' -import type * as Hex from '../core/Hex.js' -import type * as FundingSource from './FundingSource.js' - -/** - * Concrete native DEX funding request or policy entry. */ -export type Request = { - /** Input cap in base units. Omission is unlimited; zero permits no input. */ - maxAmountIn?: bigint | undefined - /** Input token to exchange for the required token. */ - tokenIn: Address.Address -} - -const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') -const nativeDexAddress = '0x1120000000000000000000000000000000000001' - -/** - * Creates a native DEX source for a funding requirement. - * - * @example - * ```ts - * import { FundingSourceDex } from 'ox/tempo' - * - * FundingSourceDex.from({ - * maxAmountIn: 30_000_000n, - * tokenIn: '0x20c0000000000000000000000000000000000000' - * }) - * ``` - */ -export function from(request: Request) { - return { - data: encodeData(request), - to: nativeDexAddress, - } satisfies FundingSource.Source -} - -/** - * Encodes a native DEX funding request or policy entry. - * - * @example - * ```ts - * import { FundingSourceDex } from 'ox/tempo' - * - * const token = - * '0x20c0000000000000000000000000000000000001' as const - * FundingSourceDex.encodeData({ - * maxAmountIn: 30_000_000n, - * tokenIn: token - * }) - * ``` - */ -export function encodeData(request: Request): Hex.Hex { - return AbiParameters.encode(parameters, [ - request.tokenIn, - request.maxAmountIn ?? 2n ** 256n - 1n, - ]) -} - -/** - * Decodes native DEX funding data, returning the concrete input cap. - * - * @example - * ```ts - * import { FundingSourceDex } from 'ox/tempo' - * - * const token = - * '0x20c0000000000000000000000000000000000001' as const - * FundingSourceDex.decode( - * FundingSourceDex.encodeData({ tokenIn: token }) - * ) - * ``` - */ -export function decode(data: Hex.Hex): Required { - const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) - return { tokenIn, maxAmountIn } -} diff --git a/src/tempo/index.ts b/src/tempo/index.ts index cae252542..d1a595f3f 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -97,17 +97,11 @@ export * as FundingPolicy from './FundingPolicy.js' */ export * as FundingRequirement from './FundingRequirement.js' /** - * Funding source shapes. + * Funding source constructors and native DEX payload codecs. * * @category Reference */ export * as FundingSource from './FundingSource.js' -/** - * Native DEX funding source constructors and payload codecs. - * - * @category Reference - */ -export * as FundingSourceDex from './FundingSourceDex.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * From e35669b9fdcd4498c36fb6a312dd8597903f1873 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:15:35 +1000 Subject: [PATCH 12/27] fix(tempo): use to for funding policy sources --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingPolicy.test.ts | 52 ++++++++++++++++++- src/tempo/FundingPolicy.ts | 78 ++++++++++++++++++++++------ src/tempo/FundingRequirement.test.ts | 5 +- src/tempo/KeyAuthorization.ts | 2 +- 5 files changed, 115 insertions(+), 24 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 5b0a46e20..977024b0b 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding transaction codecs, committed policy rules, native DEX funding payloads, and access key funding authorization. +Added Tempo funding codecs, policy rules with `to` source addresses, native DEX funding payloads, and access key funding authorization. diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index aff96e088..3ab74ec4e 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -6,7 +6,7 @@ const target = '0x0202020202020202020202020202020202020202' as const const requirement = { token, amount: 50n, - sources: [{ target, data: '0xab' as const }], + sources: [{ to: target, data: '0xab' as const }], slippageBps: 100, } @@ -36,7 +36,10 @@ describe('hash', () => { sources: { [token]: requirement.sources, [second]: requirement.sources }, } expect(FundingPolicy.encode(first)).toBe(FundingPolicy.encode(reordered)) - const sources = [...requirement.sources, { target, data: '0xcd' as const }] + const sources = [ + ...requirement.sources, + { to: target, data: '0xcd' as const }, + ] expect( FundingPolicy.hash({ ...rules, sources: { [token]: sources } }), ).not.toBe( @@ -59,3 +62,48 @@ describe('toTuple', () => { expect(() => FundingPolicy.toTuple(value)).toThrow() }) }) + +describe('toRoutes', () => { + test('maps source addresses to the contract ABI field', () => { + expect(FundingPolicy.toRoutes(rules)).toEqual([ + { token, sources: [{ target, data: '0xab' }] }, + ]) + }) +}) + +describe('toRpc', () => { + test('keeps the RPC target field', () => { + expect(FundingPolicy.toRpc({ admins: [token], rules })).toEqual({ + admins: [token], + rules: { + maxSlippageBps: 100, + sources: { [token]: [{ target, data: '0xab' }] }, + }, + }) + }) +}) + +describe('fromRpc', () => { + test('maps RPC sources to to', () => { + expect( + FundingPolicy.fromRpc({ + admins: [token], + rules: { + maxSlippageBps: 100, + sources: { [token]: [{ target, data: '0xab' }] }, + }, + }), + ).toEqual({ admins: [token], rules }) + }) +}) + +describe('fromTuple', () => { + test('decodes source addresses as to', () => { + expect( + FundingPolicy.fromTuple([ + [token], + ['0x64', [[token, [[target, '0xab']]]]], + ]), + ).toEqual({ admins: [token], rules }) + }) +}) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index 1bc832740..156237b8b 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -9,7 +9,7 @@ export type Source = { /** Source-specific policy data. */ data: Hex.Hex /** Funding source address. */ - target: Address.Address + to: Address.Address } /** @@ -38,6 +38,21 @@ export type Inline = { * Policy authorization: an existing nonzero uint64 ID or inline creation. */ export type Authorization = bigintType | Inline +/** Policy authorization in RPC form. */ +export type Rpc = + | Hex.Hex + | { + /** Accounts authorized to modify the policy. */ + admins: readonly Address.Address[] + /** Committed funding permissions. */ + rules: { + /** Maximum aggregate slippage in basis points. */ + maxSlippageBps: number + /** Ordered source permissions for each output token. */ + sources: Readonly> + } + } + /** * Policy state returned by the precompile; rules are available in events. */ export type Policy = { @@ -57,7 +72,12 @@ export type Route = { token: Address.Address /** * Ordered source permissions. */ - sources: readonly Source[] + sources: readonly { + /** Funding source address in the contract ABI. */ + target: Address.Address + /** Source-specific policy data. */ + data: Hex.Hex + }[] } /** @@ -102,7 +122,7 @@ const parameters = [ const domain = Hash.keccak256(Hex.fromString('tempo.funding-policy.rules.v1')) /** - * Converts a token map to canonical ascending routes without reordering sources. + * Converts a token map to canonical ABI routes, mapping `to` to `target` without reordering sources. * * @example * ```ts @@ -127,16 +147,19 @@ export function toRoutes(rules: Rules): readonly Route[] { if (BigInt(token) === 0n || seen.has(token.toLowerCase())) throw new InvalidPolicyError('Output tokens must be unique and nonzero.') seen.add(token.toLowerCase()) - for (const { target, data } of sources) { - Address.assert(target, { strict: false }) + for (const { to, data } of sources) { + Address.assert(to, { strict: false }) if ( - BigInt(target) === 0n || + BigInt(to) === 0n || !Hex.validate(data, { strict: true }) || data.length % 2 !== 0 ) - throw new InvalidPolicyError('Invalid source target or data.') + throw new InvalidPolicyError('Invalid source address or data.') + } + return { + token, + sources: sources.map(({ to, data }) => ({ target: to, data })), } - return { token, sources } }) return routes.sort((a, b) => (BigInt(a.token) < BigInt(b.token) ? -1 : 1)) } @@ -174,7 +197,10 @@ export function decode(data: Hex.Hex): Rules { const rules: Rules = { maxSlippageBps: value.maxSlippageBps, sources: Object.fromEntries( - value.routes.map(({ token, sources }) => [token, sources]), + value.routes.map(({ token, sources }) => [ + token, + sources.map(({ target, data }) => ({ to: target, data })), + ]), ), } if (encode(rules).toLowerCase() !== data.toLowerCase()) @@ -297,7 +323,7 @@ export function fromTuple(value: Tuple): Authorization { sources[token] = entries.map((source) => { if (!Array.isArray(source) || source.length !== 2) throw new InvalidPolicyError('Invalid source tuple.') - return { target: source[0], data: source[1] } + return { to: source[0], data: source[1] } }) } const policy = { @@ -321,8 +347,22 @@ export function fromTuple(value: Tuple): Authorization { * FundingPolicy.fromRpc('0x7') * ``` */ -export function fromRpc(value: Authorization): Authorization { - const result = typeof value === 'string' ? BigInt(value) : value +export function fromRpc(value: Rpc): Authorization { + const result = + typeof value === 'string' + ? BigInt(value) + : { + ...value, + rules: { + ...value.rules, + sources: Object.fromEntries( + Object.entries(value.rules.sources).map(([token, sources]) => [ + token, + sources.map(({ target, data }) => ({ to: target, data })), + ]), + ), + }, + } toTuple(result) return result } @@ -337,9 +377,7 @@ export function fromRpc(value: Authorization): Authorization { * FundingPolicy.toRpc(7n) * ``` */ -export function toRpc( - value: Authorization, -): Authorization { +export function toRpc(value: Authorization): Rpc { if (typeof value !== 'object') { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new InvalidPolicyError('Numeric policy IDs must be safe integers.') @@ -348,7 +386,15 @@ export function toRpc( return Hex.fromNumber(id) } toTuple(value) - return value + return { + ...value, + rules: { + ...value.rules, + sources: Object.fromEntries( + toRoutes(value.rules).map(({ token, sources }) => [token, sources]), + ), + }, + } } /** diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index e09a35b4a..44ba836e0 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -129,10 +129,7 @@ describe('behavior', () => { const rules = { maxSlippageBps: 100, sources: { - [token]: requirement.sources.map(({ to, data }) => ({ - target: to, - data, - })), + [token]: requirement.sources, }, } diff --git a/src/tempo/KeyAuthorization.ts b/src/tempo/KeyAuthorization.ts index 01f74302d..b41e0ec02 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -126,7 +126,7 @@ export type Rpc = { /** Expiry timestamp (hex quantity or null). */ expiry: Hex.Hex | null | undefined /** Existing policy ID or inline creation. */ - fundingPolicy?: FundingPolicy.Authorization | undefined + fundingPolicy?: FundingPolicy.Rpc | undefined /** Whether this authorization provisions an admin access key (TIP-1049). */ isAdmin?: boolean | null | undefined /** Key identifier. */ From 99ee99139ebf5f78c6061389851b1804557a5eb8 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:43:14 +1000 Subject: [PATCH 13/27] feat(tempo): distinguish funding payload encoders --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingPolicy.ts | 6 ++--- src/tempo/FundingSource.test-d.ts | 16 +++++++++++++ src/tempo/FundingSource.test.ts | 29 ++++++++++++++++++++--- src/tempo/FundingSource.ts | 39 +++++++++++++++++++++++-------- 5 files changed, 75 insertions(+), 17 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 977024b0b..e62347729 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules with `to` source addresses, native DEX funding payloads, and access key funding authorization. +Added Tempo funding codecs, policy rules with `to` source addresses, native DEX execution and configuration encoders, and access key funding authorization. diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index 156237b8b..c57eea7c6 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -4,9 +4,9 @@ import * as Errors from '../core/Errors.js' import * as Hash from '../core/Hash.js' import * as Hex from '../core/Hex.js' -/** An approved funding source and its policy data. */ +/** An approved funding source and its configuration data. */ export type Source = { - /** Source-specific policy data. */ + /** Source-specific configuration passed to funding hooks as `configData`. */ data: Hex.Hex /** Funding source address. */ to: Address.Address @@ -75,7 +75,7 @@ export type Route = { sources: readonly { /** Funding source address in the contract ABI. */ target: Address.Address - /** Source-specific policy data. */ + /** Source-specific configuration passed to funding hooks as `configData`. */ data: Hex.Hex }[] } diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts index 644f80bc3..45e9fb590 100644 --- a/src/tempo/FundingSource.test-d.ts +++ b/src/tempo/FundingSource.test-d.ts @@ -10,3 +10,19 @@ test('dex', () => { ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() expectTypeOf(source).toExtend() }) + +test('encodeExecutionData', () => { + expectTypeOf( + FundingSource.encodeExecutionData({ + tokenIn: '0x0101010101010101010101010101010101010101', + }), + ).toEqualTypeOf<`0x${string}`>() +}) + +test('encodeConfigData', () => { + expectTypeOf( + FundingSource.encodeConfigData({ + tokenIn: '0x0101010101010101010101010101010101010101', + }), + ).toEqualTypeOf<`0x${string}`>() +}) diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts index c549fb43d..4b973ca52 100644 --- a/src/tempo/FundingSource.test.ts +++ b/src/tempo/FundingSource.test.ts @@ -12,15 +12,38 @@ describe('dex', () => { }) }) -describe('encodeData', () => { +describe('encodeExecutionData', () => { test('preserves zero and defaults to unlimited input', () => { expect( - FundingSource.decode(FundingSource.encodeData({ tokenIn: token })), + FundingSource.decode( + FundingSource.encodeExecutionData({ tokenIn: token }), + ), ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) expect( FundingSource.decode( - FundingSource.encodeData({ tokenIn: token, maxAmountIn: 0n }), + FundingSource.encodeExecutionData({ tokenIn: token, maxAmountIn: 0n }), ), ).toEqual({ tokenIn: token, maxAmountIn: 0n }) }) }) + +describe('encodeConfigData', () => { + test('encodes an approved input and cap', () => { + expect( + FundingSource.encodeConfigData({ maxAmountIn: 30n, tokenIn: token }), + ).toBe( + '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', + ) + }) + + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSource.decode(FundingSource.encodeConfigData({ tokenIn: token })), + ).toEqual({ maxAmountIn: 2n ** 256n - 1n, tokenIn: token }) + expect( + FundingSource.decode( + FundingSource.encodeConfigData({ maxAmountIn: 0n, tokenIn: token }), + ), + ).toEqual({ maxAmountIn: 0n, tokenIn: token }) + }) +}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts index 77927f219..f45243ac5 100644 --- a/src/tempo/FundingSource.ts +++ b/src/tempo/FundingSource.ts @@ -2,15 +2,15 @@ import * as AbiParameters from '../core/AbiParameters.js' import type * as Address from '../core/Address.js' import type * as Hex from '../core/Hex.js' -/** A source invoked to satisfy a funding requirement. */ +/** A funding source with execution or configuration data. */ export type Source = { - /** Source-specific ABI-encoded request. */ + /** ABI-encoded execution data for funding, or configuration data for policies and discovery. */ data: Hex.Hex /** Funding source address. */ to: Address.Address } -/** Native DEX funding request or policy entry. */ +/** Native DEX execution arguments or source configuration. */ export type DexRequest = { /** Input cap in base units. Omission is unlimited; zero permits no input. */ maxAmountIn?: bigint | undefined @@ -22,7 +22,7 @@ const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') const nativeDexAddress = '0x1120000000000000000000000000000000000001' /** - * Creates a native DEX source for a funding requirement. + * Creates a native DEX source for funding, policy rules, or discovery. * * @example * ```ts @@ -36,25 +36,25 @@ const nativeDexAddress = '0x1120000000000000000000000000000000000001' */ export function dex(request: DexRequest) { return { - data: encodeData(request), + data: encodeExecutionData(request), to: nativeDexAddress, } satisfies Source } /** - * Encodes a native DEX funding request or policy entry. + * Encodes native DEX execution arguments passed to funding hooks as `executionData`. * * @example * ```ts * import { FundingSource } from 'ox/tempo' * - * FundingSource.encodeData({ + * FundingSource.encodeExecutionData({ * maxAmountIn: 30_000_000n, * tokenIn: '0x20c0000000000000000000000000000000000001' * }) * ``` */ -export function encodeData(request: DexRequest): Hex.Hex { +export function encodeExecutionData(request: DexRequest): Hex.Hex { return AbiParameters.encode(parameters, [ request.tokenIn, request.maxAmountIn ?? 2n ** 256n - 1n, @@ -62,14 +62,33 @@ export function encodeData(request: DexRequest): Hex.Hex { } /** - * Decodes native DEX funding data, returning the concrete input cap. + * Encodes native DEX source configuration passed to funding hooks as `configData`. + * + * The DEX uses the same encoding for execution and configuration data. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.encodeConfigData({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ``` + */ +export function encodeConfigData(config: DexRequest): Hex.Hex { + return encodeExecutionData(config) +} + +/** + * Decodes native DEX execution or configuration data, returning the concrete input cap. * * @example * ```ts * import { FundingSource } from 'ox/tempo' * * FundingSource.decode( - * FundingSource.encodeData({ + * FundingSource.encodeExecutionData({ * tokenIn: '0x20c0000000000000000000000000000000000001' * }) * ) From 4eb6793a43ecb9e0e6ab4ca6a1cc84c576faddb4 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:47:17 +1000 Subject: [PATCH 14/27] refactor(tempo): restore dex funding source module --- src/tempo/FundingSource.test-d.ts | 16 ----- src/tempo/FundingSource.test.ts | 36 ----------- src/tempo/FundingSource.ts | 78 +---------------------- src/tempo/FundingSourceDex.test-d.ts | 29 +++++++++ src/tempo/FundingSourceDex.test.ts | 56 +++++++++++++++++ src/tempo/FundingSourceDex.ts | 93 ++++++++++++++++++++++++++++ src/tempo/index.ts | 8 +++ 7 files changed, 188 insertions(+), 128 deletions(-) create mode 100644 src/tempo/FundingSourceDex.test-d.ts create mode 100644 src/tempo/FundingSourceDex.test.ts create mode 100644 src/tempo/FundingSourceDex.ts diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts index 45e9fb590..644f80bc3 100644 --- a/src/tempo/FundingSource.test-d.ts +++ b/src/tempo/FundingSource.test-d.ts @@ -10,19 +10,3 @@ test('dex', () => { ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() expectTypeOf(source).toExtend() }) - -test('encodeExecutionData', () => { - expectTypeOf( - FundingSource.encodeExecutionData({ - tokenIn: '0x0101010101010101010101010101010101010101', - }), - ).toEqualTypeOf<`0x${string}`>() -}) - -test('encodeConfigData', () => { - expectTypeOf( - FundingSource.encodeConfigData({ - tokenIn: '0x0101010101010101010101010101010101010101', - }), - ).toEqualTypeOf<`0x${string}`>() -}) diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts index 4b973ca52..7d55001e4 100644 --- a/src/tempo/FundingSource.test.ts +++ b/src/tempo/FundingSource.test.ts @@ -11,39 +11,3 @@ describe('dex', () => { }) }) }) - -describe('encodeExecutionData', () => { - test('preserves zero and defaults to unlimited input', () => { - expect( - FundingSource.decode( - FundingSource.encodeExecutionData({ tokenIn: token }), - ), - ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) - expect( - FundingSource.decode( - FundingSource.encodeExecutionData({ tokenIn: token, maxAmountIn: 0n }), - ), - ).toEqual({ tokenIn: token, maxAmountIn: 0n }) - }) -}) - -describe('encodeConfigData', () => { - test('encodes an approved input and cap', () => { - expect( - FundingSource.encodeConfigData({ maxAmountIn: 30n, tokenIn: token }), - ).toBe( - '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', - ) - }) - - test('preserves zero and defaults to unlimited input', () => { - expect( - FundingSource.decode(FundingSource.encodeConfigData({ tokenIn: token })), - ).toEqual({ maxAmountIn: 2n ** 256n - 1n, tokenIn: token }) - expect( - FundingSource.decode( - FundingSource.encodeConfigData({ maxAmountIn: 0n, tokenIn: token }), - ), - ).toEqual({ maxAmountIn: 0n, tokenIn: token }) - }) -}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts index f45243ac5..7e33decdc 100644 --- a/src/tempo/FundingSource.ts +++ b/src/tempo/FundingSource.ts @@ -1,6 +1,6 @@ -import * as AbiParameters from '../core/AbiParameters.js' import type * as Address from '../core/Address.js' import type * as Hex from '../core/Hex.js' +import * as FundingSourceDex from './FundingSourceDex.js' /** A funding source with execution or configuration data. */ export type Source = { @@ -10,17 +10,6 @@ export type Source = { to: Address.Address } -/** Native DEX execution arguments or source configuration. */ -export type DexRequest = { - /** Input cap in base units. Omission is unlimited; zero permits no input. */ - maxAmountIn?: bigint | undefined - /** Input token to exchange for the required token. */ - tokenIn: Address.Address -} - -const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') -const nativeDexAddress = '0x1120000000000000000000000000000000000001' - /** * Creates a native DEX source for funding, policy rules, or discovery. * @@ -34,67 +23,4 @@ const nativeDexAddress = '0x1120000000000000000000000000000000000001' * }) * ``` */ -export function dex(request: DexRequest) { - return { - data: encodeExecutionData(request), - to: nativeDexAddress, - } satisfies Source -} - -/** - * Encodes native DEX execution arguments passed to funding hooks as `executionData`. - * - * @example - * ```ts - * import { FundingSource } from 'ox/tempo' - * - * FundingSource.encodeExecutionData({ - * maxAmountIn: 30_000_000n, - * tokenIn: '0x20c0000000000000000000000000000000000001' - * }) - * ``` - */ -export function encodeExecutionData(request: DexRequest): Hex.Hex { - return AbiParameters.encode(parameters, [ - request.tokenIn, - request.maxAmountIn ?? 2n ** 256n - 1n, - ]) -} - -/** - * Encodes native DEX source configuration passed to funding hooks as `configData`. - * - * The DEX uses the same encoding for execution and configuration data. - * - * @example - * ```ts - * import { FundingSource } from 'ox/tempo' - * - * FundingSource.encodeConfigData({ - * maxAmountIn: 30_000_000n, - * tokenIn: '0x20c0000000000000000000000000000000000001' - * }) - * ``` - */ -export function encodeConfigData(config: DexRequest): Hex.Hex { - return encodeExecutionData(config) -} - -/** - * Decodes native DEX execution or configuration data, returning the concrete input cap. - * - * @example - * ```ts - * import { FundingSource } from 'ox/tempo' - * - * FundingSource.decode( - * FundingSource.encodeExecutionData({ - * tokenIn: '0x20c0000000000000000000000000000000000001' - * }) - * ) - * ``` - */ -export function decode(data: Hex.Hex): Required { - const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) - return { tokenIn, maxAmountIn } -} +export const dex = FundingSourceDex.from diff --git a/src/tempo/FundingSourceDex.test-d.ts b/src/tempo/FundingSourceDex.test-d.ts new file mode 100644 index 000000000..484ac20ba --- /dev/null +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -0,0 +1,29 @@ +import { expectTypeOf, test } from 'vitest' +import type * as FundingSource from './FundingSource.js' +import * as FundingSourceDex from './FundingSourceDex.js' + +test('from', () => { + const source = FundingSourceDex.from({ + tokenIn: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.to, + ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() + expectTypeOf(source).toExtend() +}) + +test('encodeExecutionData', () => { + expectTypeOf( + FundingSourceDex.encodeExecutionData({ + tokenIn: '0x0101010101010101010101010101010101010101', + }), + ).toEqualTypeOf<`0x${string}`>() +}) + +test('encodeConfigData', () => { + expectTypeOf( + FundingSourceDex.encodeConfigData({ + tokenIn: '0x0101010101010101010101010101010101010101', + }), + ).toEqualTypeOf<`0x${string}`>() +}) diff --git a/src/tempo/FundingSourceDex.test.ts b/src/tempo/FundingSourceDex.test.ts new file mode 100644 index 000000000..4fdaf8517 --- /dev/null +++ b/src/tempo/FundingSourceDex.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vitest' +import * as FundingSourceDex from './FundingSourceDex.js' + +const token = '0x0101010101010101010101010101010101010101' as const + +describe('from', () => { + test('creates a funding source with the native DEX address', () => { + expect(FundingSourceDex.from({ tokenIn: token, maxAmountIn: 30n })).toEqual( + { + data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', + to: '0x1120000000000000000000000000000000000001', + }, + ) + }) +}) + +describe('encodeExecutionData', () => { + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSourceDex.decode( + FundingSourceDex.encodeExecutionData({ tokenIn: token }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + expect( + FundingSourceDex.decode( + FundingSourceDex.encodeExecutionData({ + tokenIn: token, + maxAmountIn: 0n, + }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 0n }) + }) +}) + +describe('encodeConfigData', () => { + test('encodes an approved input and cap', () => { + expect( + FundingSourceDex.encodeConfigData({ maxAmountIn: 30n, tokenIn: token }), + ).toBe( + '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', + ) + }) + + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSourceDex.decode( + FundingSourceDex.encodeConfigData({ tokenIn: token }), + ), + ).toEqual({ maxAmountIn: 2n ** 256n - 1n, tokenIn: token }) + expect( + FundingSourceDex.decode( + FundingSourceDex.encodeConfigData({ maxAmountIn: 0n, tokenIn: token }), + ), + ).toEqual({ maxAmountIn: 0n, tokenIn: token }) + }) +}) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts new file mode 100644 index 000000000..7755094ff --- /dev/null +++ b/src/tempo/FundingSourceDex.ts @@ -0,0 +1,93 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import type * as Address from '../core/Address.js' +import type * as Hex from '../core/Hex.js' +import type * as FundingSource from './FundingSource.js' + +/** Native DEX execution arguments or source configuration. */ +export type Request = { + /** Input cap in base units. Omission is unlimited; zero permits no input. */ + maxAmountIn?: bigint | undefined + /** Input token to exchange for the required token. */ + tokenIn: Address.Address +} + +const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') +const nativeDexAddress = '0x1120000000000000000000000000000000000001' + +/** + * Creates a native DEX source for funding, policy rules, or discovery. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.from({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000000' + * }) + * ``` + */ +export function from(request: Request) { + return { + data: encodeExecutionData(request), + to: nativeDexAddress, + } satisfies FundingSource.Source +} + +/** + * Encodes native DEX execution arguments passed to funding hooks as `executionData`. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.encodeExecutionData({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ``` + */ +export function encodeExecutionData(request: Request): Hex.Hex { + return AbiParameters.encode(parameters, [ + request.tokenIn, + request.maxAmountIn ?? 2n ** 256n - 1n, + ]) +} + +/** + * Encodes native DEX source configuration passed to funding hooks as `configData`. + * + * The DEX uses the same encoding for execution and configuration data. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.encodeConfigData({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ``` + */ +export function encodeConfigData(config: Request): Hex.Hex { + return encodeExecutionData(config) +} + +/** + * Decodes native DEX execution or configuration data, returning the concrete input cap. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.decode( + * FundingSourceDex.encodeExecutionData({ + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ) + * ``` + */ +export function decode(data: Hex.Hex): Required { + const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) + return { tokenIn, maxAmountIn } +} diff --git a/src/tempo/index.ts b/src/tempo/index.ts index d1a595f3f..b813a2c42 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -102,6 +102,14 @@ export * as FundingRequirement from './FundingRequirement.js' * @category Reference */ export * as FundingSource from './FundingSource.js' + +/** + * Native DEX funding source construction and payload encoding. + * + * @category Reference + */ +export * as FundingSourceDex from './FundingSourceDex.js' + /** * Tempo key authorization utilities for provisioning and signing access keys. * From 3ae941bc8912ed495ad3a1f36b8d5f51e80cee64 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:45:01 +1000 Subject: [PATCH 15/27] feat: add earn funding source codecs --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingSourceEarn.test-d.ts | 26 +++++++++ src/tempo/FundingSourceEarn.test.ts | 74 +++++++++++++++++++++++++ src/tempo/FundingSourceEarn.ts | 79 +++++++++++++++++++++++++++ src/tempo/index.ts | 7 +++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/tempo/FundingSourceEarn.test-d.ts create mode 100644 src/tempo/FundingSourceEarn.test.ts create mode 100644 src/tempo/FundingSourceEarn.ts diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index e62347729..728f41186 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules with `to` source addresses, native DEX execution and configuration encoders, and access key funding authorization. +Added Tempo funding codecs, policy rules with `to` source addresses, native DEX and Earn execution and configuration encoders, and access key funding authorization. diff --git a/src/tempo/FundingSourceEarn.test-d.ts b/src/tempo/FundingSourceEarn.test-d.ts new file mode 100644 index 000000000..1f3523b72 --- /dev/null +++ b/src/tempo/FundingSourceEarn.test-d.ts @@ -0,0 +1,26 @@ +import { expectTypeOf, test } from 'vitest' +import * as FundingSourceEarn from './FundingSourceEarn.js' + +const vault = '0x0101010101010101010101010101010101010101' as const + +test('encodeExecutionData', () => { + expectTypeOf( + FundingSourceEarn.encodeExecutionData({ vault }), + ).toEqualTypeOf<`0x${string}`>() + // @ts-expect-error Input caps use bigint base units. + FundingSourceEarn.encodeExecutionData({ maxAmountIn: 30, vault }) +}) + +test('encodeConfigData', () => { + expectTypeOf( + FundingSourceEarn.encodeConfigData({ maxValueIn: 50n, vault }), + ).toEqualTypeOf<`0x${string}`>() + // @ts-expect-error A vault is required. + FundingSourceEarn.encodeConfigData({ maxValueIn: 50n }) +}) + +test('decode', () => { + expectTypeOf(FundingSourceEarn.decode('0x')).toEqualTypeOf< + Required + >() +}) diff --git a/src/tempo/FundingSourceEarn.test.ts b/src/tempo/FundingSourceEarn.test.ts new file mode 100644 index 000000000..63185c697 --- /dev/null +++ b/src/tempo/FundingSourceEarn.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'vitest' +import * as FundingSourceEarn from './FundingSourceEarn.js' + +const vault = '0x0101010101010101010101010101010101010101' as const +const addressWord = + '0000000000000000000000000101010101010101010101010101010101010101' +const unlimited = + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' +const zero = '0000000000000000000000000000000000000000000000000000000000000000' +const thirty = + '000000000000000000000000000000000000000000000000000000000000001e' +const fifty = '0000000000000000000000000000000000000000000000000000000000000032' + +for (const method of ['encodeConfigData', 'encodeExecutionData'] as const) + describe(method, () => { + test('encodes vault, share cap, and underlying value cap in contract order', () => { + expect( + FundingSourceEarn[method]({ maxAmountIn: 30n, maxValueIn: 50n, vault }), + ).toBe(`0x${addressWord}${thirty}${fifty}`) + }) + + test('defaults both caps to unlimited', () => { + expect(FundingSourceEarn[method]({ vault })).toBe( + `0x${addressWord}${unlimited}${unlimited}`, + ) + }) + + test('preserves zero and defaults each omitted cap independently', () => { + expect(FundingSourceEarn[method]({ maxAmountIn: 0n, vault })).toBe( + `0x${addressWord}${zero}${unlimited}`, + ) + expect(FundingSourceEarn[method]({ maxValueIn: 0n, vault })).toBe( + `0x${addressWord}${unlimited}${zero}`, + ) + }) + }) + +describe('decode', () => { + test('decodes an independently encoded contract request', () => { + expect( + FundingSourceEarn.decode(`0x${addressWord}${thirty}${fifty}`), + ).toMatchInlineSnapshot(` + { + "maxAmountIn": 30n, + "maxValueIn": 50n, + "vault": "0x0101010101010101010101010101010101010101", + } + `) + }) +}) + +describe('behavior', () => { + test('rejects malformed data', () => { + expect(() => + FundingSourceEarn.decode('0x'), + ).toThrowErrorMatchingInlineSnapshot( + `[AbiParameters.ZeroDataError: Cannot decode zero data ("0x") with ABI parameters.]`, + ) + }) + + for (const field of ['maxAmountIn', 'maxValueIn'] as const) + test(`rejects invalid ${field}`, () => { + expect(() => + FundingSourceEarn.encodeExecutionData({ [field]: -1n, vault }), + ).toThrowErrorMatchingInlineSnapshot( + `[Hex.IntegerOutOfRangeError: Number \`-1\` is not in safe 256-bit unsigned integer range (\`0\` to \`115792089237316195423570985008687907853269984665640564039457584007913129639935\`)]`, + ) + expect(() => + FundingSourceEarn.encodeConfigData({ [field]: 2n ** 256n, vault }), + ).toThrowErrorMatchingInlineSnapshot( + `[Hex.IntegerOutOfRangeError: Number \`115792089237316195423570985008687907853269984665640564039457584007913129639936\` is not in safe 256-bit unsigned integer range (\`0\` to \`115792089237316195423570985008687907853269984665640564039457584007913129639935\`)]`, + ) + }) +}) diff --git a/src/tempo/FundingSourceEarn.ts b/src/tempo/FundingSourceEarn.ts new file mode 100644 index 000000000..5f7bf3b54 --- /dev/null +++ b/src/tempo/FundingSourceEarn.ts @@ -0,0 +1,79 @@ +import * as AbiParameters from '../core/AbiParameters.js' +import type * as Address from '../core/Address.js' +import type * as Hex from '../core/Hex.js' + +/** Earn execution arguments or source configuration. */ +export type Request = { + /** EarnShare input cap in base units. Omission is unlimited; zero permits no input. */ + maxAmountIn?: bigint | undefined + /** Gross underlying-token value cap in base units, before exit fees. Omission is unlimited; zero permits no input. */ + maxValueIn?: bigint | undefined + /** Earn vault whose shares are redeemed. */ + vault: Address.Address +} + +const parameters = AbiParameters.from( + 'address vault, uint256 maxAmountIn, uint256 maxValueIn', +) + +/** + * Encodes an Earn funding request as `executionData`. + * + * Caps apply per source invocation. `maxValueIn` uses underlying-token units, even when funding a different output token. + * + * @example + * ```ts + * import { FundingSourceEarn } from 'ox/tempo' + * + * FundingSourceEarn.encodeExecutionData({ + * maxValueIn: 50_000_000n, + * vault: '0x0101010101010101010101010101010101010101', + * }) + * ``` + */ +export function encodeExecutionData(request: Request): Hex.Hex { + return AbiParameters.encode(parameters, [ + request.vault, + request.maxAmountIn ?? 2n ** 256n - 1n, + request.maxValueIn ?? 2n ** 256n - 1n, + ]) +} + +/** + * Encodes Earn source configuration as `configData`. + * + * Execution must use the configured vault and cannot increase either cap. + * + * @example + * ```ts + * import { FundingSourceEarn } from 'ox/tempo' + * + * FundingSourceEarn.encodeConfigData({ + * maxValueIn: 50_000_000n, + * vault: '0x0101010101010101010101010101010101010101', + * }) + * ``` + */ +export function encodeConfigData(config: Request): Hex.Hex { + return encodeExecutionData(config) +} + +/** + * Decodes an Earn execution request or configuration, returning both concrete caps. + * + * @example + * ```ts + * import { FundingSourceEarn } from 'ox/tempo' + * + * FundingSourceEarn.decode(FundingSourceEarn.encodeConfigData({ + * vault: '0x0101010101010101010101010101010101010101', + * })) + * ``` + */ +export function decode(data: Hex.Hex): Required { + const [vault, maxAmountIn, maxValueIn] = AbiParameters.decode( + parameters, + data, + ) + return { maxAmountIn, maxValueIn, vault } +} diff --git a/src/tempo/index.ts b/src/tempo/index.ts index b813a2c42..c8e115ccb 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -619,3 +619,10 @@ export * as ZoneId from './ZoneId.js' * @category Reference */ export * as ZoneRpcAuthentication from './ZoneRpcAuthentication.js' + +/** + * Earn funding source payload encoding. + * + * @category Reference + */ +export * as FundingSourceEarn from './FundingSourceEarn.js' From f982011c4a5fb86451336df4dc2bf29e8e9cf8a0 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:45:31 +1000 Subject: [PATCH 16/27] style: order funding source exports --- src/tempo/index.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/tempo/index.ts b/src/tempo/index.ts index c8e115ccb..d813555cc 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -109,7 +109,12 @@ export * as FundingSource from './FundingSource.js' * @category Reference */ export * as FundingSourceDex from './FundingSourceDex.js' - +/** + * Earn funding source payload encoding. + * + * @category Reference + */ +export * as FundingSourceEarn from './FundingSourceEarn.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * @@ -619,10 +624,3 @@ export * as ZoneId from './ZoneId.js' * @category Reference */ export * as ZoneRpcAuthentication from './ZoneRpcAuthentication.js' - -/** - * Earn funding source payload encoding. - * - * @category Reference - */ -export * as FundingSourceEarn from './FundingSourceEarn.js' From 2e23692fa7d16319452692c53cfff865660a59b4 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:48:58 +1000 Subject: [PATCH 17/27] refactor: name funding source data decoders --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingSourceDex.test-d.ts | 12 ++++++++++++ src/tempo/FundingSourceDex.test.ts | 18 ++++++++++++++---- src/tempo/FundingSourceDex.ts | 22 +++++++++++++++++++--- src/tempo/FundingSourceEarn.test-d.ts | 10 ++++++++-- src/tempo/FundingSourceEarn.test.ts | 18 +++++++++++++++--- src/tempo/FundingSourceEarn.ts | 22 +++++++++++++++++++--- 7 files changed, 88 insertions(+), 16 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 728f41186..5ae9616a2 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules with `to` source addresses, native DEX and Earn execution and configuration encoders, and access key funding authorization. +Added Tempo funding codecs, policy rules with `to` source addresses, native DEX and Earn execution and configuration codecs, and access key funding authorization. diff --git a/src/tempo/FundingSourceDex.test-d.ts b/src/tempo/FundingSourceDex.test-d.ts index 484ac20ba..3c9ea78d4 100644 --- a/src/tempo/FundingSourceDex.test-d.ts +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -27,3 +27,15 @@ test('encodeConfigData', () => { }), ).toEqualTypeOf<`0x${string}`>() }) + +test('decodeConfigData', () => { + expectTypeOf(FundingSourceDex.decodeConfigData('0x')).toEqualTypeOf< + Required + >() +}) + +test('decodeExecutionData', () => { + expectTypeOf(FundingSourceDex.decodeExecutionData('0x')).toEqualTypeOf< + Required + >() +}) diff --git a/src/tempo/FundingSourceDex.test.ts b/src/tempo/FundingSourceDex.test.ts index 4fdaf8517..ffe4b183b 100644 --- a/src/tempo/FundingSourceDex.test.ts +++ b/src/tempo/FundingSourceDex.test.ts @@ -17,12 +17,12 @@ describe('from', () => { describe('encodeExecutionData', () => { test('preserves zero and defaults to unlimited input', () => { expect( - FundingSourceDex.decode( + FundingSourceDex.decodeExecutionData( FundingSourceDex.encodeExecutionData({ tokenIn: token }), ), ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) expect( - FundingSourceDex.decode( + FundingSourceDex.decodeExecutionData( FundingSourceDex.encodeExecutionData({ tokenIn: token, maxAmountIn: 0n, @@ -43,14 +43,24 @@ describe('encodeConfigData', () => { test('preserves zero and defaults to unlimited input', () => { expect( - FundingSourceDex.decode( + FundingSourceDex.decodeConfigData( FundingSourceDex.encodeConfigData({ tokenIn: token }), ), ).toEqual({ maxAmountIn: 2n ** 256n - 1n, tokenIn: token }) expect( - FundingSourceDex.decode( + FundingSourceDex.decodeConfigData( FundingSourceDex.encodeConfigData({ maxAmountIn: 0n, tokenIn: token }), ), ).toEqual({ maxAmountIn: 0n, tokenIn: token }) }) }) + +describe('decodeConfigData', () => { + test('decodes configuration bytes', () => { + expect( + FundingSourceDex.decodeConfigData( + '0x00000000000000000000000001010101010101010101010101010101010101010000000000000000000000000000000000000000000000000000000000000001', + ), + ).toEqual({ maxAmountIn: 1n, tokenIn: token }) + }) +}) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts index 7755094ff..70009075d 100644 --- a/src/tempo/FundingSourceDex.ts +++ b/src/tempo/FundingSourceDex.ts @@ -74,20 +74,36 @@ export function encodeConfigData(config: Request): Hex.Hex { } /** - * Decodes native DEX execution or configuration data, returning the concrete input cap. + * Decodes native DEX execution data, returning the concrete input cap. * * @example * ```ts * import { FundingSourceDex } from 'ox/tempo' * - * FundingSourceDex.decode( + * FundingSourceDex.decodeExecutionData( * FundingSourceDex.encodeExecutionData({ * tokenIn: '0x20c0000000000000000000000000000000000001' * }) * ) * ``` */ -export function decode(data: Hex.Hex): Required { +export function decodeExecutionData(data: Hex.Hex): Required { const [tokenIn, maxAmountIn] = AbiParameters.decode(parameters, data) return { tokenIn, maxAmountIn } } + +/** + * Decodes native DEX source configuration, returning concrete input caps. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.decodeConfigData(FundingSourceDex.encodeConfigData({ + * tokenIn: '0x0101010101010101010101010101010101010101', + * })) + * ``` + */ +export function decodeConfigData(data: Hex.Hex): Required { + return decodeExecutionData(data) +} diff --git a/src/tempo/FundingSourceEarn.test-d.ts b/src/tempo/FundingSourceEarn.test-d.ts index 1f3523b72..f41c4acdd 100644 --- a/src/tempo/FundingSourceEarn.test-d.ts +++ b/src/tempo/FundingSourceEarn.test-d.ts @@ -19,8 +19,14 @@ test('encodeConfigData', () => { FundingSourceEarn.encodeConfigData({ maxValueIn: 50n }) }) -test('decode', () => { - expectTypeOf(FundingSourceEarn.decode('0x')).toEqualTypeOf< +test('decodeExecutionData', () => { + expectTypeOf(FundingSourceEarn.decodeExecutionData('0x')).toEqualTypeOf< + Required + >() +}) + +test('decodeConfigData', () => { + expectTypeOf(FundingSourceEarn.decodeConfigData('0x')).toEqualTypeOf< Required >() }) diff --git a/src/tempo/FundingSourceEarn.test.ts b/src/tempo/FundingSourceEarn.test.ts index 63185c697..0b95de099 100644 --- a/src/tempo/FundingSourceEarn.test.ts +++ b/src/tempo/FundingSourceEarn.test.ts @@ -35,10 +35,12 @@ for (const method of ['encodeConfigData', 'encodeExecutionData'] as const) }) }) -describe('decode', () => { +describe('decodeExecutionData', () => { test('decodes an independently encoded contract request', () => { expect( - FundingSourceEarn.decode(`0x${addressWord}${thirty}${fifty}`), + FundingSourceEarn.decodeExecutionData( + `0x${addressWord}${thirty}${fifty}`, + ), ).toMatchInlineSnapshot(` { "maxAmountIn": 30n, @@ -49,10 +51,20 @@ describe('decode', () => { }) }) +describe('decodeConfigData', () => { + test('decodes configuration bytes', () => { + expect( + FundingSourceEarn.decodeConfigData( + '0x000000000000000000000000010101010101010101010101010101010101010100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002', + ), + ).toEqual({ maxAmountIn: 1n, maxValueIn: 2n, vault }) + }) +}) + describe('behavior', () => { test('rejects malformed data', () => { expect(() => - FundingSourceEarn.decode('0x'), + FundingSourceEarn.decodeExecutionData('0x'), ).toThrowErrorMatchingInlineSnapshot( `[AbiParameters.ZeroDataError: Cannot decode zero data ("0x") with ABI parameters.]`, ) diff --git a/src/tempo/FundingSourceEarn.ts b/src/tempo/FundingSourceEarn.ts index 5f7bf3b54..e747b0097 100644 --- a/src/tempo/FundingSourceEarn.ts +++ b/src/tempo/FundingSourceEarn.ts @@ -59,21 +59,37 @@ export function encodeConfigData(config: Request): Hex.Hex { } /** - * Decodes an Earn execution request or configuration, returning both concrete caps. + * Decodes an Earn execution request, returning both concrete caps. * * @example * ```ts * import { FundingSourceEarn } from 'ox/tempo' * - * FundingSourceEarn.decode(FundingSourceEarn.encodeConfigData({ + * FundingSourceEarn.decodeExecutionData(FundingSourceEarn.encodeExecutionData({ * vault: '0x0101010101010101010101010101010101010101', * })) * ``` */ -export function decode(data: Hex.Hex): Required { +export function decodeExecutionData(data: Hex.Hex): Required { const [vault, maxAmountIn, maxValueIn] = AbiParameters.decode( parameters, data, ) return { maxAmountIn, maxValueIn, vault } } + +/** + * Decodes Earn source configuration, returning concrete input caps. + * + * @example + * ```ts + * import { FundingSourceEarn } from 'ox/tempo' + * + * FundingSourceEarn.decodeConfigData(FundingSourceEarn.encodeConfigData({ + * vault: '0x0101010101010101010101010101010101010101', + * })) + * ``` + */ +export function decodeConfigData(data: Hex.Hex): Required { + return decodeExecutionData(data) +} From 9f2209ce1e498ca0e6d194084bd25de247523af3 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:53:20 +1000 Subject: [PATCH 18/27] feat: add earn funding source constructor --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingSource.test-d.ts | 13 ++++++++++ src/tempo/FundingSource.test.ts | 16 +++++++++++++ src/tempo/FundingSource.ts | 19 +++++++++++++++ src/tempo/FundingSourceEarn.test-d.ts | 15 ++++++++++++ src/tempo/FundingSourceEarn.test.ts | 16 +++++++++++++ src/tempo/FundingSourceEarn.ts | 34 +++++++++++++++++++++++++++ 7 files changed, 114 insertions(+), 1 deletion(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 5ae9616a2..a69d98d20 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules with `to` source addresses, native DEX and Earn execution and configuration codecs, and access key funding authorization. +Added Tempo funding codecs, policy rules with `to` source addresses, native DEX and Earn source helpers and codecs, and access key funding authorization. diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts index 644f80bc3..771f2d02d 100644 --- a/src/tempo/FundingSource.test-d.ts +++ b/src/tempo/FundingSource.test-d.ts @@ -10,3 +10,16 @@ test('dex', () => { ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() expectTypeOf(source).toExtend() }) + +test('earn', () => { + const source = FundingSource.earn({ + source: '0x0202020202020202020202020202020202020202', + vault: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.to, + ).toEqualTypeOf<'0x0202020202020202020202020202020202020202'>() + expectTypeOf(source.data).toEqualTypeOf<`0x${string}`>() + // @ts-expect-error The deployed source address is required. + FundingSource.earn({ vault: '0x0101010101010101010101010101010101010101' }) +}) diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts index 7d55001e4..059565ca8 100644 --- a/src/tempo/FundingSource.test.ts +++ b/src/tempo/FundingSource.test.ts @@ -11,3 +11,19 @@ describe('dex', () => { }) }) }) + +describe('earn', () => { + test('uses the supplied source address and encodes both caps', () => { + expect( + FundingSource.earn({ + maxAmountIn: 30n, + maxValueIn: 50n, + source: '0x0202020202020202020202020202020202020202', + vault: '0x0101010101010101010101010101010101010101', + }), + ).toEqual({ + data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000032', + to: '0x0202020202020202020202020202020202020202', + }) + }) +}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts index 7e33decdc..b5b897316 100644 --- a/src/tempo/FundingSource.ts +++ b/src/tempo/FundingSource.ts @@ -1,6 +1,7 @@ import type * as Address from '../core/Address.js' import type * as Hex from '../core/Hex.js' import * as FundingSourceDex from './FundingSourceDex.js' +import * as FundingSourceEarn from './FundingSourceEarn.js' /** A funding source with execution or configuration data. */ export type Source = { @@ -24,3 +25,21 @@ export type Source = { * ``` */ export const dex = FundingSourceDex.from + +/** + * Creates an Earn source for funding, policy rules, or discovery. + * + * The source address identifies a deployed Earn funding source. Caps apply per invocation. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.earn({ + * maxValueIn: 50_000_000n, + * source: '0x0202020202020202020202020202020202020202', + * vault: '0x0101010101010101010101010101010101010101', + * }) + * ``` + */ +export const earn = FundingSourceEarn.from diff --git a/src/tempo/FundingSourceEarn.test-d.ts b/src/tempo/FundingSourceEarn.test-d.ts index f41c4acdd..e78516d7e 100644 --- a/src/tempo/FundingSourceEarn.test-d.ts +++ b/src/tempo/FundingSourceEarn.test-d.ts @@ -30,3 +30,18 @@ test('decodeConfigData', () => { Required >() }) + +test('from', () => { + const source = FundingSourceEarn.from({ + source: '0x0202020202020202020202020202020202020202', + vault: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.to, + ).toEqualTypeOf<'0x0202020202020202020202020202020202020202'>() + expectTypeOf(source.data).toEqualTypeOf<`0x${string}`>() + // @ts-expect-error The deployed source address is required. + FundingSourceEarn.from({ + vault: '0x0101010101010101010101010101010101010101', + }) +}) diff --git a/src/tempo/FundingSourceEarn.test.ts b/src/tempo/FundingSourceEarn.test.ts index 0b95de099..3a47d4522 100644 --- a/src/tempo/FundingSourceEarn.test.ts +++ b/src/tempo/FundingSourceEarn.test.ts @@ -61,6 +61,22 @@ describe('decodeConfigData', () => { }) }) +describe('from', () => { + test('uses the supplied source address and encodes both caps', () => { + expect( + FundingSourceEarn.from({ + maxAmountIn: 30n, + maxValueIn: 50n, + source: '0x0202020202020202020202020202020202020202', + vault: '0x0101010101010101010101010101010101010101', + }), + ).toEqual({ + data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000032', + to: '0x0202020202020202020202020202020202020202', + }) + }) +}) + describe('behavior', () => { test('rejects malformed data', () => { expect(() => diff --git a/src/tempo/FundingSourceEarn.ts b/src/tempo/FundingSourceEarn.ts index e747b0097..e90519e45 100644 --- a/src/tempo/FundingSourceEarn.ts +++ b/src/tempo/FundingSourceEarn.ts @@ -1,6 +1,7 @@ import * as AbiParameters from '../core/AbiParameters.js' import type * as Address from '../core/Address.js' import type * as Hex from '../core/Hex.js' +import type * as FundingSource from './FundingSource.js' /** Earn execution arguments or source configuration. */ export type Request = { @@ -16,6 +17,39 @@ const parameters = AbiParameters.from( 'address vault, uint256 maxAmountIn, uint256 maxValueIn', ) +/** + * Creates an Earn source for funding, policy rules, or discovery. + * + * The source address identifies a deployed Earn funding source. Caps apply per invocation. + * + * @example + * ```ts + * import { FundingSourceEarn } from 'ox/tempo' + * + * FundingSourceEarn.from({ + * maxValueIn: 50_000_000n, + * source: '0x0202020202020202020202020202020202020202', + * vault: '0x0101010101010101010101010101010101010101', + * }) + * ``` + */ +export function from( + request: from.Parameters, +) { + return { + data: encodeExecutionData(request), + to: request.source, + } satisfies FundingSource.Source +} + +export declare namespace from { + type Parameters = + Request & { + /** Deployed Earn funding source address. */ + source: source + } +} + /** * Encodes an Earn funding request as `executionData`. * From 2e783741ea79078d36a9f8e235c9412501985637 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:44:08 +1000 Subject: [PATCH 19/27] feat(tempo): add unsigned key authorization rpc conversion --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingPolicy.ts | 8 ++++ src/tempo/FundingRequirement.test.ts | 24 ++++++++++ src/tempo/FundingRequirement.ts | 4 ++ src/tempo/KeyAuthorization.test.ts | 66 ++++++++++++++++++++++++++++ src/tempo/KeyAuthorization.ts | 63 ++++++++++++++++++++++---- src/tempo/TxEnvelopeTempo.test.ts | 14 ++++++ src/tempo/TxEnvelopeTempo.ts | 7 +++ 8 files changed, 179 insertions(+), 9 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index a69d98d20..0a8fc819f 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules with `to` source addresses, native DEX and Earn source helpers and codecs, and access key funding authorization. +Added Tempo funding codecs, policy rules, native DEX and Earn source helpers, access key funding authorization, and unsigned key authorization RPC conversion. diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index c57eea7c6..f756f5e64 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -241,6 +241,14 @@ export function hash(rules: Rules): Hex.Hex { * ``` */ export function toTuple(value: Authorization): Tuple { + if ( + typeof value !== 'bigint' && + (typeof value !== 'object' || value === null) + ) + throw new InvalidPolicyError( + 'Funding policy must be resolved before signing.', + ) + if (typeof value === 'bigint') { if (value <= 0n || value >= 2n ** 64n) throw new InvalidPolicyError('Policy ID must be a nonzero uint64.') diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index 44ba836e0..f91c98dfc 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -126,6 +126,30 @@ describe('fromRpc', () => { }) describe('behavior', () => { + test('rejects unresolved sources before signing', () => { + expect(() => + FundingRequirement.assert({ + amount: 1n, + token: '0x20c0000000000000000000000000000000000000', + } as never), + ).toThrowErrorMatchingInlineSnapshot( + `[FundingRequirement.InvalidRequirementError: Funding sources must be resolved before signing.]`, + ) + }) + + test('rejects unresolved policy before signing', () => { + expect(() => + KeyAuthorization.getSignPayload({ + address: '0x1111111111111111111111111111111111111111', + chainId: 1n, + fundingPolicy: true as never, + type: 'secp256k1', + }), + ).toThrowErrorMatchingInlineSnapshot( + `[FundingPolicy.InvalidPolicyError: Funding policy must be resolved before signing.]`, + ) + }) + const rules = { maxSlippageBps: 100, sources: { diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index 86da478b8..db27cfea0 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -52,6 +52,10 @@ export type Tuple = readonly [ * ``` */ export function assert(value: FundingRequirement): void { + if (!Array.isArray(value.sources)) + throw new InvalidRequirementError( + 'Funding sources must be resolved before signing.', + ) Address.assert(value.token, { strict: false }) if (value.amount < 0n || value.amount >= 2n ** 256n) throw new InvalidRequirementError('Amount must fit uint256.') diff --git a/src/tempo/KeyAuthorization.test.ts b/src/tempo/KeyAuthorization.test.ts index 3de1a4962..c387b95d2 100644 --- a/src/tempo/KeyAuthorization.test.ts +++ b/src/tempo/KeyAuthorization.test.ts @@ -2807,3 +2807,69 @@ describe('admin keys (TIP-1049)', () => { expect(KeyAuthorization.hash(a)).not.toBe(KeyAuthorization.hash(b)) }) }) + +describe('fromRpcUnsigned', () => { + test('decodes an authorization before owner signing', () => { + expect( + KeyAuthorization.fromRpcUnsigned({ + chainId: '0x1', + expiry: '0x0', + fundingPolicy: '0x7', + keyId: address, + keyType: 'secp256k1', + limits: [], + }), + ).toMatchInlineSnapshot(` + { + "address": "0xbe95c3f554e9fc85ec51be69a3d807a0d55bcf2c", + "chainId": 1n, + "expiry": 0, + "fundingPolicy": 7n, + "limits": [], + "type": "secp256k1", + } + `) + }) +}) + +describe('toRpcUnsigned', () => { + test('encodes an authorization without a signature', () => { + expect( + KeyAuthorization.toRpcUnsigned({ + address, + chainId: 1n, + fundingPolicy: 7n, + limits: [{ limit: 50n, token }], + type: 'secp256k1', + }), + ).toMatchInlineSnapshot(` + { + "chainId": "0x1", + "expiry": null, + "fundingPolicy": "0x7", + "keyId": "0xbe95c3f554e9fc85ec51be69a3d807a0d55bcf2c", + "keyType": "secp256k1", + "limits": [ + { + "limit": "0x32", + "token": "0x20c0000000000000000000000000000000000001", + }, + ], + } + `) + }) + + test('preserves multisig account binding', () => { + const authorization = { + account: '0x1111111111111111111111111111111111111111', + address, + chainId: 1n, + type: 'multisig', + } as const + expect( + KeyAuthorization.fromRpcUnsigned( + KeyAuthorization.toRpcUnsigned(authorization), + ), + ).toEqual(authorization) + }) +}) diff --git a/src/tempo/KeyAuthorization.ts b/src/tempo/KeyAuthorization.ts index b41e0ec02..a40577776 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -117,6 +117,16 @@ export type Input = KeyAuthorization< TempoAddress.Address > +/** Unsigned RPC authorization used before owner signing. */ +export type UnsignedRpc = Rpc extends infer authorization + ? authorization extends Rpc + ? Omit & { signature?: never } + : never + : never + +/** Authorization fields before owner signing. */ +export type Unsigned = KeyAuthorization & { signature?: never } + /** RPC representation matching the node's wire format. */ export type Rpc = { /** Allowed call scopes (node field: `allowedCalls`). */ @@ -572,14 +582,34 @@ export declare namespace from { * @returns A signed {@link ox#AuthorizationTempo.AuthorizationTempo}. */ export function fromRpc(authorization: Rpc): Signed { + const { signature: signatureRpc, ...unsigned } = authorization + const signature = SignatureEnvelope.fromRpc(signatureRpc) + assertSignature(signature) + return { ...fromRpcUnsigned(unsigned), signature } +} + +/** + * Converts unsigned RPC fields before owner signing. + * + * @example + * ```ts + * import { KeyAuthorization } from 'ox/tempo' + * + * const authorization = KeyAuthorization.fromRpcUnsigned({ + * chainId: '0x1', + * expiry: null, + * keyId: '0x2222222222222222222222222222222222222222', + * keyType: 'secp256k1', + * }) + * ``` + */ +export function fromRpcUnsigned(authorization: UnsignedRpc): Unsigned { const { allowedCalls, chainId, keyId, expiry, limits, keyType } = authorization const witness = authorization.witness ?? undefined const isAdmin = authorization.isAdmin ?? undefined const account = authorization.account ?? undefined assertAccountBinding(keyType, account) - const signature = SignatureEnvelope.fromRpc(authorization.signature) - assertSignature(signature) if (witness !== undefined) assertWitness(witness) // Unflatten nested allowedCalls into flat scopes @@ -611,7 +641,6 @@ export function fromRpc(authorization: Rpc): Signed { : {}), })), ...(scopes ? { scopes } : {}), - signature, ...(keyType === 'multisig' ? { account: account!, type: keyType } : { type: keyType }), @@ -987,6 +1016,29 @@ export declare namespace serialize { * @returns An RPC-formatted Key Authorization. */ export function toRpc(authorization: Signed): Rpc { + const { signature, ...unsigned } = authorization + assertSignature(signature) + return { + ...toRpcUnsigned(unsigned), + signature: SignatureEnvelope.toRpc(signature) as SignatureRpc, + } +} + +/** + * Converts unsigned authorization fields to RPC before owner signing. + * + * @example + * ```ts + * import { KeyAuthorization } from 'ox/tempo' + * + * const authorization = KeyAuthorization.toRpcUnsigned({ + * address: '0x2222222222222222222222222222222222222222', + * chainId: 1n, + * type: 'secp256k1', + * }) + * ``` + */ +export function toRpcUnsigned(authorization: Unsigned): UnsignedRpc { assertAccountBinding(authorization.type, authorization.account) const { address, @@ -995,12 +1047,10 @@ export function toRpc(authorization: Signed): Rpc { expiry, limits, type, - signature, witness, isAdmin, account, } = authorization - assertSignature(signature) if (witness !== undefined) assertWitness(witness) // Group flat scopes by address into nested allowedCalls wire format @@ -1037,9 +1087,6 @@ export function toRpc(authorization: Signed): Rpc { limit: Hex.fromNumber(limit), ...(period ? { period: numberToHex(period) } : {}), })), - signature: SignatureEnvelope.toRpc(signature) as - | SignatureEnvelope.PrimitiveRpc - | SignatureEnvelope.MultisigRpc, ...(allowedCalls ? { allowedCalls } : {}), ...(witness !== undefined ? { witness } : {}), ...(isAdmin ? { isAdmin: true } : {}), diff --git a/src/tempo/TxEnvelopeTempo.test.ts b/src/tempo/TxEnvelopeTempo.test.ts index 64a18dc2d..7f9b8788e 100644 --- a/src/tempo/TxEnvelopeTempo.test.ts +++ b/src/tempo/TxEnvelopeTempo.test.ts @@ -1644,3 +1644,17 @@ describe('validate', () => { ).toBe(false) }) }) + +describe('behavior', () => { + test('rejects unresolved funding intent before signing', () => { + expect(() => + TxEnvelopeTempo.serialize({ + calls: [], + chainId: 1, + requireFunds: true as never, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[FundingRequirement.InvalidRequirementError: Funding requirements must be resolved before signing.]`, + ) + }) +}) diff --git a/src/tempo/TxEnvelopeTempo.ts b/src/tempo/TxEnvelopeTempo.ts index 98791207c..8e0ae186a 100644 --- a/src/tempo/TxEnvelopeTempo.ts +++ b/src/tempo/TxEnvelopeTempo.ts @@ -189,6 +189,13 @@ export function assert(envelope: PartialBy) { validAfter, } = envelope + if ( + envelope.requireFunds !== undefined && + !Array.isArray(envelope.requireFunds) + ) + throw new FundingRequirement.InvalidRequirementError( + 'Funding requirements must be resolved before signing.', + ) for (const requirement of envelope.requireFunds ?? []) FundingRequirement.assert(requirement) From 7591c1cf48e51593f48d9a5a237ec76aab204dfc Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:27:12 +1000 Subject: [PATCH 20/27] fix(tempo): use to for funding rpc sources --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingPolicy.test.ts | 8 ++++---- src/tempo/FundingPolicy.ts | 23 ++++++----------------- src/tempo/FundingRequirement.test.ts | 8 ++++---- src/tempo/FundingRequirement.ts | 10 ++++------ 5 files changed, 19 insertions(+), 32 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 0a8fc819f..8f04d93a6 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules, native DEX and Earn source helpers, access key funding authorization, and unsigned key authorization RPC conversion. +Added Tempo funding codecs, policy rules, native DEX and Earn source helpers, access key funding authorization, and unsigned key authorization RPC conversion using `to` for funding sources. diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index 3ab74ec4e..1f3a528d2 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -72,25 +72,25 @@ describe('toRoutes', () => { }) describe('toRpc', () => { - test('keeps the RPC target field', () => { + test('preserves the source to field', () => { expect(FundingPolicy.toRpc({ admins: [token], rules })).toEqual({ admins: [token], rules: { maxSlippageBps: 100, - sources: { [token]: [{ target, data: '0xab' }] }, + sources: { [token]: [{ to: target, data: '0xab' }] }, }, }) }) }) describe('fromRpc', () => { - test('maps RPC sources to to', () => { + test('decodes the source to field', () => { expect( FundingPolicy.fromRpc({ admins: [token], rules: { maxSlippageBps: 100, - sources: { [token]: [{ target, data: '0xab' }] }, + sources: { [token]: [{ to: target, data: '0xab' }] }, }, }), ).toEqual({ admins: [token], rules }) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index f756f5e64..112e9566d 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -49,7 +49,7 @@ export type Rpc = /** Maximum aggregate slippage in basis points. */ maxSlippageBps: number /** Ordered source permissions for each output token. */ - sources: Readonly> + sources: Readonly> } } @@ -356,21 +356,7 @@ export function fromTuple(value: Tuple): Authorization { * ``` */ export function fromRpc(value: Rpc): Authorization { - const result = - typeof value === 'string' - ? BigInt(value) - : { - ...value, - rules: { - ...value.rules, - sources: Object.fromEntries( - Object.entries(value.rules.sources).map(([token, sources]) => [ - token, - sources.map(({ target, data }) => ({ to: target, data })), - ]), - ), - }, - } + const result = typeof value === 'string' ? BigInt(value) : value toTuple(result) return result } @@ -399,7 +385,10 @@ export function toRpc(value: Authorization): Rpc { rules: { ...value.rules, sources: Object.fromEntries( - toRoutes(value.rules).map(({ token, sources }) => [token, sources]), + toRoutes(value.rules).map(({ token, sources }) => [ + token, + sources.map(({ target, data }) => ({ to: target, data })), + ]), ), }, } diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index f91c98dfc..800e831f6 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -40,9 +40,9 @@ describe('toTuple', () => { }) describe('toRpc', () => { - test('maps to to the RPC target field', () => { + test('preserves the source to field', () => { expect(FundingRequirement.toRpc(requirement).sources).toEqual([ - { target, data: '0xab' }, + { to: target, data: '0xab' }, ]) }) @@ -114,12 +114,12 @@ describe('from', () => { }) describe('fromRpc', () => { - test('maps the RPC target field to to', () => { + test('decodes the source to field', () => { expect( FundingRequirement.fromRpc({ token, amount: '0x32', - sources: [{ target, data: '0xab' }], + sources: [{ to: target, data: '0xab' }], }), ).toEqual({ token, amount: 50n, sources: [{ to: target, data: '0xab' }] }) }) diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index db27cfea0..e24cff052 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -26,9 +26,7 @@ export type FundingRequirement = { /** * RPC funding requirement. */ -export type Rpc = Omit, 'sources'> & { - sources: readonly { data: Hex.Hex; target: Address.Address }[] -} +export type Rpc = FundingRequirement /** * RLP funding requirement tuple. */ @@ -171,7 +169,7 @@ export function fromTuple(value: Tuple): FundingRequirement { amount: decode(amount), sources: sources.map((source) => { if (!Array.isArray(source) || source.length !== 2) - throw new InvalidRequirementError('Expected source target and data.') + throw new InvalidRequirementError('Expected source to and data.') return { to: source[0], data: source[1] } }), ...(slippage.length ? { slippageBps: Number(decode(slippage[0]!)) } : {}), @@ -197,7 +195,7 @@ export function fromRpc(value: Rpc): FundingRequirement { const result: FundingRequirement = { ...rest, amount: BigInt(amount), - sources: sources.map(({ target, data }) => ({ to: target, data })), + sources: sources.map(({ to, data }) => ({ to, data })), ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), } assert(result) @@ -228,7 +226,7 @@ export function toRpc( return { ...rest, amount: Quantity.fromNumberish(amount), - sources: sources.map(({ to, data }) => ({ target: to, data })), + sources: sources.map(({ to, data }) => ({ to, data })), ...(slippageBps === undefined ? {} : { slippageBps: Quantity.fromNumberish(slippageBps) }), From d02bec396005ae8f25daf0dde93ad13b6ed41b35 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:32:15 +1000 Subject: [PATCH 21/27] fix: restore funding rpc target fields --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingPolicy.test.ts | 8 ++++---- src/tempo/FundingPolicy.ts | 23 +++++++++++++++++------ src/tempo/FundingRequirement.test.ts | 8 ++++---- src/tempo/FundingRequirement.ts | 10 ++++++---- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 8f04d93a6..0a8fc819f 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules, native DEX and Earn source helpers, access key funding authorization, and unsigned key authorization RPC conversion using `to` for funding sources. +Added Tempo funding codecs, policy rules, native DEX and Earn source helpers, access key funding authorization, and unsigned key authorization RPC conversion. diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index 1f3a528d2..3ab74ec4e 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -72,25 +72,25 @@ describe('toRoutes', () => { }) describe('toRpc', () => { - test('preserves the source to field', () => { + test('keeps the RPC target field', () => { expect(FundingPolicy.toRpc({ admins: [token], rules })).toEqual({ admins: [token], rules: { maxSlippageBps: 100, - sources: { [token]: [{ to: target, data: '0xab' }] }, + sources: { [token]: [{ target, data: '0xab' }] }, }, }) }) }) describe('fromRpc', () => { - test('decodes the source to field', () => { + test('maps RPC sources to to', () => { expect( FundingPolicy.fromRpc({ admins: [token], rules: { maxSlippageBps: 100, - sources: { [token]: [{ to: target, data: '0xab' }] }, + sources: { [token]: [{ target, data: '0xab' }] }, }, }), ).toEqual({ admins: [token], rules }) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index 112e9566d..f756f5e64 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -49,7 +49,7 @@ export type Rpc = /** Maximum aggregate slippage in basis points. */ maxSlippageBps: number /** Ordered source permissions for each output token. */ - sources: Readonly> + sources: Readonly> } } @@ -356,7 +356,21 @@ export function fromTuple(value: Tuple): Authorization { * ``` */ export function fromRpc(value: Rpc): Authorization { - const result = typeof value === 'string' ? BigInt(value) : value + const result = + typeof value === 'string' + ? BigInt(value) + : { + ...value, + rules: { + ...value.rules, + sources: Object.fromEntries( + Object.entries(value.rules.sources).map(([token, sources]) => [ + token, + sources.map(({ target, data }) => ({ to: target, data })), + ]), + ), + }, + } toTuple(result) return result } @@ -385,10 +399,7 @@ export function toRpc(value: Authorization): Rpc { rules: { ...value.rules, sources: Object.fromEntries( - toRoutes(value.rules).map(({ token, sources }) => [ - token, - sources.map(({ target, data }) => ({ to: target, data })), - ]), + toRoutes(value.rules).map(({ token, sources }) => [token, sources]), ), }, } diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index 800e831f6..f91c98dfc 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -40,9 +40,9 @@ describe('toTuple', () => { }) describe('toRpc', () => { - test('preserves the source to field', () => { + test('maps to to the RPC target field', () => { expect(FundingRequirement.toRpc(requirement).sources).toEqual([ - { to: target, data: '0xab' }, + { target, data: '0xab' }, ]) }) @@ -114,12 +114,12 @@ describe('from', () => { }) describe('fromRpc', () => { - test('decodes the source to field', () => { + test('maps the RPC target field to to', () => { expect( FundingRequirement.fromRpc({ token, amount: '0x32', - sources: [{ to: target, data: '0xab' }], + sources: [{ target, data: '0xab' }], }), ).toEqual({ token, amount: 50n, sources: [{ to: target, data: '0xab' }] }) }) diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index e24cff052..db27cfea0 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -26,7 +26,9 @@ export type FundingRequirement = { /** * RPC funding requirement. */ -export type Rpc = FundingRequirement +export type Rpc = Omit, 'sources'> & { + sources: readonly { data: Hex.Hex; target: Address.Address }[] +} /** * RLP funding requirement tuple. */ @@ -169,7 +171,7 @@ export function fromTuple(value: Tuple): FundingRequirement { amount: decode(amount), sources: sources.map((source) => { if (!Array.isArray(source) || source.length !== 2) - throw new InvalidRequirementError('Expected source to and data.') + throw new InvalidRequirementError('Expected source target and data.') return { to: source[0], data: source[1] } }), ...(slippage.length ? { slippageBps: Number(decode(slippage[0]!)) } : {}), @@ -195,7 +197,7 @@ export function fromRpc(value: Rpc): FundingRequirement { const result: FundingRequirement = { ...rest, amount: BigInt(amount), - sources: sources.map(({ to, data }) => ({ to, data })), + sources: sources.map(({ target, data }) => ({ to: target, data })), ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), } assert(result) @@ -226,7 +228,7 @@ export function toRpc( return { ...rest, amount: Quantity.fromNumberish(amount), - sources: sources.map(({ to, data }) => ({ to, data })), + sources: sources.map(({ to, data }) => ({ target: to, data })), ...(slippageBps === undefined ? {} : { slippageBps: Quantity.fromNumberish(slippageBps) }), From 3b2879bf0945a90518b614e13abf3b944f74f037 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:36:50 +1000 Subject: [PATCH 22/27] refactor(tempo): use target for funding sources --- .changeset/tempo-funding.md | 2 +- src/tempo/FundingPolicy.test.ts | 8 ++++---- src/tempo/FundingPolicy.ts | 18 +++++++++--------- src/tempo/FundingRequirement.test-d.ts | 2 +- src/tempo/FundingRequirement.test.ts | 12 ++++++------ src/tempo/FundingRequirement.ts | 14 ++++++-------- src/tempo/FundingSource.test-d.ts | 4 ++-- src/tempo/FundingSource.test.ts | 4 ++-- src/tempo/FundingSource.ts | 2 +- src/tempo/FundingSourceDex.test-d.ts | 2 +- src/tempo/FundingSourceDex.test.ts | 2 +- src/tempo/FundingSourceDex.ts | 2 +- src/tempo/FundingSourceEarn.test-d.ts | 2 +- src/tempo/FundingSourceEarn.test.ts | 2 +- src/tempo/FundingSourceEarn.ts | 2 +- 15 files changed, 38 insertions(+), 40 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 0a8fc819f..418551fbe 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,4 @@ "ox": patch --- -Added Tempo funding codecs, policy rules, native DEX and Earn source helpers, access key funding authorization, and unsigned key authorization RPC conversion. +Added Tempo funding codecs, policy rules, source helpers, and access key funding authorization using consistent `target` and `data` source fields. diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index 3ab74ec4e..f6b7ca4e1 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -6,7 +6,7 @@ const target = '0x0202020202020202020202020202020202020202' as const const requirement = { token, amount: 50n, - sources: [{ to: target, data: '0xab' as const }], + sources: [{ target, data: '0xab' as const }], slippageBps: 100, } @@ -38,7 +38,7 @@ describe('hash', () => { expect(FundingPolicy.encode(first)).toBe(FundingPolicy.encode(reordered)) const sources = [ ...requirement.sources, - { to: target, data: '0xcd' as const }, + { target, data: '0xcd' as const }, ] expect( FundingPolicy.hash({ ...rules, sources: { [token]: sources } }), @@ -84,7 +84,7 @@ describe('toRpc', () => { }) describe('fromRpc', () => { - test('maps RPC sources to to', () => { + test('preserves RPC source targets', () => { expect( FundingPolicy.fromRpc({ admins: [token], @@ -98,7 +98,7 @@ describe('fromRpc', () => { }) describe('fromTuple', () => { - test('decodes source addresses as to', () => { + test('decodes source targets', () => { expect( FundingPolicy.fromTuple([ [token], diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index f756f5e64..bc191564e 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -9,7 +9,7 @@ export type Source = { /** Source-specific configuration passed to funding hooks as `configData`. */ data: Hex.Hex /** Funding source address. */ - to: Address.Address + target: Address.Address } /** @@ -122,7 +122,7 @@ const parameters = [ const domain = Hash.keccak256(Hex.fromString('tempo.funding-policy.rules.v1')) /** - * Converts a token map to canonical ABI routes, mapping `to` to `target` without reordering sources. + * Converts a token map to canonical ABI routes, without reordering sources. * * @example * ```ts @@ -147,10 +147,10 @@ export function toRoutes(rules: Rules): readonly Route[] { if (BigInt(token) === 0n || seen.has(token.toLowerCase())) throw new InvalidPolicyError('Output tokens must be unique and nonzero.') seen.add(token.toLowerCase()) - for (const { to, data } of sources) { - Address.assert(to, { strict: false }) + for (const { target, data } of sources) { + Address.assert(target, { strict: false }) if ( - BigInt(to) === 0n || + BigInt(target) === 0n || !Hex.validate(data, { strict: true }) || data.length % 2 !== 0 ) @@ -158,7 +158,7 @@ export function toRoutes(rules: Rules): readonly Route[] { } return { token, - sources: sources.map(({ to, data }) => ({ target: to, data })), + sources: sources.map(({ target, data }) => ({ target, data })), } }) return routes.sort((a, b) => (BigInt(a.token) < BigInt(b.token) ? -1 : 1)) @@ -199,7 +199,7 @@ export function decode(data: Hex.Hex): Rules { sources: Object.fromEntries( value.routes.map(({ token, sources }) => [ token, - sources.map(({ target, data }) => ({ to: target, data })), + sources.map(({ target, data }) => ({ target, data })), ]), ), } @@ -331,7 +331,7 @@ export function fromTuple(value: Tuple): Authorization { sources[token] = entries.map((source) => { if (!Array.isArray(source) || source.length !== 2) throw new InvalidPolicyError('Invalid source tuple.') - return { to: source[0], data: source[1] } + return { target: source[0], data: source[1] } }) } const policy = { @@ -366,7 +366,7 @@ export function fromRpc(value: Rpc): Authorization { sources: Object.fromEntries( Object.entries(value.rules.sources).map(([token, sources]) => [ token, - sources.map(({ target, data }) => ({ to: target, data })), + sources.map(({ target, data }) => ({ target, data })), ]), ), }, diff --git a/src/tempo/FundingRequirement.test-d.ts b/src/tempo/FundingRequirement.test-d.ts index da095f5b1..2692de4a1 100644 --- a/src/tempo/FundingRequirement.test-d.ts +++ b/src/tempo/FundingRequirement.test-d.ts @@ -37,7 +37,7 @@ test('from', () => { amount: 50n, slippageBps: 0, sources: [ - { to: '0x0202020202020202020202020202020202020202', data: '0xab' }, + { target: '0x0202020202020202020202020202020202020202', data: '0xab' }, ], }) expectTypeOf(requirement.amount).toEqualTypeOf<50n>() diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index f91c98dfc..0e3974367 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -12,7 +12,7 @@ const target = '0x0202020202020202020202020202020202020202' as const const requirement = { token, amount: 50n, - sources: [{ to: target, data: '0xab' as const }], + sources: [{ target, data: '0xab' as const }], slippageBps: 100, } const envelope = TxEnvelopeTempo.from({ @@ -40,7 +40,7 @@ describe('toTuple', () => { }) describe('toRpc', () => { - test('maps to to the RPC target field', () => { + test('preserves source targets', () => { expect(FundingRequirement.toRpc(requirement).sources).toEqual([ { target, data: '0xab' }, ]) @@ -114,14 +114,14 @@ describe('from', () => { }) describe('fromRpc', () => { - test('maps the RPC target field to to', () => { + test('decodes source targets', () => { expect( FundingRequirement.fromRpc({ token, amount: '0x32', sources: [{ target, data: '0xab' }], }), - ).toEqual({ token, amount: 50n, sources: [{ to: target, data: '0xab' }] }) + ).toEqual({ token, amount: 50n, sources: [{ target, data: '0xab' }] }) }) }) @@ -163,8 +163,8 @@ describe('behavior', () => { { ...requirement, amount: 51n }, { ...requirement, slippageBps: 0 }, { ...requirement, policyRules: '0xab' as const }, - { ...requirement, sources: [{ to: token, data: '0xab' as const }] }, - { ...requirement, sources: [{ to: target, data: '0xcd' as const }] }, + { ...requirement, sources: [{ target: token, data: '0xab' as const }] }, + { ...requirement, sources: [{ target, data: '0xcd' as const }] }, ]) { const altered = { ...envelope, requireFunds: [changed] } expect(TxEnvelopeTempo.getSignPayload(altered)).not.toBe( diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index db27cfea0..16d824c0e 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -26,9 +26,7 @@ export type FundingRequirement = { /** * RPC funding requirement. */ -export type Rpc = Omit, 'sources'> & { - sources: readonly { data: Hex.Hex; target: Address.Address }[] -} +export type Rpc = FundingRequirement /** * RLP funding requirement tuple. */ @@ -76,7 +74,7 @@ export function assert(value: FundingRequirement): void { ) throw new InvalidRequirementError('Policy rules must be nonempty bytes.') for (const source of value.sources) { - Address.assert(source.to, { strict: false }) + Address.assert(source.target, { strict: false }) if ( !Hex.validate(source.data, { strict: true }) || source.data.length % 2 !== 0 @@ -122,7 +120,7 @@ export function toTuple(value: FundingRequirement): Tuple { return [ value.token, value.amount === 0n ? '0x' : Hex.fromNumber(BigInt(value.amount)), - value.sources.map(({ to, data }) => [to, data] as const), + value.sources.map(({ target, data }) => [target, data] as const), value.slippageBps === undefined ? [] : [ @@ -172,7 +170,7 @@ export function fromTuple(value: Tuple): FundingRequirement { sources: sources.map((source) => { if (!Array.isArray(source) || source.length !== 2) throw new InvalidRequirementError('Expected source target and data.') - return { to: source[0], data: source[1] } + return { target: source[0], data: source[1] } }), ...(slippage.length ? { slippageBps: Number(decode(slippage[0]!)) } : {}), ...(policyRules !== undefined ? { policyRules } : {}), @@ -197,7 +195,7 @@ export function fromRpc(value: Rpc): FundingRequirement { const result: FundingRequirement = { ...rest, amount: BigInt(amount), - sources: sources.map(({ target, data }) => ({ to: target, data })), + sources: sources.map(({ target, data }) => ({ target, data })), ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), } assert(result) @@ -228,7 +226,7 @@ export function toRpc( return { ...rest, amount: Quantity.fromNumberish(amount), - sources: sources.map(({ to, data }) => ({ target: to, data })), + sources: sources.map(({ target, data }) => ({ target, data })), ...(slippageBps === undefined ? {} : { slippageBps: Quantity.fromNumberish(slippageBps) }), diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts index 771f2d02d..5454196d8 100644 --- a/src/tempo/FundingSource.test-d.ts +++ b/src/tempo/FundingSource.test-d.ts @@ -6,7 +6,7 @@ test('dex', () => { tokenIn: '0x0101010101010101010101010101010101010101', }) expectTypeOf( - source.to, + source.target, ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() expectTypeOf(source).toExtend() }) @@ -17,7 +17,7 @@ test('earn', () => { vault: '0x0101010101010101010101010101010101010101', }) expectTypeOf( - source.to, + source.target, ).toEqualTypeOf<'0x0202020202020202020202020202020202020202'>() expectTypeOf(source.data).toEqualTypeOf<`0x${string}`>() // @ts-expect-error The deployed source address is required. diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts index 059565ca8..028c015a7 100644 --- a/src/tempo/FundingSource.test.ts +++ b/src/tempo/FundingSource.test.ts @@ -7,7 +7,7 @@ describe('dex', () => { test('creates a funding source with the native DEX address', () => { expect(FundingSource.dex({ tokenIn: token, maxAmountIn: 30n })).toEqual({ data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', - to: '0x1120000000000000000000000000000000000001', + target: '0x1120000000000000000000000000000000000001', }) }) }) @@ -23,7 +23,7 @@ describe('earn', () => { }), ).toEqual({ data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000032', - to: '0x0202020202020202020202020202020202020202', + target: '0x0202020202020202020202020202020202020202', }) }) }) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts index b5b897316..417216c04 100644 --- a/src/tempo/FundingSource.ts +++ b/src/tempo/FundingSource.ts @@ -8,7 +8,7 @@ export type Source = { /** ABI-encoded execution data for funding, or configuration data for policies and discovery. */ data: Hex.Hex /** Funding source address. */ - to: Address.Address + target: Address.Address } /** diff --git a/src/tempo/FundingSourceDex.test-d.ts b/src/tempo/FundingSourceDex.test-d.ts index 3c9ea78d4..fc92ea79d 100644 --- a/src/tempo/FundingSourceDex.test-d.ts +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -7,7 +7,7 @@ test('from', () => { tokenIn: '0x0101010101010101010101010101010101010101', }) expectTypeOf( - source.to, + source.target, ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() expectTypeOf(source).toExtend() }) diff --git a/src/tempo/FundingSourceDex.test.ts b/src/tempo/FundingSourceDex.test.ts index ffe4b183b..9c1928c42 100644 --- a/src/tempo/FundingSourceDex.test.ts +++ b/src/tempo/FundingSourceDex.test.ts @@ -8,7 +8,7 @@ describe('from', () => { expect(FundingSourceDex.from({ tokenIn: token, maxAmountIn: 30n })).toEqual( { data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e', - to: '0x1120000000000000000000000000000000000001', + target: '0x1120000000000000000000000000000000000001', }, ) }) diff --git a/src/tempo/FundingSourceDex.ts b/src/tempo/FundingSourceDex.ts index 70009075d..d8f7ccfe9 100644 --- a/src/tempo/FundingSourceDex.ts +++ b/src/tempo/FundingSourceDex.ts @@ -30,7 +30,7 @@ const nativeDexAddress = '0x1120000000000000000000000000000000000001' export function from(request: Request) { return { data: encodeExecutionData(request), - to: nativeDexAddress, + target: nativeDexAddress, } satisfies FundingSource.Source } diff --git a/src/tempo/FundingSourceEarn.test-d.ts b/src/tempo/FundingSourceEarn.test-d.ts index e78516d7e..ee980971e 100644 --- a/src/tempo/FundingSourceEarn.test-d.ts +++ b/src/tempo/FundingSourceEarn.test-d.ts @@ -37,7 +37,7 @@ test('from', () => { vault: '0x0101010101010101010101010101010101010101', }) expectTypeOf( - source.to, + source.target, ).toEqualTypeOf<'0x0202020202020202020202020202020202020202'>() expectTypeOf(source.data).toEqualTypeOf<`0x${string}`>() // @ts-expect-error The deployed source address is required. diff --git a/src/tempo/FundingSourceEarn.test.ts b/src/tempo/FundingSourceEarn.test.ts index 3a47d4522..3e8ceaf98 100644 --- a/src/tempo/FundingSourceEarn.test.ts +++ b/src/tempo/FundingSourceEarn.test.ts @@ -72,7 +72,7 @@ describe('from', () => { }), ).toEqual({ data: '0x0000000000000000000000000101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000032', - to: '0x0202020202020202020202020202020202020202', + target: '0x0202020202020202020202020202020202020202', }) }) }) diff --git a/src/tempo/FundingSourceEarn.ts b/src/tempo/FundingSourceEarn.ts index e90519e45..912068971 100644 --- a/src/tempo/FundingSourceEarn.ts +++ b/src/tempo/FundingSourceEarn.ts @@ -38,7 +38,7 @@ export function from( ) { return { data: encodeExecutionData(request), - to: request.source, + target: request.source, } satisfies FundingSource.Source } From 7ac06e9f15a05541e0a9e722d782918b0a277ba4 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:37:48 +1000 Subject: [PATCH 23/27] style(tempo): format funding sources --- src/tempo/FundingPolicy.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index f6b7ca4e1..9adab506a 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -36,10 +36,7 @@ describe('hash', () => { sources: { [token]: requirement.sources, [second]: requirement.sources }, } expect(FundingPolicy.encode(first)).toBe(FundingPolicy.encode(reordered)) - const sources = [ - ...requirement.sources, - { target, data: '0xcd' as const }, - ] + const sources = [...requirement.sources, { target, data: '0xcd' as const }] expect( FundingPolicy.hash({ ...rules, sources: { [token]: sources } }), ).not.toBe( From c896879e3614633de52f09c5cb6ba2613e3e4f08 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:42:33 +1000 Subject: [PATCH 24/27] feat(tempo): support unsigned funding intent --- .changeset/tempo-funding.md | 8 +- src/tempo/FundingRequirement.test.ts | 69 ++++++++++++- src/tempo/FundingRequirement.ts | 131 ++++++++++++++++++++----- src/tempo/TransactionRequest.test-d.ts | 27 ++++- src/tempo/TransactionRequest.ts | 19 ++-- 5 files changed, 217 insertions(+), 37 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index 418551fbe..bec430443 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,4 +2,10 @@ "ox": patch --- -Added Tempo funding codecs, policy rules, source helpers, and access key funding authorization using consistent `target` and `data` source fields. +Added Tempo funding codecs, unsigned requirement inference, policy rules, source helpers, and access key funding authorization. + +```ts +import { TransactionRequest } from 'ox/tempo' + +TransactionRequest.toRpc({ requireFunds: [{ sources: [] }] }) +``` diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index 0e3974367..f7c927549 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -187,7 +187,9 @@ describe('behavior', () => { TxEnvelopeTempo.serialize({ ...envelope, requireFunds: undefined }), ) const rpc = TransactionRequest.toRpc(envelope) - expect(rpc.requireFunds?.[0]?.amount).toBe('0x32') + expect(rpc.requireFunds !== true && rpc.requireFunds?.[0]?.amount).toBe( + '0x32', + ) expect(TransactionRequest.fromRpc(rpc).requireFunds).toEqual([requirement]) }) @@ -269,3 +271,68 @@ describe('behavior', () => { ) }) }) + +describe('unsigned intent', () => { + const intents = [ + {}, + { token }, + { amount: 0n, sources: [] }, + { sources: [{ target, data: '0xab' as const }], slippageBps: 0 }, + ] as const + + test('round-trips omitted fields, zero amounts, and explicit empty sources', () => { + const rpc = intents.map(FundingRequirement.toRpcIntent) + expect(rpc).toEqual([ + {}, + { token }, + { amount: '0x0', sources: [] }, + { sources: [{ target, data: '0xab' }], slippageBps: '0x0' }, + ]) + expect(rpc.map(FundingRequirement.fromRpcIntent)).toEqual(intents) + }) + + test.each([true, [], intents] as const)( + 'round-trips transaction intent (%s)', + (requireFunds) => { + const rpc = TransactionRequest.toRpc({ requireFunds }) + expect(rpc.type).toBe('0x76') + expect(TransactionRequest.fromRpc(rpc).requireFunds).toEqual(requireFunds) + }, + ) + + test('keeps resolved RPC and signed codecs strict', () => { + for (const intent of intents) { + expect(() => FundingRequirement.toTuple(intent as never)).toThrow() + expect(() => FundingRequirement.toRpc(intent as never)).toThrow() + expect(() => + FundingRequirement.fromRpc( + FundingRequirement.toRpcIntent(intent) as never, + ), + ).toThrow() + expect(() => + TxEnvelopeTempo.serialize({ + ...envelope, + requireFunds: [intent], + } as never), + ).toThrow() + } + expect(() => + TxEnvelopeTempo.serialize({ ...envelope, requireFunds: true } as never), + ).toThrow() + }) + + test.each([ + { token: '0x1234' }, + { amount: -1n }, + { amount: 2n ** 256n }, + { amount: Number.MAX_SAFE_INTEGER + 1 }, + { slippageBps: 10_001 }, + { policyRules: '0x' }, + { sources: [{ target, data: '0x1' }] }, + { sources: null }, + ])('rejects invalid supplied fields (%s)', (intent) => { + expect(() => + TransactionRequest.toRpc({ requireFunds: [intent] } as never), + ).toThrow() + }) +}) diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index 16d824c0e..0b0a0e026 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -28,6 +28,16 @@ export type FundingRequirement = { * RPC funding requirement. */ export type Rpc = FundingRequirement +/** Unsigned funding fields to resolve before signing. */ +export type Intent = { + [key in keyof FundingRequirement]?: + | FundingRequirement[key] + | undefined +} + +/** RPC funding intent with optional token, amount, and sources. */ +export type IntentRpc = Intent + /** * RLP funding requirement tuple. */ export type Tuple = readonly [ @@ -49,38 +59,16 @@ export type Tuple = readonly [ * FundingRequirement.assert({ token, amount: 50n, sources: [] }) * ``` */ -export function assert(value: FundingRequirement): void { +export function assert(value: Intent): asserts value is FundingRequirement { if (!Array.isArray(value.sources)) throw new InvalidRequirementError( 'Funding sources must be resolved before signing.', ) - Address.assert(value.token, { strict: false }) - if (value.amount < 0n || value.amount >= 2n ** 256n) - throw new InvalidRequirementError('Amount must fit uint256.') - if ( - value.slippageBps !== undefined && - (!Number.isInteger(value.slippageBps) || - value.slippageBps < 0 || - value.slippageBps > 10_000) - ) + if (value.token === undefined || value.amount === undefined) throw new InvalidRequirementError( - 'Slippage must be between 0 and 10,000 basis points.', - ) - if ( - value.policyRules !== undefined && - (!Hex.validate(value.policyRules, { strict: true }) || - value.policyRules === '0x' || - value.policyRules.length % 2 !== 0) - ) - throw new InvalidRequirementError('Policy rules must be nonempty bytes.') - for (const source of value.sources) { - Address.assert(source.target, { strict: false }) - if ( - !Hex.validate(source.data, { strict: true }) || - source.data.length % 2 !== 0 + 'Funding token and amount must be resolved before signing.', ) - throw new InvalidRequirementError('Source data must be bytes.') - } + assertFields(value) } /** @@ -233,6 +221,97 @@ export function toRpc( } } +/** + * Decodes unsigned funding intent, preserving omitted fields for inference. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * FundingRequirement.fromRpcIntent({ amount: '0x32' }) + * ``` + */ +export function fromRpcIntent(value: IntentRpc): Intent { + const { amount, slippageBps, sources, ...rest } = value + const result = { + ...rest, + ...(amount === undefined ? {} : { amount: BigInt(amount) }), + ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), + ...(sources === undefined + ? {} + : { sources: sources.map(({ target, data }) => ({ target, data })) }), + } + assertFields(result) + return result +} + +/** + * Encodes unsigned funding intent without filling omitted fields. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * FundingRequirement.toRpcIntent({ amount: 50n }) + * ``` + */ +export function toRpcIntent( + value: Intent, +): IntentRpc { + assertFields({ + ...value, + amount: value.amount === undefined ? undefined : BigInt(value.amount), + slippageBps: + value.slippageBps === undefined ? undefined : Number(value.slippageBps), + }) + const { amount, slippageBps, sources, ...rest } = value + return { + ...rest, + ...(amount === undefined ? {} : { amount: Quantity.fromNumberish(amount) }), + ...(slippageBps === undefined + ? {} + : { slippageBps: Quantity.fromNumberish(slippageBps) }), + ...(sources === undefined + ? {} + : { sources: sources.map(({ target, data }) => ({ target, data })) }), + } +} + +function assertFields(value: Intent): void { + if (value.sources !== undefined && !Array.isArray(value.sources)) + throw new InvalidRequirementError('Funding sources must be an array.') + if (value.token !== undefined) Address.assert(value.token, { strict: false }) + if ( + value.amount !== undefined && + (value.amount < 0n || value.amount >= 2n ** 256n) + ) + throw new InvalidRequirementError('Amount must fit uint256.') + if ( + value.slippageBps !== undefined && + (!Number.isInteger(value.slippageBps) || + value.slippageBps < 0 || + value.slippageBps > 10_000) + ) + throw new InvalidRequirementError( + 'Slippage must be between 0 and 10,000 basis points.', + ) + if ( + value.policyRules !== undefined && + (!Hex.validate(value.policyRules, { strict: true }) || + value.policyRules === '0x' || + value.policyRules.length % 2 !== 0) + ) + throw new InvalidRequirementError('Policy rules must be nonempty bytes.') + for (const source of value.sources ?? []) { + Address.assert(source.target, { strict: false }) + if ( + !Hex.validate(source.data, { strict: true }) || + source.data.length % 2 !== 0 + ) + throw new InvalidRequirementError('Source data must be bytes.') + } +} + /** * Thrown when funding fields violate the protocol encoding. */ export class InvalidRequirementError extends Errors.BaseError { diff --git a/src/tempo/TransactionRequest.test-d.ts b/src/tempo/TransactionRequest.test-d.ts index f50f19cb8..21576dad6 100644 --- a/src/tempo/TransactionRequest.test-d.ts +++ b/src/tempo/TransactionRequest.test-d.ts @@ -1,6 +1,7 @@ -import { expectTypeOf, test } from 'vitest' +import { describe, expectTypeOf, test } from 'vitest' import type * as MultisigSimulation from './MultisigSimulation.js' -import type * as TransactionRequest from './TransactionRequest.js' +import * as TransactionRequest from './TransactionRequest.js' +import type * as TxEnvelopeTempo from './TxEnvelopeTempo.js' declare const request: TransactionRequest.TransactionRequest declare const rpc: TransactionRequest.Rpc @@ -23,3 +24,25 @@ test('RPC requests use encoded multisig configurations', () => { `0x${string}` | undefined >() }) + +describe('funding intent', () => { + test('accepts partial unsigned requirements but keeps envelopes strict', () => { + const partial = { sources: [] } as const + expectTypeOf(partial).toExtend< + Exclude< + NonNullable, + true + >[number] + >() + expectTypeOf(partial).not.toExtend< + NonNullable[number] + >() + const rpc = TransactionRequest.toRpc({ + requireFunds: [{ amount: 0n }, partial], + }) + expectTypeOf(rpc).toEqualTypeOf() + TransactionRequest.toRpc({ requireFunds: true }) + TransactionRequest.fromRpc({ requireFunds: [{ amount: '0x0' }, partial] }) + TransactionRequest.fromRpc({ requireFunds: true }) + }) +}) diff --git a/src/tempo/TransactionRequest.ts b/src/tempo/TransactionRequest.ts index f2522d7a7..fec822a79 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -35,9 +35,10 @@ export type TransactionRequest< authorizationList?: | AuthorizationTempo.ListSigned | undefined - /** Required balances before application calls. */ + /** Required balances before application calls. Use true or omit fields to request inference before signing. */ requireFunds?: - | readonly FundingRequirement.FundingRequirement[] + | true + | readonly FundingRequirement.Intent[] | undefined calls?: readonly Call[] | undefined feePayer?: boolean | undefined @@ -63,7 +64,7 @@ export type Rpc = Omit< | 'multisigSimulation' | 'keyAuthorizationSimulation' > & { - requireFunds?: readonly FundingRequirement.Rpc[] | undefined + requireFunds?: true | readonly FundingRequirement.IntentRpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feeToken?: Hex.Hex | undefined keyAuthorization?: KeyAuthorization.Rpc | undefined @@ -126,7 +127,10 @@ export function fromRpc(request: Rpc): TransactionRequest { if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken if (request.requireFunds) - request_.requireFunds = request.requireFunds.map(FundingRequirement.fromRpc) + request_.requireFunds = + request.requireFunds === true + ? true + : request.requireFunds.map(FundingRequirement.fromRpcIntent) if (request.keyAuthorization) request_.keyAuthorization = KeyAuthorization.fromRpc( request.keyAuthorization, @@ -234,9 +238,10 @@ export function toRpc(request: TransactionRequest): Rpc { if (typeof request.feeToken !== 'undefined') request_rpc.feeToken = TokenId.toAddress(request.feeToken) if (request.requireFunds) - request_rpc.requireFunds = request.requireFunds.map( - FundingRequirement.toRpc, - ) + request_rpc.requireFunds = + request.requireFunds === true + ? true + : request.requireFunds.map(FundingRequirement.toRpcIntent) if (request.keyAuthorization) request_rpc.keyAuthorization = KeyAuthorization.toRpc( request.keyAuthorization, From ad30b6777643b7eb2102b7e841f88f4922abb745 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:51:21 +1000 Subject: [PATCH 25/27] refactor(tempo): rename funding request types --- src/tempo/FundingRequirement.test.ts | 6 +++--- src/tempo/FundingRequirement.ts | 20 ++++++++++---------- src/tempo/TransactionRequest.ts | 8 ++++---- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index f7c927549..20c9b1e46 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -281,14 +281,14 @@ describe('unsigned intent', () => { ] as const test('round-trips omitted fields, zero amounts, and explicit empty sources', () => { - const rpc = intents.map(FundingRequirement.toRpcIntent) + const rpc = intents.map(FundingRequirement.toRpcRequest) expect(rpc).toEqual([ {}, { token }, { amount: '0x0', sources: [] }, { sources: [{ target, data: '0xab' }], slippageBps: '0x0' }, ]) - expect(rpc.map(FundingRequirement.fromRpcIntent)).toEqual(intents) + expect(rpc.map(FundingRequirement.fromRpcRequest)).toEqual(intents) }) test.each([true, [], intents] as const)( @@ -306,7 +306,7 @@ describe('unsigned intent', () => { expect(() => FundingRequirement.toRpc(intent as never)).toThrow() expect(() => FundingRequirement.fromRpc( - FundingRequirement.toRpcIntent(intent) as never, + FundingRequirement.toRpcRequest(intent) as never, ), ).toThrow() expect(() => diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index 0b0a0e026..2a9831852 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -29,14 +29,14 @@ export type FundingRequirement = { export type Rpc = FundingRequirement /** Unsigned funding fields to resolve before signing. */ -export type Intent = { +export type Request = { [key in keyof FundingRequirement]?: | FundingRequirement[key] | undefined } /** RPC funding intent with optional token, amount, and sources. */ -export type IntentRpc = Intent +export type RequestRpc = Request /** * RLP funding requirement tuple. */ @@ -59,7 +59,7 @@ export type Tuple = readonly [ * FundingRequirement.assert({ token, amount: 50n, sources: [] }) * ``` */ -export function assert(value: Intent): asserts value is FundingRequirement { +export function assert(value: Request): asserts value is FundingRequirement { if (!Array.isArray(value.sources)) throw new InvalidRequirementError( 'Funding sources must be resolved before signing.', @@ -228,10 +228,10 @@ export function toRpc( * ```ts * import { FundingRequirement } from 'ox/tempo' * - * FundingRequirement.fromRpcIntent({ amount: '0x32' }) + * FundingRequirement.fromRpcRequest({ amount: '0x32' }) * ``` */ -export function fromRpcIntent(value: IntentRpc): Intent { +export function fromRpcRequest(value: RequestRpc): Request { const { amount, slippageBps, sources, ...rest } = value const result = { ...rest, @@ -252,12 +252,12 @@ export function fromRpcIntent(value: IntentRpc): Intent { * ```ts * import { FundingRequirement } from 'ox/tempo' * - * FundingRequirement.toRpcIntent({ amount: 50n }) + * FundingRequirement.toRpcRequest({ amount: 50n }) * ``` */ -export function toRpcIntent( - value: Intent, -): IntentRpc { +export function toRpcRequest( + value: Request, +): RequestRpc { assertFields({ ...value, amount: value.amount === undefined ? undefined : BigInt(value.amount), @@ -277,7 +277,7 @@ export function toRpcIntent( } } -function assertFields(value: Intent): void { +function assertFields(value: Request): void { if (value.sources !== undefined && !Array.isArray(value.sources)) throw new InvalidRequirementError('Funding sources must be an array.') if (value.token !== undefined) Address.assert(value.token, { strict: false }) diff --git a/src/tempo/TransactionRequest.ts b/src/tempo/TransactionRequest.ts index fec822a79..208ba45aa 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -38,7 +38,7 @@ export type TransactionRequest< /** Required balances before application calls. Use true or omit fields to request inference before signing. */ requireFunds?: | true - | readonly FundingRequirement.Intent[] + | readonly FundingRequirement.Request[] | undefined calls?: readonly Call[] | undefined feePayer?: boolean | undefined @@ -64,7 +64,7 @@ export type Rpc = Omit< | 'multisigSimulation' | 'keyAuthorizationSimulation' > & { - requireFunds?: true | readonly FundingRequirement.IntentRpc[] | undefined + requireFunds?: true | readonly FundingRequirement.RequestRpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feeToken?: Hex.Hex | undefined keyAuthorization?: KeyAuthorization.Rpc | undefined @@ -130,7 +130,7 @@ export function fromRpc(request: Rpc): TransactionRequest { request_.requireFunds = request.requireFunds === true ? true - : request.requireFunds.map(FundingRequirement.fromRpcIntent) + : request.requireFunds.map(FundingRequirement.fromRpcRequest) if (request.keyAuthorization) request_.keyAuthorization = KeyAuthorization.fromRpc( request.keyAuthorization, @@ -241,7 +241,7 @@ export function toRpc(request: TransactionRequest): Rpc { request_rpc.requireFunds = request.requireFunds === true ? true - : request.requireFunds.map(FundingRequirement.toRpcIntent) + : request.requireFunds.map(FundingRequirement.toRpcRequest) if (request.keyAuthorization) request_rpc.keyAuthorization = KeyAuthorization.toRpc( request.keyAuthorization, From 59d87c6270f5caf2c52592c9bc9536befa02217a Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:22:23 +1000 Subject: [PATCH 26/27] feat(tempo): support optional funding source ordering --- .changeset/tempo-funding.md | 2 +- .github/workflows/verify.yml | 9 +-- src/tempo/FundingPolicy.test.ts | 100 ++++++++++++++++++++++++- src/tempo/FundingPolicy.ts | 33 ++++++-- src/tempo/FundingRequirement.test-d.ts | 6 ++ src/tempo/FundingRequirement.test.ts | 1 + src/tempo/e2e.test.ts | 96 +++++++++++++++++++++++- test/tempo/prool.ts | 6 +- 8 files changed, 233 insertions(+), 20 deletions(-) diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index bec430443..737ae9043 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -2,7 +2,7 @@ "ox": patch --- -Added Tempo funding codecs, unsigned requirement inference, policy rules, source helpers, and access key funding authorization. +Added Tempo funding codecs, unsigned requirement inference, policy rules with optional source ordering, source helpers, and access key funding authorization. ```ts import { TransactionRequest } from 'ox/tempo' diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6470b548c..e1bc8c281 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -86,12 +86,9 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - # `edge` tracks `tempoxyz/tempo` main and includes unreleased - # features the tests depend on (e.g. TIP-1049 admin keys). The - # `latest` tag is pinned to released versions and lags behind. - tag: - - edge - # - https://rpc.moderato.tempo.xyz + # TIP-1120 image from tempoxyz/tempo/actions/runs/36383271463. + tag: + - sha-6e27f81 steps: - name: Clone repository diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index 9adab506a..27ce1b93a 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest' +import * as Rlp from '../core/Rlp.js' import * as FundingPolicy from './FundingPolicy.js' +import * as KeyAuthorization from './KeyAuthorization.js' const token = '0x0101010101010101010101010101010101010101' as const const target = '0x0202020202020202020202020202020202020202' as const @@ -11,6 +13,7 @@ const requirement = { } const rules = { + enforceOrder: false, maxSlippageBps: 100, sources: { [token]: requirement.sources }, } @@ -73,6 +76,7 @@ describe('toRpc', () => { expect(FundingPolicy.toRpc({ admins: [token], rules })).toEqual({ admins: [token], rules: { + enforceOrder: false, maxSlippageBps: 100, sources: { [token]: [{ target, data: '0xab' }] }, }, @@ -86,6 +90,7 @@ describe('fromRpc', () => { FundingPolicy.fromRpc({ admins: [token], rules: { + enforceOrder: false, maxSlippageBps: 100, sources: { [token]: [{ target, data: '0xab' }] }, }, @@ -99,8 +104,101 @@ describe('fromTuple', () => { expect( FundingPolicy.fromTuple([ [token], - ['0x64', [[token, [[target, '0xab']]]]], + ['0x64', [[token, [[target, '0xab']]]], '0x'], ]), ).toEqual({ admins: [token], rules }) }) }) + +describe('behavior', () => { + test.each([undefined, false, true])( + 'round-trips ordering (%s)', + (enforceOrder) => { + const policy = { admins: [token], rules: { ...rules, enforceOrder } } + const expected = { + ...policy, + rules: { ...policy.rules, enforceOrder: enforceOrder ?? false }, + } + expect(FundingPolicy.decode(FundingPolicy.encode(policy.rules))).toEqual( + expected.rules, + ) + expect(FundingPolicy.fromTuple(FundingPolicy.toTuple(policy))).toEqual( + expected, + ) + expect(FundingPolicy.fromRpc(FundingPolicy.toRpc(policy))).toEqual(policy) + const authorization = { + address: target, + chainId: 1n, + fundingPolicy: policy, + type: 'secp256k1' as const, + } + expect( + KeyAuthorization.deserialize(KeyAuthorization.serialize(authorization)), + ).toEqual({ + ...authorization, + fundingPolicy: expected, + }) + }, + ) + + test('omitted ordering encodes identically to false', () => { + const { enforceOrder: _, ...unordered } = rules + expect(FundingPolicy.encode(unordered)).toBe(FundingPolicy.encode(rules)) + expect(FundingPolicy.hash(unordered)).toBe(FundingPolicy.hash(rules)) + expect( + FundingPolicy.toTuple({ admins: [token], rules: unordered }), + ).toEqual(FundingPolicy.toTuple({ admins: [token], rules })) + }) + + test('ordering changes the commitment and authorization signature', () => { + const ordered = { ...rules, enforceOrder: true } + expect(FundingPolicy.hash(ordered)).not.toBe(FundingPolicy.hash(rules)) + const authorization = { + address: target, + chainId: 1n, + fundingPolicy: { admins: [token], rules }, + type: 'secp256k1' as const, + } + expect(KeyAuthorization.getSignPayload(authorization)).not.toBe( + KeyAuthorization.getSignPayload({ + ...authorization, + fundingPolicy: { admins: [token], rules: ordered }, + }), + ) + }) + + test('uses the canonical rules wire layout', () => { + for (const enforceOrder of [false, true]) { + const empty = { enforceOrder, maxSlippageBps: 0, sources: {} } + const abi = `0x${[ + '20', // Dynamic tuple offset. + '00', // Slippage. + '60', // Routes offset within the tuple. + enforceOrder ? '01' : '00', + '00', // Empty routes. + ] + .map((word) => word.padStart(64, '0')) + .join('')}` + expect(FundingPolicy.encode(empty)).toBe(abi) + const tuple = FundingPolicy.toTuple({ admins: [token], rules: empty }) + if (typeof tuple === 'string') + throw new Error('Expected an inline policy.') + expect(Rlp.fromHex(tuple[1])).toBe( + enforceOrder ? '0xc380c001' : '0xc380c080', + ) + } + }) + + test('rejects invalid and missing ordering flags', () => { + for (const enforceOrder of [0, 1, 'false', null]) + expect(() => + FundingPolicy.encode({ ...rules, enforceOrder } as never), + ).toThrowErrorMatchingInlineSnapshot( + `[FundingPolicy.InvalidPolicyError: Ordering enforcement must be a boolean.]`, + ) + for (const suffix of [[], ['0x00'], ['0x02'], ['0x0001']]) + expect(() => + FundingPolicy.fromTuple([[token], ['0x', [], ...suffix]] as never), + ).toThrow() + }) +}) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index bc191564e..3bcd6ffc3 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -15,11 +15,13 @@ export type Source = { /** * Funding permissions, represented by output token. */ export type Rules = { + /** Enforce policy source order. Defaults to `false`. */ + enforceOrder?: boolean | undefined /** * Maximum aggregate slippage in basis points. */ maxSlippageBps: number /** - * Ordered source permissions for each output token. */ + * Source permissions for each output token. */ sources: Readonly> } @@ -46,9 +48,11 @@ export type Rpc = admins: readonly Address.Address[] /** Committed funding permissions. */ rules: { + /** Enforce policy source order. Defaults to `false`. */ + enforceOrder?: boolean | undefined /** Maximum aggregate slippage in basis points. */ maxSlippageBps: number - /** Ordered source permissions for each output token. */ + /** Source permissions for each output token. */ sources: Readonly> } } @@ -71,7 +75,7 @@ export type Route = { * Output token. */ token: Address.Address /** - * Ordered source permissions. */ + * Source permissions. */ sources: readonly { /** Funding source address in the contract ABI. */ target: Address.Address @@ -92,6 +96,7 @@ export type Tuple = Hex.Hex, readonly (readonly [Hex.Hex, Hex.Hex])[], ])[], + '0x' | '0x01', ], ] @@ -115,6 +120,7 @@ const parameters = [ }, ], }, + { name: 'enforceOrder', type: 'bool' }, ], }, ] as const @@ -133,6 +139,11 @@ const domain = Hash.keccak256(Hex.fromString('tempo.funding-policy.rules.v1')) * ``` */ export function toRoutes(rules: Rules): readonly Route[] { + if ( + rules.enforceOrder !== undefined && + typeof rules.enforceOrder !== 'boolean' + ) + throw new InvalidPolicyError('Ordering enforcement must be a boolean.') if ( !Number.isInteger(rules.maxSlippageBps) || rules.maxSlippageBps < 0 || @@ -177,7 +188,11 @@ export function toRoutes(rules: Rules): readonly Route[] { */ export function encode(rules: Rules): Hex.Hex { return AbiParameters.encode(parameters, [ - { maxSlippageBps: rules.maxSlippageBps, routes: toRoutes(rules) }, + { + enforceOrder: rules.enforceOrder ?? false, + maxSlippageBps: rules.maxSlippageBps, + routes: toRoutes(rules), + }, ]) } @@ -195,6 +210,7 @@ export function encode(rules: Rules): Hex.Hex { export function decode(data: Hex.Hex): Rules { const [value] = AbiParameters.decode(parameters, data) const rules: Rules = { + enforceOrder: value.enforceOrder, maxSlippageBps: value.maxSlippageBps, sources: Object.fromEntries( value.routes.map(({ token, sources }) => [ @@ -276,6 +292,7 @@ export function toTuple(value: Authorization): Tuple { sources.map(({ target, data }) => [target, data] as const), ] as const, ), + value.rules.enforceOrder ? '0x01' : '0x', ], ] } @@ -307,15 +324,16 @@ export function fromTuple(value: Tuple): Authorization { value.length !== 2 || !Array.isArray(value[0]) || !Array.isArray(value[1]) || - value[1].length !== 2 + value[1].length !== 3 ) throw new InvalidPolicyError('Invalid inline policy tuple.') - const [admins, [slippage, routes]] = value + const [admins, [slippage, routes, enforceOrder]] = value if ( typeof slippage !== 'string' || !Hex.validate(slippage, { strict: true }) || slippage.startsWith('0x00') || - !Array.isArray(routes) + !Array.isArray(routes) || + (enforceOrder !== '0x' && enforceOrder !== '0x01') ) throw new InvalidPolicyError('Invalid policy rules tuple.') const sources: Record = {} @@ -337,6 +355,7 @@ export function fromTuple(value: Tuple): Authorization { const policy = { admins, rules: { + enforceOrder: enforceOrder === '0x01', maxSlippageBps: slippage === '0x' ? 0 : Number(slippage), sources, }, diff --git a/src/tempo/FundingRequirement.test-d.ts b/src/tempo/FundingRequirement.test-d.ts index 2692de4a1..4289147b8 100644 --- a/src/tempo/FundingRequirement.test-d.ts +++ b/src/tempo/FundingRequirement.test-d.ts @@ -23,6 +23,12 @@ test('funding requirements use executable values', () => { }) test('policy authorization preserves ID and inline types', () => { + expectTypeOf().toEqualTypeOf< + boolean | undefined + >() + expectTypeOf< + Exclude['rules']['enforceOrder'] + >().toEqualTypeOf() expectTypeOf().toEqualTypeOf< FundingPolicy.Authorization | undefined >() diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index 20c9b1e46..e0bb6b010 100644 --- a/src/tempo/FundingRequirement.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -151,6 +151,7 @@ describe('behavior', () => { }) const rules = { + enforceOrder: false, maxSlippageBps: 100, sources: { [token]: requirement.sources, diff --git a/src/tempo/e2e.test.ts b/src/tempo/e2e.test.ts index f8d7ded2c..bfeb43157 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -8,11 +8,13 @@ import { WebAuthnP256, WebCryptoP256, } from 'ox' -import { getTransactionCount } from 'viem/actions' +import { getTransactionCount, readContract } from 'viem/actions' import { beforeEach, describe, expect, test } from 'vitest' import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, + FundingPolicy, + FundingSourceDex, KeyAuthorization, Period, SignatureEnvelope, @@ -893,6 +895,98 @@ describe('behavior: keyAuthorization', () => { }) }) + test.each([undefined, false, true])( + 'behavior: funding policy ordering (%s)', + async (enforceOrder) => { + const privateKey = Secp256k1.randomPrivateKey() + const address = Address.fromPublicKey( + Secp256k1.getPublicKey({ privateKey }), + ) + const rules = { + enforceOrder, + maxSlippageBps: 100, + sources: { + '0x20c0000000000000000000000000000000000001': [ + FundingSourceDex.from({ + tokenIn: '0x20c0000000000000000000000000000000000002', + }), + ], + }, + } + const authorization = KeyAuthorization.from({ + address, + chainId: BigInt(chainId), + fundingPolicy: { admins: [root.address], rules }, + type: 'secp256k1', + }) + const transaction = TxEnvelopeTempo.from({ + calls: [{ to: '0x0000000000000000000000000000000000000000' }], + chainId, + feeToken: '0x20c0000000000000000000000000000000000001', + gas: 5_000_000n, + keyAuthorization: KeyAuthorization.from(authorization, { + signature: SignatureEnvelope.from( + Secp256k1.sign({ + payload: KeyAuthorization.getSignPayload(authorization), + privateKey: root.privateKey, + }), + ), + }), + maxFeePerGas: Value.fromGwei('20'), + maxPriorityFeePerGas: Value.fromGwei('10'), + nonce: BigInt( + await getTransactionCount(client, { + address: root.address, + blockTag: 'pending', + }), + ), + }) + const serialized = TxEnvelopeTempo.serialize(transaction, { + signature: SignatureEnvelope.from({ + inner: SignatureEnvelope.from( + Secp256k1.sign({ + payload: TxEnvelopeTempo.getSignPayload(transaction, { + from: root.address, + }), + privateKey, + }), + ), + type: 'keychain', + userAddress: root.address, + }), + }) + const receipt = await client.request({ + method: 'eth_sendRawTransactionSync', + params: [serialized], + }) + expect(receipt?.status).toBe('0x1') + const policyId = await readContract(client, { + abi: [ + AbiFunction.from( + 'function getFundingPolicyId(address account, address keyId) view returns (uint64)', + ), + ], + address: '0xaaaaaaaa00000000000000000000000000000000', + args: [root.address, address], + functionName: 'getFundingPolicyId', + }) + const policy = await readContract(client, { + abi: [ + AbiFunction.from( + 'function getPolicy(uint64 policyId) view returns ((address[] admins, bytes32 rulesHash) policy)', + ), + ], + address: '0x1120000000000000000000000000000000000002', + args: [policyId], + functionName: 'getPolicy', + }) + expect(policy.admins.map((admin) => admin.toLowerCase())).toEqual([ + root.address.toLowerCase(), + ]) + expect(policy.rulesHash).toBe(FundingPolicy.hash(rules)) + }, + ) + test('behavior: secp256k1 access key', async () => { const privateKey = '0x06a952d58c24d287245276dd8b4272d82a921d27d90874a6c27a3bc19ff4bfde' diff --git a/test/tempo/prool.ts b/test/tempo/prool.ts index 9a004a3ab..40e986056 100644 --- a/test/tempo/prool.ts +++ b/test/tempo/prool.ts @@ -22,10 +22,8 @@ export const rpcUrl = (() => { export async function createServer(options: createServer.Options = {}) { const serverPort = options.port ?? port const tag = await (async () => { - // Default to `edge` which tracks `tempoxyz/tempo` main and includes - // unreleased features the tests depend on (e.g. TIP-1049 admin keys). - // The `latest` tag lags behind released versions. - const envTag = options.tag ?? import.meta.env.VITE_TEMPO_TAG ?? 'edge' + const envTag = + options.tag ?? import.meta.env.VITE_TEMPO_TAG ?? 'sha-6e27f81' if (!envTag.startsWith('http')) return envTag const transport = RpcTransport.fromHttp(envTag) From 1089162d3bfca26282ab9a1d9b1d2358a4c9f4a3 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:32:36 +1000 Subject: [PATCH 27/27] test(tempo): remove funding policy e2e tests --- .github/workflows/verify.yml | 7 ++- src/tempo/e2e.test.ts | 96 +----------------------------------- test/tempo/prool.ts | 6 ++- 3 files changed, 10 insertions(+), 99 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index e1bc8c281..504a3a2d2 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -86,9 +86,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - # TIP-1120 image from tempoxyz/tempo/actions/runs/36383271463. + # `edge` tracks `tempoxyz/tempo` main and includes unreleased + # features the tests depend on (e.g. TIP-1049 admin keys). The + # `latest` tag is pinned to released versions and lags behind. tag: - - sha-6e27f81 + - edge + # - https://rpc.moderato.tempo.xyz steps: - name: Clone repository diff --git a/src/tempo/e2e.test.ts b/src/tempo/e2e.test.ts index bfeb43157..f8d7ded2c 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -8,13 +8,11 @@ import { WebAuthnP256, WebCryptoP256, } from 'ox' -import { getTransactionCount, readContract } from 'viem/actions' +import { getTransactionCount } from 'viem/actions' import { beforeEach, describe, expect, test } from 'vitest' import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, - FundingPolicy, - FundingSourceDex, KeyAuthorization, Period, SignatureEnvelope, @@ -895,98 +893,6 @@ describe('behavior: keyAuthorization', () => { }) }) - test.each([undefined, false, true])( - 'behavior: funding policy ordering (%s)', - async (enforceOrder) => { - const privateKey = Secp256k1.randomPrivateKey() - const address = Address.fromPublicKey( - Secp256k1.getPublicKey({ privateKey }), - ) - const rules = { - enforceOrder, - maxSlippageBps: 100, - sources: { - '0x20c0000000000000000000000000000000000001': [ - FundingSourceDex.from({ - tokenIn: '0x20c0000000000000000000000000000000000002', - }), - ], - }, - } - const authorization = KeyAuthorization.from({ - address, - chainId: BigInt(chainId), - fundingPolicy: { admins: [root.address], rules }, - type: 'secp256k1', - }) - const transaction = TxEnvelopeTempo.from({ - calls: [{ to: '0x0000000000000000000000000000000000000000' }], - chainId, - feeToken: '0x20c0000000000000000000000000000000000001', - gas: 5_000_000n, - keyAuthorization: KeyAuthorization.from(authorization, { - signature: SignatureEnvelope.from( - Secp256k1.sign({ - payload: KeyAuthorization.getSignPayload(authorization), - privateKey: root.privateKey, - }), - ), - }), - maxFeePerGas: Value.fromGwei('20'), - maxPriorityFeePerGas: Value.fromGwei('10'), - nonce: BigInt( - await getTransactionCount(client, { - address: root.address, - blockTag: 'pending', - }), - ), - }) - const serialized = TxEnvelopeTempo.serialize(transaction, { - signature: SignatureEnvelope.from({ - inner: SignatureEnvelope.from( - Secp256k1.sign({ - payload: TxEnvelopeTempo.getSignPayload(transaction, { - from: root.address, - }), - privateKey, - }), - ), - type: 'keychain', - userAddress: root.address, - }), - }) - const receipt = await client.request({ - method: 'eth_sendRawTransactionSync', - params: [serialized], - }) - expect(receipt?.status).toBe('0x1') - const policyId = await readContract(client, { - abi: [ - AbiFunction.from( - 'function getFundingPolicyId(address account, address keyId) view returns (uint64)', - ), - ], - address: '0xaaaaaaaa00000000000000000000000000000000', - args: [root.address, address], - functionName: 'getFundingPolicyId', - }) - const policy = await readContract(client, { - abi: [ - AbiFunction.from( - 'function getPolicy(uint64 policyId) view returns ((address[] admins, bytes32 rulesHash) policy)', - ), - ], - address: '0x1120000000000000000000000000000000000002', - args: [policyId], - functionName: 'getPolicy', - }) - expect(policy.admins.map((admin) => admin.toLowerCase())).toEqual([ - root.address.toLowerCase(), - ]) - expect(policy.rulesHash).toBe(FundingPolicy.hash(rules)) - }, - ) - test('behavior: secp256k1 access key', async () => { const privateKey = '0x06a952d58c24d287245276dd8b4272d82a921d27d90874a6c27a3bc19ff4bfde' diff --git a/test/tempo/prool.ts b/test/tempo/prool.ts index 40e986056..9a004a3ab 100644 --- a/test/tempo/prool.ts +++ b/test/tempo/prool.ts @@ -22,8 +22,10 @@ export const rpcUrl = (() => { export async function createServer(options: createServer.Options = {}) { const serverPort = options.port ?? port const tag = await (async () => { - const envTag = - options.tag ?? import.meta.env.VITE_TEMPO_TAG ?? 'sha-6e27f81' + // Default to `edge` which tracks `tempoxyz/tempo` main and includes + // unreleased features the tests depend on (e.g. TIP-1049 admin keys). + // The `latest` tag lags behind released versions. + const envTag = options.tag ?? import.meta.env.VITE_TEMPO_TAG ?? 'edge' if (!envTag.startsWith('http')) return envTag const transport = RpcTransport.fromHttp(envTag)