From 0c2226385ecbf66d646d5bdbed4d63c5df0639be Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:20:47 +1000 Subject: [PATCH 01/28] feat(tempo): add funding requirements and policies --- .changeset/tempo-funding.md | 19 + package.json | 25 + src/tempo/Funding.test-d.ts | 28 + src/tempo/Funding.test.ts | 227 ++++++++ src/tempo/Funding.ts | 226 ++++++++ src/tempo/FundingPolicy.ts | 354 ++++++++++++ src/tempo/KeyAuthorization.ts | 59 +- src/tempo/NativeDexFunding.ts | 50 ++ src/tempo/Transaction.ts | 9 + src/tempo/TransactionRequest.ts | 11 + src/tempo/TxEnvelopeTempo.ts | 54 +- src/tempo/funding.e2e.test.ts | 856 ++++++++++++++++++++++++++++ src/tempo/index.ts | 20 + src/zod/tempo/Funding.ts | 61 ++ src/zod/tempo/FundingPolicy.ts | 47 ++ src/zod/tempo/KeyAuthorization.ts | 4 + src/zod/tempo/Transaction.ts | 15 +- src/zod/tempo/TransactionRequest.ts | 10 + src/zod/tempo/TxEnvelopeTempo.ts | 2 + src/zod/tempo/_test/Funding.test.ts | 74 +++ src/zod/tempo/z.ts | 2 + test/tempo/funding.md | 26 + test/tempo/funding.ts | 5 + test/tempo/prool.ts | 2 +- test/tempo/setup.global.funding.ts | 7 + vite.config.ts | 20 + 26 files changed, 2187 insertions(+), 26 deletions(-) create mode 100644 .changeset/tempo-funding.md create mode 100644 src/tempo/Funding.test-d.ts create mode 100644 src/tempo/Funding.test.ts create mode 100644 src/tempo/Funding.ts create mode 100644 src/tempo/FundingPolicy.ts create mode 100644 src/tempo/NativeDexFunding.ts create mode 100644 src/tempo/funding.e2e.test.ts create mode 100644 src/zod/tempo/Funding.ts create mode 100644 src/zod/tempo/FundingPolicy.ts create mode 100644 src/zod/tempo/_test/Funding.test.ts create mode 100644 test/tempo/funding.md create mode 100644 test/tempo/funding.ts create mode 100644 test/tempo/setup.global.funding.ts diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md new file mode 100644 index 000000000..a474640b7 --- /dev/null +++ b/.changeset/tempo-funding.md @@ -0,0 +1,19 @@ +--- +"ox": minor +--- + +Added Tempo funding transaction codecs, committed policy rules, native DEX funding payloads, and access key funding authorization. + +```ts +import { FundingPolicy, NativeDexFunding } from 'ox/tempo' + +const policyRules = FundingPolicy.encode({ + maxSlippageBps: 100, + sources: { + [outputToken]: [{ + target: nativeDexSource, + data: NativeDexFunding.encode({ tokenIn: inputToken, maxAmountIn: 30_000_000n }), + }], + }, +}) +``` diff --git a/package.json b/package.json index 3ef1d7b86..c7a351828 100644 --- a/package.json +++ b/package.json @@ -781,6 +781,16 @@ "types": "./dist/tempo/EarnShares.d.ts", "default": "./dist/tempo/EarnShares.js" }, + "./tempo/Funding": { + "src": "./src/tempo/Funding.ts", + "types": "./dist/tempo/Funding.d.ts", + "default": "./dist/tempo/Funding.js" + }, + "./tempo/FundingPolicy": { + "src": "./src/tempo/FundingPolicy.ts", + "types": "./dist/tempo/FundingPolicy.d.ts", + "default": "./dist/tempo/FundingPolicy.js" + }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", @@ -801,6 +811,11 @@ "types": "./dist/tempo/MultisigSimulation.d.ts", "default": "./dist/tempo/MultisigSimulation.js" }, + "./tempo/NativeDexFunding": { + "src": "./src/tempo/NativeDexFunding.ts", + "types": "./dist/tempo/NativeDexFunding.d.ts", + "default": "./dist/tempo/NativeDexFunding.js" + }, "./tempo/Period": { "src": "./src/tempo/Period.ts", "types": "./dist/tempo/Period.d.ts", @@ -1226,6 +1241,16 @@ "types": "./dist/zod/tempo/AuthorizationTempo.d.ts", "default": "./dist/zod/tempo/AuthorizationTempo.js" }, + "./zod/tempo/Funding": { + "src": "./src/zod/tempo/Funding.ts", + "types": "./dist/zod/tempo/Funding.d.ts", + "default": "./dist/zod/tempo/Funding.js" + }, + "./zod/tempo/FundingPolicy": { + "src": "./src/zod/tempo/FundingPolicy.ts", + "types": "./dist/zod/tempo/FundingPolicy.d.ts", + "default": "./dist/zod/tempo/FundingPolicy.js" + }, "./zod/tempo/KeyAuthorization": { "src": "./src/zod/tempo/KeyAuthorization.ts", "types": "./dist/zod/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/Funding.test-d.ts b/src/tempo/Funding.test-d.ts new file mode 100644 index 000000000..23db44800 --- /dev/null +++ b/src/tempo/Funding.test-d.ts @@ -0,0 +1,28 @@ +import { expectTypeOf, test } from 'vp/test' +import * as Funding from './Funding.js' +import * as FundingPolicy from './FundingPolicy.js' +import * as KeyAuthorization from './KeyAuthorization.js' +import * as TxEnvelopeTempo from './TxEnvelopeTempo.js' + +test('funding requirements use executable values', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | undefined + >() + expectTypeOf().toEqualTypeOf< + readonly Funding.Requirement[] | undefined + >() + expectTypeOf().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}`>() +}) diff --git a/src/tempo/Funding.test.ts b/src/tempo/Funding.test.ts new file mode 100644 index 000000000..fb7840a9a --- /dev/null +++ b/src/tempo/Funding.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from 'vp/test' +import type * as Hex from '../core/Hex.js' +import * as Rlp from '../core/Rlp.js' +import * as Funding from './Funding.js' +import * as FundingPolicy from './FundingPolicy.js' +import * as KeyAuthorization from './KeyAuthorization.js' +import * as NativeDexFunding from './NativeDexFunding.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('funding transaction codecs', () => { + test('matches the Rust requirement golden vector', () => { + // tempo 4926397: funding::funding_requirement_golden. + expect(Rlp.fromHex(Funding.toTuple(requirement))).toBe( + '0xf194010101010101010101010101010101010101010132d8d794020202020202020202020202020202020202020281abc164', + ) + }) + 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('distinguishes omitted and explicit zero slippage', () => { + expect( + Funding.toTuple({ ...requirement, slippageBps: undefined })[3], + ).toEqual([]) + expect(Funding.toTuple({ ...requirement, slippageBps: 0 })[3]).toEqual([ + '0x', + ]) + }) + 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(() => Funding.toTuple({ ...requirement, amount: -1n })).toThrow() + expect(() => + Funding.toTuple({ ...requirement, slippageBps: 10_001 }), + ).toThrow() + expect(() => + Funding.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() + }) +}) + +describe('malformed funding encodings', () => { + test('rejects unsafe numeric RPC inputs', () => { + expect(() => + Funding.toRpc({ ...requirement, amount: Number.MAX_SAFE_INTEGER + 1 }), + ).toThrow() + expect(() => FundingPolicy.toRpc(Number.MAX_SAFE_INTEGER + 1)).toThrow() + }) + + 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(() => Funding.fromTuple(tuple as never)).toThrow() + }) + test('accepts uint256 maximum and rejects overflow', () => { + const value = { ...requirement, amount: 2n ** 256n - 1n } + expect(Funding.fromTuple(Funding.toTuple(value))).toEqual(value) + expect(() => Funding.toTuple({ ...value, amount: 2n ** 256n })).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', [Funding.toTuple(requirement)], [], '0x'], + ]) + expect(() => + TxEnvelopeTempo.deserialize( + `0x76${Rlp.fromHex([...base, ...trailing] as never).slice(2)}`, + ), + ).toThrow() + }) +}) + +describe('policy codecs', () => { + const rules = { + maxSlippageBps: 100, + sources: { [token]: requirement.sources }, + } + 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() + }) + 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', + ) + }) + 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() }, + }), + ) + }) + 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() + }) +}) + +describe('native DEX payload', () => { + 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/Funding.ts b/src/tempo/Funding.ts new file mode 100644 index 000000000..301de0613 --- /dev/null +++ b/src/tempo/Funding.ts @@ -0,0 +1,226 @@ +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 Requirement = { + /** + * 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 = Requirement + +/** + * 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 { Funding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * Funding.assert({ token, amount: 50n, sources: [] }) + * ``` + */ +export function assert(value: Requirement): 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.') + } +} + +/** + * Converts a funding requirement to its RLP tuple. + * + * @example + * ```ts + * import { Funding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * Funding.toTuple({ token, amount: 50n, sources: [] }) + * ``` + */ +export function toTuple(value: Requirement): 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 { Funding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * Funding.fromTuple([token, '0x32', [], []]) + * ``` + */ +export function fromTuple(value: Tuple): Requirement { + 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: Requirement = { + 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 { Funding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * Funding.fromRpc({ token, amount: '0x32', sources: [] }) + * ``` + */ +export function fromRpc(value: Rpc): Requirement { + const { amount, slippageBps, ...rest } = value + const result: Requirement = { + ...rest, + amount: BigInt(amount), + ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), + } + assert(result) + return result +} + +/** + * Converts a funding requirement to RPC quantities. + * + * @example + * ```ts + * import { Funding } from 'ox/tempo' + * + * const token = '0x20c0000000000000000000000000000000000001' as const + * Funding.toRpc({ token, amount: 50n, sources: [] }) + * ``` + */ +export function toRpc( + value: Requirement, +): 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 = 'Funding.InvalidRequirementError' + constructor(message: string) { + super(message) + } +} diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts new file mode 100644 index 000000000..18b59aafd --- /dev/null +++ b/src/tempo/FundingPolicy.ts @@ -0,0 +1,354 @@ +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 Funding from './Funding.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> +} + +/** + * 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 Funding.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/KeyAuthorization.ts b/src/tempo/KeyAuthorization.ts index 15e397484..f2f07e7b6 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -10,6 +10,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' /** @@ -46,6 +47,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. */ @@ -106,6 +109,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. */ @@ -174,6 +179,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[]] @@ -442,6 +457,8 @@ export function from< selector?: Hex.Hex | string }[] } + if (auth.fundingPolicy !== undefined) + FundingPolicy.toTuple(auth.fundingPolicy) if (auth.witness !== undefined) assertWitness(auth.witness) if (auth.signature) assertSignature(auth.signature) const resolved = { @@ -576,6 +593,9 @@ export function fromRpc(authorization: Rpc): Signed { signature, type: keyType, ...(witness !== undefined ? { witness } : {}), + ...(authorization.fundingPolicy !== undefined + ? { fundingPolicy: FundingPolicy.fromRpc(authorization.fundingPolicy) } + : {}), ...(account !== undefined ? { account } : {}), ...(isAdmin ? { isAdmin: true as const } : {}), } @@ -666,8 +686,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 @@ -723,6 +754,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 } : {}), } @@ -1006,6 +1044,9 @@ export function toRpc(authorization: toRpc.Input): Rpc { ...(allowedCalls ? { allowedCalls } : {}), ...(witness !== undefined ? { witness } : {}), ...(isAdmin ? { isAdmin: true } : {}), + ...(authorization.fundingPolicy !== undefined + ? { fundingPolicy: FundingPolicy.toRpc(authorization.fundingPolicy) } + : {}), ...(account !== undefined ? { account } : {}), } } @@ -1132,7 +1173,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: @@ -1150,8 +1194,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.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 8cf52b460..b3e96d201 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -7,6 +7,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 Funding from './Funding.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as SignatureEnvelope from './SignatureEnvelope.js' import type { Call } from './TxEnvelopeTempo.js' @@ -79,6 +80,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 Funding.Requirement[] + | undefined keyAuthorization?: | KeyAuthorization.KeyAuthorization | undefined @@ -251,6 +256,8 @@ export function fromRpc< transaction_.validAfter = Number(transaction.validAfter) if (transaction.validBefore) transaction_.validBefore = Number(transaction.validBefore) + if (transaction.requireFunds) + transaction_.requireFunds = transaction.requireFunds.map(Funding.fromRpc) if (transaction.keyAuthorization) transaction_.keyAuthorization = KeyAuthorization.fromRpc( transaction.keyAuthorization, @@ -362,6 +369,8 @@ export function toRpc( data: call.data, })) if (transaction.feeToken) rpc.feeToken = transaction.feeToken + if (transaction.requireFunds) + rpc.requireFunds = transaction.requireFunds.map(Funding.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 7cc3a56a3..cdc73b4f7 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -6,6 +6,7 @@ import type { Compute } from '../core/internal/types.js' import * as Signature from '../core/Signature.js' import * as ox_TransactionRequest from '../core/TransactionRequest.js' import * as AuthorizationTempo from './AuthorizationTempo.js' +import * as Funding from './Funding.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as MultisigSimulation from './MultisigSimulation.js' import * as SignatureEnvelope from './SignatureEnvelope.js' @@ -40,6 +41,10 @@ export type TransactionRequest< feePayer?: boolean | undefined feePayerSignature?: Signature.Signature | null | undefined feeToken?: Address.Address | undefined + /** Required balances before application calls. */ + requireFunds?: + | readonly Funding.Requirement[] + | undefined keyAuthorization?: KeyAuthorization.KeyAuthorization | undefined keyAuthorizationSimulation?: MultisigSimulation.Spec | undefined keyData?: Hex.Hex | undefined @@ -133,6 +138,8 @@ export function fromRpc(request: Rpc): TransactionRequest { }) if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken + if (request.requireFunds) + request_.requireFunds = request.requireFunds.map(Funding.fromRpc) if (request.keyAuthorization) request_.keyAuthorization = KeyAuthorization.fromRpc( request.keyAuthorization, @@ -230,6 +237,7 @@ export function toRpc(request: toRpc.Input): Rpc { typeof request.capabilities !== 'undefined' || typeof request.feePayer !== 'undefined' || typeof request.feeToken !== 'undefined' || + typeof request.requireFunds !== 'undefined' || typeof request.keyAuthorization !== 'undefined' || typeof request.keyData !== 'undefined' || typeof request.keyId !== 'undefined' || @@ -272,6 +280,8 @@ export function toRpc(request: toRpc.Input): Rpc { !(request.feePayer === true && !request.feePayerSignature) ) request_rpc.feeToken = request.feeToken + if (request.requireFunds) + request_rpc.requireFunds = request.requireFunds.map(Funding.toRpc) if (request.keyAuthorization) request_rpc.keyAuthorization = KeyAuthorization.toRpc( request.keyAuthorization, @@ -404,6 +414,7 @@ export function toEnvelope( : {}), ...(typeof request.from !== 'undefined' ? { from: request.from } : {}), ...(typeof request.gas !== 'undefined' ? { gas: request.gas } : {}), + ...(request.requireFunds ? { requireFunds: request.requireFunds } : {}), ...(typeof request.keyAuthorization !== 'undefined' ? { keyAuthorization: request.keyAuthorization } : {}), diff --git a/src/tempo/TxEnvelopeTempo.ts b/src/tempo/TxEnvelopeTempo.ts index b2fec7be2..79ac79de5 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 Funding from './Funding.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as SignatureEnvelope from './SignatureEnvelope.js' import type * as TransactionRequest from './TransactionRequest.js' @@ -98,6 +99,10 @@ export type TxEnvelopeTempo< keyAuthorization?: | KeyAuthorization.Signed | undefined + /** Required balances processed before application calls. */ + requireFunds?: + | readonly Funding.Requirement[] + | undefined /** Total fee per gas in wei (gasPrice/baseFeePerGas + maxPriorityFeePerGas). */ maxFeePerGas?: bigintType | undefined /** Max priority fee per gas (in wei). */ @@ -180,6 +185,9 @@ export function assert(envelope: PartialBy) { validAfter, } = envelope + for (const requirement of envelope.requireFunds ?? []) + Funding.assert(requirement) + // Calls must not be empty if (!calls || calls.length === 0) throw new CallsEmptyError() @@ -279,23 +287,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: { @@ -313,15 +322,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) => + Funding.fromTuple(value as Funding.Tuple), + ) let transaction = { chainId: Number(chainId), @@ -382,6 +390,8 @@ export function deserialize(serialized: Serialized): Compute { ) } + if (requireFunds) transaction.requireFunds = requireFunds + if (keyAuthorization) transaction.keyAuthorization = KeyAuthorization.fromTuple( keyAuthorization as never, @@ -649,6 +659,7 @@ export function serialize( feeToken, gas, keyAuthorization, + requireFunds, nonce, nonceKey, maxFeePerGas, @@ -727,7 +738,12 @@ export function serialize( !skipFeeToken && feeToken ? feeToken : '0x', feePayerSignatureOrSender, authorizationTupleList, - ...(keyAuthorization ? [KeyAuthorization.toTuple(keyAuthorization)] : []), + ...(keyAuthorization + ? [KeyAuthorization.toTuple(keyAuthorization)] + : requireFunds?.length + ? ['0x' as const] + : []), + ...(requireFunds?.length ? [requireFunds.map(Funding.toTuple)] : []), ...(signature ? [SignatureEnvelope.serialize(SignatureEnvelope.from(signature))] : []), diff --git a/src/tempo/funding.e2e.test.ts b/src/tempo/funding.e2e.test.ts new file mode 100644 index 000000000..89e1dce49 --- /dev/null +++ b/src/tempo/funding.e2e.test.ts @@ -0,0 +1,856 @@ +import { + AbiFunction, + AbiParameters, + Address, + Hash, + Hex, + P256, + WebAuthnP256, + Secp256k1, +} from 'ox' +import { afterAll, beforeAll, describe, expect, test } from 'vp/test' +import { accounts } from '../../test/constants/accounts.js' +import { rpcUrl } from '../../test/tempo/prool.js' +import * as Funding from './Funding.js' +import * as FundingPolicy from './FundingPolicy.js' +import * as KeyAuthorization from './KeyAuthorization.js' +import * as NativeDexFunding from './NativeDexFunding.js' +import * as SignatureEnvelope from './SignatureEnvelope.js' +import * as Transaction from './Transaction.js' +import * as TxEnvelopeTempo from './TxEnvelopeTempo.js' + +const maker = accounts[0] +const output = '0x20c0000000000000000000000000000000000000' as const +const dex = '0xdec0000000000000000000000000000000000000' as const +const factory = '0x20fc000000000000000000000000000000000000' as const +const source = '0x1120000000000000000000000000000000000001' as const +const policyAddress = '0x1120000000000000000000000000000000000002' as const +const recipient = '0x8888888888888888888888888888888888888888' as const +const unit = 1_000_000n +const max = 2n ** 256n - 1n +const transfer = AbiFunction.from( + 'function transfer(address to, uint256 amount) returns (bool)', +) +const mint = AbiFunction.from('function mint(address to, uint256 amount)') +const balanceOf = AbiFunction.from( + 'function balanceOf(address account) view returns (uint256)', +) +const createPolicy = AbiFunction.from( + 'function createPolicy(address[] admins, (uint16 maxSlippageBps, (address token, (address target, bytes data)[] sources)[] routes) rules) returns (uint64)', +) +const getPolicy = AbiFunction.from( + 'function getPolicy(uint64 policyId) view returns ((address[] admins, bytes32 rulesHash))', +) +let chainId: number +let inputs: Address.Address[] + +type Receipt = { + status: Hex.Hex + transactionHash: Hex.Hex + logs: readonly { + address: Address.Address + topics: readonly Hex.Hex[] + data: Hex.Hex + }[] +} +async function rpc( + method: string, + params: readonly unknown[] = [], +): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + }) + const result = (await response.json()) as { + result: T + error?: { message: string } + } + if (result.error) throw new Error(result.error.message) + return result.result +} + +function wallet() { + const privateKey = Secp256k1.randomPrivateKey() + return { + privateKey, + address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey })), + } +} + +async function send( + owner: { address: Address.Address; privateKey: Hex.Hex }, + request: Partial = {}, + access?: ReturnType & { + sign?: (payload: Hex.Hex) => SignatureEnvelope.Primitive + }, +) { + const nonce = BigInt( + await rpc('eth_getTransactionCount', [owner.address, 'pending']), + ) + const tx = TxEnvelopeTempo.from({ + chainId, + calls: [{ to: recipient }], + gas: 20_000_000n, + maxFeePerGas: 20_000_000_000n, + nonce, + feeToken: output, + feePayerSignature: owner.address === maker.address ? undefined : null, + ...request, + }) + const payload = TxEnvelopeTempo.getSignPayload( + tx, + access ? { from: owner.address } : {}, + ) + const inner = access?.sign + ? access.sign(payload) + : SignatureEnvelope.from( + Secp256k1.sign({ + privateKey: access?.privateKey ?? owner.privateKey, + payload, + }), + ) + const signature = access + ? SignatureEnvelope.from({ + type: 'keychain', + userAddress: owner.address, + inner, + }) + : inner + const feePayerSignature = Secp256k1.sign({ + privateKey: maker.privateKey, + payload: TxEnvelopeTempo.getFeePayerSignPayload(tx, { + sender: owner.address, + }), + }) + const serialized = TxEnvelopeTempo.serialize(tx, { + signature, + ...(tx.feePayerSignature === undefined ? {} : { feePayerSignature }), + }) + return rpc('eth_sendRawTransactionSync', [serialized]) +} + +async function balance(token: Address.Address, account: Address.Address) { + return AbiFunction.decodeResult( + balanceOf, + await rpc('eth_call', [ + { to: token, data: AbiFunction.encodeData(balanceOf, [account]) }, + 'latest', + ]), + ) +} + +function requirement(policyRules?: Hex.Hex): Funding.Requirement { + return { + token: output, + amount: 50n * unit, + policyRules, + slippageBps: 100, + sources: inputs.map((tokenIn, i) => ({ + target: source, + data: NativeDexFunding.encode({ + tokenIn, + maxAmountIn: i === 0 ? 30n * unit : undefined, + }), + })), + } +} +function rules(): FundingPolicy.Rules { + return { maxSlippageBps: 100, sources: { [output]: requirement().sources } } +} +async function owner() { + const account = wallet() + expect( + ( + await send(maker, { + calls: inputs.map((to) => ({ + to, + data: AbiFunction.encodeData(mint, [account.address, 500n * unit]), + })), + }) + ).status, + ).toBe('0x1') + return account +} +function authorization( + root: ReturnType, + key: ReturnType, + fundingPolicy: FundingPolicy.Authorization, + limit = 50n * unit, +) { + const value = { + chainId: BigInt(chainId), + address: key.address, + type: 'secp256k1' as const, + limits: [{ token: output, limit }], + fundingPolicy, + } + return KeyAuthorization.from(value, { + signature: SignatureEnvelope.from( + Secp256k1.sign({ + privateKey: root.privateKey, + payload: KeyAuthorization.getSignPayload(value), + }), + ), + }) +} + +beforeAll(async () => { + expect(await rpc('web3_clientVersion')).toContain('4926397') + chainId = Number(await rpc('eth_chainId')) + const tokenAddress = AbiFunction.from( + 'function getTokenAddress(address sender, bytes32 salt) view returns (address)', + ) + const createToken = AbiFunction.from( + 'function createToken(string name, string symbol, string currency, address quoteToken, address admin, bytes32 salt) returns (address)', + ) + const grantRole = AbiFunction.from( + 'function grantRole(bytes32 role, address account)', + ) + const approve = AbiFunction.from( + 'function approve(address spender, uint256 amount) returns (bool)', + ) + const place = AbiFunction.from( + 'function place(address token, uint128 amount, bool isBid, int16 tick) returns (uint128)', + ) + expect( + ( + await send(maker, { + calls: [ + { to: output, data: AbiFunction.encodeData(approve, [dex, max]) }, + ], + }) + ).status, + ).toBe('0x1') + inputs = [] + for (let i = 0; i < 2; i++) { + const salt = Hex.random(32) + const token = AbiFunction.decodeResult( + tokenAddress, + await rpc('eth_call', [ + { + to: factory, + data: AbiFunction.encodeData(tokenAddress, [maker.address, salt]), + }, + 'latest', + ]), + ) + expect( + ( + await send(maker, { + calls: [ + { + to: factory, + data: AbiFunction.encodeData(createToken, [ + 'Funding input', + i === 0 ? 'USDC.e' : 'OUSD', + 'USD', + output, + maker.address, + salt, + ]), + }, + ], + }) + ).status, + ).toBe('0x1') + expect( + ( + await send(maker, { + calls: [ + { + to: token, + data: AbiFunction.encodeData(grantRole, [ + Hash.keccak256(Hex.fromString('ISSUER_ROLE')), + maker.address, + ]), + }, + { + to: dex, + data: AbiFunction.encodeData(place, [ + token, + 100_000n * unit, + true, + 0, + ]), + }, + ], + }) + ).status, + ).toBe('0x1') + inputs.push(token) + } +}) +afterAll(async () => { + await fetch(`${rpcUrl}/stop`) +}) + +describe('owner funding', () => { + test('funds 30 from the first source and 20 from the second, with a sponsor', async () => { + const account = await owner() + const receipt = await send(account, { + requireFunds: [requirement()], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), + }, + ], + }) + expect(receipt.status).toBe('0x1') + expect(await balance(inputs[0]!, account.address)).toBe(470n * unit) + expect(await balance(inputs[1]!, account.address)).toBe(480n * unit) + expect(await balance(output, account.address)).toBe(0n) + const tx = Transaction.fromRpc( + await rpc('eth_getTransactionByHash', [ + receipt.transactionHash, + ]), + ) + expect(tx.requireFunds).toEqual([requirement()]) + }) + test('application failure rolls back input debits', async () => { + const account = await owner() + const receipt = await send(account, { + requireFunds: [requirement()], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 51n * unit]), + }, + ], + }) + expect(receipt.status).toBe('0x0') + expect(await balance(inputs[0]!, account.address)).toBe(500n * unit) + expect(await balance(output, account.address)).toBe(0n) + }) +}) + +describe('policy funding', () => { + test('creates a policy with the same hash as Ox', async () => { + const value = rules() + const receipt = await send(maker, { + calls: [ + { + to: policyAddress, + data: AbiFunction.encodeData(createPolicy, [ + [maker.address], + { + maxSlippageBps: value.maxSlippageBps, + routes: FundingPolicy.toRoutes(value), + }, + ]), + }, + ], + }) + expect(receipt.status).toBe('0x1') + const event = receipt.logs.find( + (log) => log.address.toLowerCase() === policyAddress, + )! + const policyId = BigInt(event.topics[1]!) + const result = await rpc('eth_call', [ + { + to: policyAddress, + data: AbiFunction.encodeData(getPolicy, [policyId]), + }, + 'latest', + ]) + const [policy] = AbiParameters.decode( + [ + { + type: 'tuple', + components: [ + { name: 'admins', type: 'address[]' }, + { name: 'rulesHash', type: 'bytes32' }, + ], + }, + ], + result, + ) + expect(policy.rulesHash).toBe(FundingPolicy.hash(value)) + const root = await owner() + const key = wallet() + const payment = await send( + root, + { + keyAuthorization: authorization(root, key, policyId), + requireFunds: [requirement(FundingPolicy.encode(value))], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), + }, + ], + }, + key, + ) + expect(payment.status).toBe('0x1') + }) + test('creates inline policy, installs key, funds and pays without double charging', async () => { + const root = await owner() + const key = wallet() + const value = rules() + const receipt = await send( + root, + { + keyAuthorization: authorization(root, key, { + admins: [root.address], + rules: value, + }), + requireFunds: [requirement(FundingPolicy.encode(value))], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), + }, + ], + }, + key, + ) + expect(receipt.status).toBe('0x1') + expect(await balance(inputs[0]!, root.address)).toBe(470n * unit) + const exhausted = await send( + root, + { requireFunds: [requirement(FundingPolicy.encode(value))] }, + key, + ) + expect(exhausted.status).toBe('0x0') + expect(await balance(inputs[0]!, root.address)).toBe(470n * unit) + }) +}) + +describe('funding authorization boundaries', () => { + test('reuses an installed key, then rejects the exhausted output budget', async () => { + const root = await owner() + const key = wallet() + const value = rules() + const installed = await send( + root, + { + keyAuthorization: authorization( + root, + key, + { admins: [root.address], rules: value }, + 100n * unit, + ), + }, + key, + ) + expect(installed.status).toBe('0x1') + for (let i = 0; i < 2; i++) { + const payment = await send( + root, + { + requireFunds: [requirement(FundingPolicy.encode(value))], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), + }, + ], + }, + key, + ) + expect(payment.status).toBe('0x1') + } + expect( + ( + await send( + root, + { requireFunds: [requirement(FundingPolicy.encode(value))] }, + key, + ) + ).status, + ).toBe('0x0') + expect(await balance(inputs[0]!, root.address)).toBe(440n * unit) + }) + + test('rejects missing, tampered and mismatched rules before debiting inputs', async () => { + const root = await owner() + const key = wallet() + const value = rules() + expect( + ( + await send( + root, + { + keyAuthorization: authorization(root, key, { + admins: [root.address], + rules: value, + }), + }, + key, + ) + ).status, + ).toBe('0x1') + for (const policyRules of [ + undefined, + '0xab' as const, + FundingPolicy.encode({ ...value, maxSlippageBps: 101 }), + ]) { + expect( + (await send(root, { requireFunds: [requirement(policyRules)] }, key)) + .status, + ).toBe('0x0') + expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) + } + }) + + test('rejects excessive slippage, reversed sources and unapproved output', async () => { + const root = await owner() + const key = wallet() + const value = rules() + expect( + ( + await send( + root, + { + keyAuthorization: authorization(root, key, { + admins: [root.address], + rules: value, + }), + }, + key, + ) + ).status, + ).toBe('0x1') + const request = requirement(FundingPolicy.encode(value)) + for (const entry of [ + { ...request, slippageBps: 101 }, + { ...request, sources: [...request.sources].reverse() }, + { ...request, token: inputs[0]! }, + { + ...request, + sources: [ + { + target: source, + data: NativeDexFunding.encode({ tokenIn: output }), + }, + ], + }, + ]) { + expect((await send(root, { requireFunds: [entry] }, key)).status).toBe( + '0x0', + ) + expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) + } + }) + + test('uses existing balances and treats repeated requirements as target balances', async () => { + const root = await owner() + expect( + ( + await send(maker, { + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [ + root.address, + 50n * unit, + ]), + }, + ], + }) + ).status, + ).toBe('0x1') + const entry = { + token: output, + amount: 50n * unit, + sources: [], + slippageBps: 0, + } + expect( + ( + await send(root, { + requireFunds: [entry, entry], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), + }, + ], + }) + ).status, + ).toBe('0x1') + expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) + }) + + test('insufficient input and caller caps roll back earlier contributions', async () => { + const root = await owner() + const entry = { ...requirement(), sources: [requirement().sources[0]!] } + expect((await send(root, { requireFunds: [entry] })).status).toBe('0x0') + expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) + const empty = wallet() + expect((await send(empty, { requireFunds: [requirement()] })).status).toBe( + '0x0', + ) + }) + + test('owner funding rejects policy rules', async () => { + const root = await owner() + expect( + ( + await send(root, { + requireFunds: [requirement(FundingPolicy.encode(rules()))], + }) + ).status, + ).toBe('0x0') + }) + + test('updated policy rules invalidate the previous witness', async () => { + const root = await owner() + const key = wallet() + const value = rules() + const installed = await send( + root, + { + keyAuthorization: authorization(root, key, { + admins: [root.address], + rules: value, + }), + }, + key, + ) + expect(installed.status).toBe('0x1') + const event = installed.logs.find( + (log) => log.address.toLowerCase() === policyAddress, + )! + const id = BigInt(event.topics[1]!) + const setRules = AbiFunction.from( + 'function setRules(uint64 policyId, (uint16 maxSlippageBps, (address token, (address target, bytes data)[] sources)[] routes) rules)', + ) + const updated = { ...value, maxSlippageBps: 50 } + expect( + ( + await send(root, { + calls: [ + { + to: policyAddress, + data: AbiFunction.encodeData(setRules, [ + id, + { maxSlippageBps: 50, routes: FundingPolicy.toRoutes(updated) }, + ]), + }, + ], + }) + ).status, + ).toBe('0x1') + expect( + ( + await send( + root, + { requireFunds: [requirement(FundingPolicy.encode(value))] }, + key, + ) + ).status, + ).toBe('0x0') + expect( + ( + await send( + root, + { + requireFunds: [ + { + ...requirement(FundingPolicy.encode(updated)), + slippageBps: undefined, + }, + ], + }, + key, + ) + ).status, + ).toBe('0x1') + }) +}) + +describe('key validity and signature families', () => { + test.each(['p256', 'webAuthn'] as const)( + 'funds with a %s access key', + async (type) => { + const root = await owner() + const privateKey = P256.randomPrivateKey() + const publicKey = P256.getPublicKey({ privateKey }) + const key = { + address: Address.fromPublicKey(publicKey), + privateKey, + sign(payload: Hex.Hex): SignatureEnvelope.Primitive { + if (type === 'p256') + return { + type, + publicKey, + prehash: false, + signature: P256.sign({ payload, privateKey }), + } + const webAuthn = WebAuthnP256.getSignPayload({ + challenge: payload, + rpId: 'localhost', + origin: 'http://localhost', + }) + return { + type, + publicKey, + metadata: webAuthn.metadata, + signature: P256.sign({ + payload: webAuthn.payload, + privateKey, + hash: true, + }), + } + }, + } + const policy = rules() + const unsigned = { + chainId: BigInt(chainId), + address: key.address, + type, + fundingPolicy: { admins: [root.address], rules: policy }, + limits: [{ token: output, limit: 50n * unit }], + } + const keyAuthorization = KeyAuthorization.from(unsigned, { + signature: SignatureEnvelope.from( + Secp256k1.sign({ + privateKey: root.privateKey, + payload: KeyAuthorization.getSignPayload(unsigned), + }), + ), + }) + expect( + ( + await send( + root, + { + keyAuthorization, + requireFunds: [requirement(FundingPolicy.encode(policy))], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [ + recipient, + 50n * unit, + ]), + }, + ], + }, + key, + ) + ).status, + ).toBe('0x1') + }, + ) + + test('rejects a revoked funding key', async () => { + const root = await owner() + const key = wallet() + const value = rules() + expect( + ( + await send( + root, + { + keyAuthorization: authorization(root, key, { + admins: [root.address], + rules: value, + }), + }, + key, + ) + ).status, + ).toBe('0x1') + const revoke = AbiFunction.from('function revokeKey(address keyId)') + expect( + ( + await send(root, { + calls: [ + { + to: '0xaaaaaaaa00000000000000000000000000000000', + data: AbiFunction.encodeData(revoke, [key.address]), + }, + ], + }) + ).status, + ).toBe('0x1') + await expect( + send( + root, + { requireFunds: [requirement(FundingPolicy.encode(value))] }, + key, + ), + ).rejects.toThrow() + expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) + }) +}) + +test('owner funding also works without a sponsor and cannot fund its own fees', async () => { + const root = await owner() + await expect( + send(root, { feePayerSignature: undefined, requireFunds: [requirement()] }), + ).rejects.toThrow() + expect( + ( + await send(maker, { + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [root.address, 10n * unit]), + }, + ], + }) + ).status, + ).toBe('0x1') + expect( + ( + await send(root, { + feePayerSignature: undefined, + requireFunds: [requirement()], + calls: [ + { + to: output, + data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), + }, + ], + }) + ).status, + ).toBe('0x1') +}) + +test('rejects an installed funding key after expiry', async () => { + const root = await owner() + const key = wallet() + const value = rules() + const block = await rpc<{ timestamp: Hex.Hex }>('eth_getBlockByNumber', [ + 'latest', + false, + ]) + const expiry = Number(block.timestamp) + 10 + const unsigned = { + chainId: BigInt(chainId), + address: key.address, + type: 'secp256k1' as const, + expiry, + fundingPolicy: { admins: [root.address], rules: value }, + } + const keyAuthorization = KeyAuthorization.from(unsigned, { + signature: SignatureEnvelope.from( + Secp256k1.sign({ + privateKey: root.privateKey, + payload: KeyAuthorization.getSignPayload(unsigned), + }), + ), + }) + expect((await send(root, { keyAuthorization }, key)).status).toBe('0x1') + for (let i = 0; i < 200; i++) { + const latest = await rpc<{ timestamp: Hex.Hex }>('eth_getBlockByNumber', [ + 'latest', + false, + ]) + if (Number(latest.timestamp) > expiry) break + await new Promise((resolve) => setTimeout(resolve, 100)) + } + await expect( + send( + root, + { requireFunds: [requirement(FundingPolicy.encode(value))] }, + key, + ), + ).rejects.toThrow() + expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) +}) diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 90050cc6d..c82274870 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -634,3 +634,23 @@ export * as ZoneId from './ZoneId.js' * @category Reference */ export * as ZoneRpcAuthentication from './ZoneRpcAuthentication.js' + +/** + * Funding requirements and source request types. + * + * @category Reference + */ +export * as Funding from './Funding.js' + +/** + * Funding policy commitments and authorization codecs. + * + * @category Reference + */ +export * as FundingPolicy from './FundingPolicy.js' +/** + * Native DEX funding payload codecs. + * + * @category Reference + */ +export * as NativeDexFunding from './NativeDexFunding.js' diff --git a/src/zod/tempo/Funding.ts b/src/zod/tempo/Funding.ts new file mode 100644 index 000000000..3939f4ba0 --- /dev/null +++ b/src/zod/tempo/Funding.ts @@ -0,0 +1,61 @@ +import * as z from 'zod/mini' +import * as core_Funding from '../../tempo/Funding.js' +import * as z_Address from '../Address.js' +import * as z_Hex from '../Hex.js' +import { + uintBigintNumberish, + uintNumberNumberish, +} from '../internal/Integer.js' + +/** Concrete funding source schema. */ +export const Source = z.object({ target: z_Address.Address, data: z_Hex.Hex }) + +/** RPC funding requirement schema. */ +export const Rpc = z.object({ + token: z_Address.Address, + amount: z_Hex.Hex, + policyRules: z.optional(z_Hex.Hex), + slippageBps: z.optional(z_Hex.Hex), + sources: z.readonly(z.array(Source)), +}) + +/** Domain funding requirement schema. */ +export const Domain = z + .object({ + token: z_Address.Address, + amount: z.bigint(), + policyRules: z.optional(z_Hex.Hex), + slippageBps: z.optional(z.number()), + sources: z.readonly(z.array(Source)), + }) + .check( + z.refine((value) => { + try { + core_Funding.assert(value) + return true + } catch { + return false + } + }, 'Invalid funding requirement'), + ) + +/** Encode-only domain schema accepting numberish quantities. */ +export const DomainToRpc = z.object({ + token: z_Address.Address, + amount: uintBigintNumberish(), + policyRules: z.optional(z_Hex.Hex), + slippageBps: z.optional(uintNumberNumberish()), + sources: z.readonly(z.array(Source)), +}) + +/** RPC funding requirement codec. */ +export const Requirement = z.codec(Rpc, Domain, { + decode: core_Funding.fromRpc, + encode: core_Funding.toRpc, +}) + +/** Encode-only funding codec accepting numberish quantities. */ +export const RequirementToRpc = z.codec(Rpc, DomainToRpc, { + decode: core_Funding.fromRpc, + encode: core_Funding.toRpc, +}) diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts new file mode 100644 index 000000000..2d45914ce --- /dev/null +++ b/src/zod/tempo/FundingPolicy.ts @@ -0,0 +1,47 @@ +import * as z from 'zod/mini' +import * as core_FundingPolicy from '../../tempo/FundingPolicy.js' +import * as z_Address from '../Address.js' +import * as z_Hex from '../Hex.js' +import { uintBigintNumberish } from '../internal/Integer.js' +import * as z_Funding from './Funding.js' + +/** Funding permissions keyed by output token. */ +export const Rules = z + .object({ + maxSlippageBps: z.number(), + sources: z.record(z_Address.Address, z.readonly(z.array(z_Funding.Source))), + }) + .check( + z.refine((value) => { + try { + core_FundingPolicy.toRoutes(value) + return true + } catch { + return false + } + }, 'Invalid funding policy rules'), + ) + +/** Inline funding policy creation schema. */ +export const Inline = z + .object({ admins: z.readonly(z.array(z_Address.Address)), rules: Rules }) + .check( + z.refine((value) => { + try { + core_FundingPolicy.toTuple(value) + return true + } catch { + return false + } + }, 'Invalid inline policy'), + ) + +/** Domain policy authorization schema. */ +export const Authorization = z.union([ + z.bigint().check(z.minimum(1n), z.maximum(2n ** 64n - 1n)), + Inline, +]) +/** RPC policy authorization schema. */ +export const Rpc = z.union([z_Hex.Hex, Inline]) +/** Encode-only policy authorization schema. */ +export const AuthorizationToRpc = z.union([uintBigintNumberish(), Inline]) diff --git a/src/zod/tempo/KeyAuthorization.ts b/src/zod/tempo/KeyAuthorization.ts index 86845f3e1..abb6bb0f9 100644 --- a/src/zod/tempo/KeyAuthorization.ts +++ b/src/zod/tempo/KeyAuthorization.ts @@ -1,4 +1,5 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ +import * as z_FundingPolicy from './FundingPolicy.js' import * as core_KeyAuthorization from '../../tempo/KeyAuthorization.js' import * as z_Address from '../Address.js' import * as z_Hex from '../Hex.js' @@ -35,6 +36,7 @@ export const Rpc = z allowedCalls: z.optional(z.nullable(z.readonly(z.array(RpcCallScope)))), chainId: z_Hex.Hex, expiry: z.optional(z.nullable(z_Hex.Hex)), + fundingPolicy: z.optional(z_FundingPolicy.Rpc), isAdmin: z.optional(z.nullable(z.boolean())), keyId: z_Address.Address, keyType: z.union([z_SignatureEnvelope.Type, z.literal('multisig')]), @@ -74,6 +76,7 @@ export const Scope = z.object({ }) const domainShape = { + fundingPolicy: z.optional(z_FundingPolicy.Authorization), address: z_Address.Address, chainId: z.bigint(), expiry: z.optional(z.number()), @@ -103,6 +106,7 @@ export const Domain = z const domainToRpcShape = { ...domainShape, + fundingPolicy: z.optional(z_FundingPolicy.AuthorizationToRpc), chainId: uintBigintNumberish(), expiry: z.optional(uintNumberNumberish()), limits: z.optional(z.readonly(z.array(TokenLimitToRpc))), diff --git a/src/zod/tempo/Transaction.ts b/src/zod/tempo/Transaction.ts index f4156cc8d..c8e3716e8 100644 --- a/src/zod/tempo/Transaction.ts +++ b/src/zod/tempo/Transaction.ts @@ -1,4 +1,5 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ +import * as z_Funding from './Funding.js' import * as z_AccessList from '../AccessList.js' import * as z_Address from '../Address.js' import * as z_Hex from '../Hex.js' @@ -119,7 +120,13 @@ function wire(schema: schema) { /** Tempo transaction schema (type `0x76`). */ export const Tempo = wire( z.object( - fields(z_Uint.Uint, z_Number.Number, Call, z_AuthorizationTempo.ListSigned), + fields( + z_Uint.Uint, + z_Number.Number, + Call, + z_AuthorizationTempo.ListSigned, + z_Funding.Requirement, + ), ), ) @@ -131,6 +138,7 @@ export const TempoToRpc = wire( z_Number.NumberToRpc, CallToRpc, z_AuthorizationTempo.ListSignedToRpc, + z_Funding.RequirementToRpc, ), ), ) @@ -143,6 +151,7 @@ export const PendingTempo = wire( z_Number.Number, Call, z_AuthorizationTempo.ListSigned, + z_Funding.Requirement, ), blockHash: z.null(), blockNumber: z.null(), @@ -156,7 +165,8 @@ function fields< num extends z.ZodMiniType, call extends z.ZodMiniType, authList extends z.ZodMiniType, ->(uint: uint, num: num, call: call, authList: authList) { + funding extends z.ZodMiniType, +>(uint: uint, num: num, call: call, authList: authList, funding: funding) { return { accessList: z_AccessList.AccessList, authorizationList: z.optional(authList), @@ -172,6 +182,7 @@ function fields< gas: uint, gasPrice: z.optional(uint), hash: z_Hex.Hex, + requireFunds: z.optional(z.readonly(z.array(funding))), keyAuthorization: z.optional(z_KeyAuthorization.KeyAuthorization), maxFeePerGas: uint, maxPriorityFeePerGas: uint, diff --git a/src/zod/tempo/TransactionRequest.ts b/src/zod/tempo/TransactionRequest.ts index acaa91c52..532ff0eb9 100644 --- a/src/zod/tempo/TransactionRequest.ts +++ b/src/zod/tempo/TransactionRequest.ts @@ -1,4 +1,6 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ +import * as core_Funding from '../../tempo/Funding.js' +import * as z_Funding from './Funding.js' import * as core_Hex from '../../core/Hex.js' import type * as core_TransactionRequest from '../../tempo/TransactionRequest.js' import * as z_AccessList from '../AccessList.js' @@ -77,6 +79,7 @@ export const Rpc = z.object({ gas: z.optional(z_Hex.Hex), gasPrice: z.optional(z_Hex.Hex), input: z.optional(z_Hex.Hex), + requireFunds: z.optional(z.readonly(z.array(z_Funding.Rpc))), keyAuthorization: z.optional(z_KeyAuthorization.Rpc), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -131,6 +134,7 @@ export const Domain = z.object({ gas: z.optional(z.bigint()), gasPrice: z.optional(z.bigint()), input: z.optional(z_Hex.Hex), + requireFunds: z.optional(z.readonly(z.array(z_Funding.Domain))), keyAuthorization: z.optional(z_KeyAuthorization.Domain), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -171,6 +175,7 @@ export const DomainToRpc = z.object({ gas: z.optional(uintBigintNumberish()), gasPrice: z.optional(uintBigintNumberish()), input: z.optional(z_Hex.Hex), + requireFunds: z.optional(z.readonly(z.array(z_Funding.DomainToRpc))), keyAuthorization: z.optional(z_KeyAuthorization.DomainToRpc), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -242,6 +247,8 @@ function fromRpc( })) if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken + if (request.requireFunds) + request_.requireFunds = request.requireFunds.map(core_Funding.fromRpc) if (request.keyAuthorization) request_.keyAuthorization = z.decode( z_KeyAuthorization.KeyAuthorization, @@ -288,6 +295,7 @@ function toRpc( typeof request.capabilities !== 'undefined' || typeof request.feePayer !== 'undefined' || typeof request.feeToken !== 'undefined' || + typeof request.requireFunds !== 'undefined' || typeof request.keyAuthorization !== 'undefined' || typeof request.keyData !== 'undefined' || typeof request.keyId !== 'undefined' || @@ -333,6 +341,8 @@ function toRpc( !(request.feePayer === true && !request.feePayerSignature) ) request_rpc.feeToken = request.feeToken + if (request.requireFunds) + request_rpc.requireFunds = request.requireFunds.map(core_Funding.toRpc) if (request.keyAuthorization) request_rpc.keyAuthorization = z.encode( z_KeyAuthorization.KeyAuthorizationToRpc, diff --git a/src/zod/tempo/TxEnvelopeTempo.ts b/src/zod/tempo/TxEnvelopeTempo.ts index 275fe42a2..c7ec66c05 100644 --- a/src/zod/tempo/TxEnvelopeTempo.ts +++ b/src/zod/tempo/TxEnvelopeTempo.ts @@ -1,4 +1,5 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ +import * as z_Funding from './Funding.js' import * as core_TxEnvelopeTempo from '../../tempo/TxEnvelopeTempo.js' import * as z_AccessList from '../AccessList.js' import * as z_Address from '../Address.js' @@ -34,6 +35,7 @@ const baseFields = { feeToken: z.optional(z_Address.Address), from: z.optional(z_Address.Address), gas: z.optional(z.bigint()), + requireFunds: z.optional(z.readonly(z.array(z_Funding.Domain))), keyAuthorization: z.optional(z_KeyAuthorization.Domain), maxFeePerGas: z.optional(z.bigint()), maxPriorityFeePerGas: z.optional(z.bigint()), diff --git a/src/zod/tempo/_test/Funding.test.ts b/src/zod/tempo/_test/Funding.test.ts new file mode 100644 index 000000000..472350cef --- /dev/null +++ b/src/zod/tempo/_test/Funding.test.ts @@ -0,0 +1,74 @@ +import * as z from 'zod/mini' +import { expect, test } from 'vp/test' +import * as Funding from '../Funding.js' +import * as FundingPolicy from '../FundingPolicy.js' +import * as z_KeyAuthorization from '../KeyAuthorization.js' +import * as TransactionRequest from '../TransactionRequest.js' +import * as TxEnvelopeTempo from '../TxEnvelopeTempo.js' + +const token = '0x0101010101010101010101010101010101010101' as const +const requirement = { + token, + amount: 50n, + slippageBps: 0, + sources: [{ target: token, data: '0xab' as const }], +} + +test('funding RPC and request codecs retain all fields', () => { + const rpc = z.encode(Funding.Requirement, requirement) + expect(rpc.amount).toBe('0x32') + expect(rpc.slippageBps).toBe('0x0') + expect(z.decode(Funding.Requirement, rpc)).toEqual(requirement) + const request = z.encode(TransactionRequest.TransactionRequest, { + requireFunds: [requirement], + }) + expect(request.type).toBe('0x76') + expect( + z.decode(TransactionRequest.TransactionRequest, request).requireFunds, + ).toEqual([requirement]) +}) + +test('envelope schema preserves funding and rejects invalid slippage', () => { + const envelope = { + type: 'tempo' as const, + chainId: 1, + calls: [{ to: token }], + requireFunds: [requirement], + } + expect( + z.parse(TxEnvelopeTempo.TxEnvelopeTempo, envelope).requireFunds, + ).toEqual([requirement]) + expect( + z.safeParse(Funding.Domain, { ...requirement, slippageBps: 10001 }).success, + ).toBe(false) +}) + +test('key schema preserves existing and inline policies', () => { + for (const fundingPolicy of [ + 7n, + { + admins: [token], + rules: { maxSlippageBps: 100, sources: { [token]: requirement.sources } }, + }, + ]) { + const value = { + address: token, + chainId: 1n, + type: 'secp256k1' as const, + fundingPolicy, + signature: { + type: 'secp256k1' as const, + signature: { + r: `0x${'01'.repeat(32)}` as const, + s: `0x${'02'.repeat(32)}` as const, + yParity: 0 as const, + }, + }, + } + const encoded = z.encode(z_KeyAuthorization.KeyAuthorization, value) + expect( + z.decode(z_KeyAuthorization.KeyAuthorization, encoded).fundingPolicy, + ).toEqual(fundingPolicy) + } + expect(z.safeParse(FundingPolicy.Authorization, 0n).success).toBe(false) +}) diff --git a/src/zod/tempo/z.ts b/src/zod/tempo/z.ts index 44f9e2d88..095fc7919 100644 --- a/src/zod/tempo/z.ts +++ b/src/zod/tempo/z.ts @@ -1,5 +1,7 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ export * as AuthorizationTempo from './AuthorizationTempo.js' +export * as Funding from './Funding.js' +export * as FundingPolicy from './FundingPolicy.js' export * as KeyAuthorization from './KeyAuthorization.js' export * as MultisigConfig from './MultisigConfig.js' export * as MultisigOperation from './MultisigOperation.js' diff --git a/test/tempo/funding.md b/test/tempo/funding.md new file mode 100644 index 000000000..47926d639 --- /dev/null +++ b/test/tempo/funding.md @@ -0,0 +1,26 @@ +# Funding Conformance Tests + +Run the TIP-1120 integration suite with Docker running: + +```sh +pnpm test run --project tempo-funding +``` + +The suite starts an isolated development node through the existing Prool harness and creates its own tokens, DEX liquidity, funding policies, and access keys. No public-network account or deployment is required. + +- Image: `ghcr.io/tempoxyz/tempo@sha256:485e878fb0a68894ceb4743280f1d2747932ae3728e94cf51302d0cae5476f5a` +- Build: https://github.com/tempoxyz/tempo/actions/runs/35911644417 +- Node revision: `492639758d54b83ddd68f4a4b7a67e5c51221c0e` +- Specification: `tempoxyz/tempo` TIP-1120 at `245acb48b13423ffeb84e2204bc8bcfcc61be663` +- Prool port: `3112` + +The tests assert the node revision before submitting transactions. They cover owner and delegated funding, sponsorship, existing and inline policies, spending limits, source permissions, stale commitments, rollback, and key expiry/revocation. P256 and WebAuthn access keys use the same funding fields as secp256k1 keys. + +The unit fixtures in `src/tempo/Funding.test.ts` also contain independent Rust RLP vectors. Reproduce them at the pinned node revision with: + +```sh +cargo test -p tempo-primitives funding_requirement_golden +cargo test -p tempo-primitives policy_reference_has_canonical_trailing_encoding +``` + +Policy ABI encoding and commitment hashes are independently checked against `createPolicy`/`getPolicy` on the node. Signed transactions accepted by the node verify the sender and sponsor encodings against its Rust implementation. diff --git a/test/tempo/funding.ts b/test/tempo/funding.ts new file mode 100644 index 000000000..7dc765ce3 --- /dev/null +++ b/test/tempo/funding.ts @@ -0,0 +1,5 @@ +export const port = 3112 + +// Run 35911644417, commit 492639758d54b83ddd68f4a4b7a67e5c51221c0e. +export const tag = + 'sha256:485e878fb0a68894ceb4743280f1d2747932ae3728e94cf51302d0cae5476f5a' diff --git a/test/tempo/prool.ts b/test/tempo/prool.ts index 9a004a3ab..22754b01d 100644 --- a/test/tempo/prool.ts +++ b/test/tempo/prool.ts @@ -47,7 +47,7 @@ export async function createServer(options: createServer.Options = {}) { instance: tag ? TestContainers.Instance.tempo({ ...args, - image: `ghcr.io/tempoxyz/tempo:${tag}`, + image: `ghcr.io/tempoxyz/tempo${tag.startsWith('sha256:') ? '@' : ':'}${tag}`, }) : Instance.tempo(args), port: serverPort, diff --git a/test/tempo/setup.global.funding.ts b/test/tempo/setup.global.funding.ts new file mode 100644 index 000000000..479b1df00 --- /dev/null +++ b/test/tempo/setup.global.funding.ts @@ -0,0 +1,7 @@ +import { port, tag } from './funding.js' +import { createServer } from './prool.js' + +export default async function () { + const server = await createServer({ port, tag }) + return server.start() +} diff --git a/vite.config.ts b/vite.config.ts index 83ae42bc7..5604ba374 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,11 @@ import { tag as tempoMultisigTag, } from './test/tempo/multisig.js' +import { + port as tempoFundingPort, + tag as tempoFundingTag, +} from './test/tempo/funding.js' + const root = import.meta.dirname /** @@ -191,9 +196,24 @@ export default defineConfig({ 'src/tempo/**/*.test.ts', '!src/tempo/e2e.test.ts', '!src/tempo/multisig.e2e.test.ts', + '!src/tempo/funding.e2e.test.ts', ], }, }, + { + extends: true, + test: { + name: 'tempo-funding', + include: ['src/tempo/funding.e2e.test.ts'], + globalSetup: [join(root, 'test/tempo/setup.global.funding.ts')], + hookTimeout: 180_000, + testTimeout: 60_000, + env: { + VITE_TEMPO_PORT: String(tempoFundingPort), + VITE_TEMPO_TAG: tempoFundingTag, + }, + }, + }, { extends: true, test: { From b466938c0bffcb7c91e25126a6609af49b7ecd6f Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:22:02 +1000 Subject: [PATCH 02/28] test(tempo): run funding conformance in ci --- .github/workflows/verify.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index a9f737d87..99f42c010 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -116,6 +116,23 @@ jobs: VITE_TEMPO_CREDENTIALS: ${{ secrets.VITE_TEMPO_CREDENTIALS }} VITE_TEMPO_TAG: ${{ matrix.tag }} + test-tempo-funding: + name: Test Runtime (tempo funding) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Clone repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + submodules: 'recursive' + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Run tests + run: pnpm test --project tempo-funding --bail=1 + test-tempo-multisig: name: Test Runtime (tempo multisig) runs-on: ubuntu-latest From 86043afd33249c084a64e4571973197678dec9f3 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:32:38 +1000 Subject: [PATCH 03/28] test(tempo): remove funding e2e suite --- .github/workflows/verify.yml | 17 - src/tempo/funding.e2e.test.ts | 856 ----------------------------- test/tempo/funding.md | 26 - test/tempo/funding.ts | 5 - test/tempo/prool.ts | 2 +- test/tempo/setup.global.funding.ts | 7 - vite.config.ts | 20 - 7 files changed, 1 insertion(+), 932 deletions(-) delete mode 100644 src/tempo/funding.e2e.test.ts delete mode 100644 test/tempo/funding.md delete mode 100644 test/tempo/funding.ts delete mode 100644 test/tempo/setup.global.funding.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 99f42c010..a9f737d87 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -116,23 +116,6 @@ jobs: VITE_TEMPO_CREDENTIALS: ${{ secrets.VITE_TEMPO_CREDENTIALS }} VITE_TEMPO_TAG: ${{ matrix.tag }} - test-tempo-funding: - name: Test Runtime (tempo funding) - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - name: Clone repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - submodules: 'recursive' - - - name: Install dependencies - uses: ./.github/actions/install-dependencies - - - name: Run tests - run: pnpm test --project tempo-funding --bail=1 - test-tempo-multisig: name: Test Runtime (tempo multisig) runs-on: ubuntu-latest diff --git a/src/tempo/funding.e2e.test.ts b/src/tempo/funding.e2e.test.ts deleted file mode 100644 index 89e1dce49..000000000 --- a/src/tempo/funding.e2e.test.ts +++ /dev/null @@ -1,856 +0,0 @@ -import { - AbiFunction, - AbiParameters, - Address, - Hash, - Hex, - P256, - WebAuthnP256, - Secp256k1, -} from 'ox' -import { afterAll, beforeAll, describe, expect, test } from 'vp/test' -import { accounts } from '../../test/constants/accounts.js' -import { rpcUrl } from '../../test/tempo/prool.js' -import * as Funding from './Funding.js' -import * as FundingPolicy from './FundingPolicy.js' -import * as KeyAuthorization from './KeyAuthorization.js' -import * as NativeDexFunding from './NativeDexFunding.js' -import * as SignatureEnvelope from './SignatureEnvelope.js' -import * as Transaction from './Transaction.js' -import * as TxEnvelopeTempo from './TxEnvelopeTempo.js' - -const maker = accounts[0] -const output = '0x20c0000000000000000000000000000000000000' as const -const dex = '0xdec0000000000000000000000000000000000000' as const -const factory = '0x20fc000000000000000000000000000000000000' as const -const source = '0x1120000000000000000000000000000000000001' as const -const policyAddress = '0x1120000000000000000000000000000000000002' as const -const recipient = '0x8888888888888888888888888888888888888888' as const -const unit = 1_000_000n -const max = 2n ** 256n - 1n -const transfer = AbiFunction.from( - 'function transfer(address to, uint256 amount) returns (bool)', -) -const mint = AbiFunction.from('function mint(address to, uint256 amount)') -const balanceOf = AbiFunction.from( - 'function balanceOf(address account) view returns (uint256)', -) -const createPolicy = AbiFunction.from( - 'function createPolicy(address[] admins, (uint16 maxSlippageBps, (address token, (address target, bytes data)[] sources)[] routes) rules) returns (uint64)', -) -const getPolicy = AbiFunction.from( - 'function getPolicy(uint64 policyId) view returns ((address[] admins, bytes32 rulesHash))', -) -let chainId: number -let inputs: Address.Address[] - -type Receipt = { - status: Hex.Hex - transactionHash: Hex.Hex - logs: readonly { - address: Address.Address - topics: readonly Hex.Hex[] - data: Hex.Hex - }[] -} -async function rpc( - method: string, - params: readonly unknown[] = [], -): Promise { - const response = await fetch(rpcUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), - }) - const result = (await response.json()) as { - result: T - error?: { message: string } - } - if (result.error) throw new Error(result.error.message) - return result.result -} - -function wallet() { - const privateKey = Secp256k1.randomPrivateKey() - return { - privateKey, - address: Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey })), - } -} - -async function send( - owner: { address: Address.Address; privateKey: Hex.Hex }, - request: Partial = {}, - access?: ReturnType & { - sign?: (payload: Hex.Hex) => SignatureEnvelope.Primitive - }, -) { - const nonce = BigInt( - await rpc('eth_getTransactionCount', [owner.address, 'pending']), - ) - const tx = TxEnvelopeTempo.from({ - chainId, - calls: [{ to: recipient }], - gas: 20_000_000n, - maxFeePerGas: 20_000_000_000n, - nonce, - feeToken: output, - feePayerSignature: owner.address === maker.address ? undefined : null, - ...request, - }) - const payload = TxEnvelopeTempo.getSignPayload( - tx, - access ? { from: owner.address } : {}, - ) - const inner = access?.sign - ? access.sign(payload) - : SignatureEnvelope.from( - Secp256k1.sign({ - privateKey: access?.privateKey ?? owner.privateKey, - payload, - }), - ) - const signature = access - ? SignatureEnvelope.from({ - type: 'keychain', - userAddress: owner.address, - inner, - }) - : inner - const feePayerSignature = Secp256k1.sign({ - privateKey: maker.privateKey, - payload: TxEnvelopeTempo.getFeePayerSignPayload(tx, { - sender: owner.address, - }), - }) - const serialized = TxEnvelopeTempo.serialize(tx, { - signature, - ...(tx.feePayerSignature === undefined ? {} : { feePayerSignature }), - }) - return rpc('eth_sendRawTransactionSync', [serialized]) -} - -async function balance(token: Address.Address, account: Address.Address) { - return AbiFunction.decodeResult( - balanceOf, - await rpc('eth_call', [ - { to: token, data: AbiFunction.encodeData(balanceOf, [account]) }, - 'latest', - ]), - ) -} - -function requirement(policyRules?: Hex.Hex): Funding.Requirement { - return { - token: output, - amount: 50n * unit, - policyRules, - slippageBps: 100, - sources: inputs.map((tokenIn, i) => ({ - target: source, - data: NativeDexFunding.encode({ - tokenIn, - maxAmountIn: i === 0 ? 30n * unit : undefined, - }), - })), - } -} -function rules(): FundingPolicy.Rules { - return { maxSlippageBps: 100, sources: { [output]: requirement().sources } } -} -async function owner() { - const account = wallet() - expect( - ( - await send(maker, { - calls: inputs.map((to) => ({ - to, - data: AbiFunction.encodeData(mint, [account.address, 500n * unit]), - })), - }) - ).status, - ).toBe('0x1') - return account -} -function authorization( - root: ReturnType, - key: ReturnType, - fundingPolicy: FundingPolicy.Authorization, - limit = 50n * unit, -) { - const value = { - chainId: BigInt(chainId), - address: key.address, - type: 'secp256k1' as const, - limits: [{ token: output, limit }], - fundingPolicy, - } - return KeyAuthorization.from(value, { - signature: SignatureEnvelope.from( - Secp256k1.sign({ - privateKey: root.privateKey, - payload: KeyAuthorization.getSignPayload(value), - }), - ), - }) -} - -beforeAll(async () => { - expect(await rpc('web3_clientVersion')).toContain('4926397') - chainId = Number(await rpc('eth_chainId')) - const tokenAddress = AbiFunction.from( - 'function getTokenAddress(address sender, bytes32 salt) view returns (address)', - ) - const createToken = AbiFunction.from( - 'function createToken(string name, string symbol, string currency, address quoteToken, address admin, bytes32 salt) returns (address)', - ) - const grantRole = AbiFunction.from( - 'function grantRole(bytes32 role, address account)', - ) - const approve = AbiFunction.from( - 'function approve(address spender, uint256 amount) returns (bool)', - ) - const place = AbiFunction.from( - 'function place(address token, uint128 amount, bool isBid, int16 tick) returns (uint128)', - ) - expect( - ( - await send(maker, { - calls: [ - { to: output, data: AbiFunction.encodeData(approve, [dex, max]) }, - ], - }) - ).status, - ).toBe('0x1') - inputs = [] - for (let i = 0; i < 2; i++) { - const salt = Hex.random(32) - const token = AbiFunction.decodeResult( - tokenAddress, - await rpc('eth_call', [ - { - to: factory, - data: AbiFunction.encodeData(tokenAddress, [maker.address, salt]), - }, - 'latest', - ]), - ) - expect( - ( - await send(maker, { - calls: [ - { - to: factory, - data: AbiFunction.encodeData(createToken, [ - 'Funding input', - i === 0 ? 'USDC.e' : 'OUSD', - 'USD', - output, - maker.address, - salt, - ]), - }, - ], - }) - ).status, - ).toBe('0x1') - expect( - ( - await send(maker, { - calls: [ - { - to: token, - data: AbiFunction.encodeData(grantRole, [ - Hash.keccak256(Hex.fromString('ISSUER_ROLE')), - maker.address, - ]), - }, - { - to: dex, - data: AbiFunction.encodeData(place, [ - token, - 100_000n * unit, - true, - 0, - ]), - }, - ], - }) - ).status, - ).toBe('0x1') - inputs.push(token) - } -}) -afterAll(async () => { - await fetch(`${rpcUrl}/stop`) -}) - -describe('owner funding', () => { - test('funds 30 from the first source and 20 from the second, with a sponsor', async () => { - const account = await owner() - const receipt = await send(account, { - requireFunds: [requirement()], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), - }, - ], - }) - expect(receipt.status).toBe('0x1') - expect(await balance(inputs[0]!, account.address)).toBe(470n * unit) - expect(await balance(inputs[1]!, account.address)).toBe(480n * unit) - expect(await balance(output, account.address)).toBe(0n) - const tx = Transaction.fromRpc( - await rpc('eth_getTransactionByHash', [ - receipt.transactionHash, - ]), - ) - expect(tx.requireFunds).toEqual([requirement()]) - }) - test('application failure rolls back input debits', async () => { - const account = await owner() - const receipt = await send(account, { - requireFunds: [requirement()], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 51n * unit]), - }, - ], - }) - expect(receipt.status).toBe('0x0') - expect(await balance(inputs[0]!, account.address)).toBe(500n * unit) - expect(await balance(output, account.address)).toBe(0n) - }) -}) - -describe('policy funding', () => { - test('creates a policy with the same hash as Ox', async () => { - const value = rules() - const receipt = await send(maker, { - calls: [ - { - to: policyAddress, - data: AbiFunction.encodeData(createPolicy, [ - [maker.address], - { - maxSlippageBps: value.maxSlippageBps, - routes: FundingPolicy.toRoutes(value), - }, - ]), - }, - ], - }) - expect(receipt.status).toBe('0x1') - const event = receipt.logs.find( - (log) => log.address.toLowerCase() === policyAddress, - )! - const policyId = BigInt(event.topics[1]!) - const result = await rpc('eth_call', [ - { - to: policyAddress, - data: AbiFunction.encodeData(getPolicy, [policyId]), - }, - 'latest', - ]) - const [policy] = AbiParameters.decode( - [ - { - type: 'tuple', - components: [ - { name: 'admins', type: 'address[]' }, - { name: 'rulesHash', type: 'bytes32' }, - ], - }, - ], - result, - ) - expect(policy.rulesHash).toBe(FundingPolicy.hash(value)) - const root = await owner() - const key = wallet() - const payment = await send( - root, - { - keyAuthorization: authorization(root, key, policyId), - requireFunds: [requirement(FundingPolicy.encode(value))], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), - }, - ], - }, - key, - ) - expect(payment.status).toBe('0x1') - }) - test('creates inline policy, installs key, funds and pays without double charging', async () => { - const root = await owner() - const key = wallet() - const value = rules() - const receipt = await send( - root, - { - keyAuthorization: authorization(root, key, { - admins: [root.address], - rules: value, - }), - requireFunds: [requirement(FundingPolicy.encode(value))], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), - }, - ], - }, - key, - ) - expect(receipt.status).toBe('0x1') - expect(await balance(inputs[0]!, root.address)).toBe(470n * unit) - const exhausted = await send( - root, - { requireFunds: [requirement(FundingPolicy.encode(value))] }, - key, - ) - expect(exhausted.status).toBe('0x0') - expect(await balance(inputs[0]!, root.address)).toBe(470n * unit) - }) -}) - -describe('funding authorization boundaries', () => { - test('reuses an installed key, then rejects the exhausted output budget', async () => { - const root = await owner() - const key = wallet() - const value = rules() - const installed = await send( - root, - { - keyAuthorization: authorization( - root, - key, - { admins: [root.address], rules: value }, - 100n * unit, - ), - }, - key, - ) - expect(installed.status).toBe('0x1') - for (let i = 0; i < 2; i++) { - const payment = await send( - root, - { - requireFunds: [requirement(FundingPolicy.encode(value))], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), - }, - ], - }, - key, - ) - expect(payment.status).toBe('0x1') - } - expect( - ( - await send( - root, - { requireFunds: [requirement(FundingPolicy.encode(value))] }, - key, - ) - ).status, - ).toBe('0x0') - expect(await balance(inputs[0]!, root.address)).toBe(440n * unit) - }) - - test('rejects missing, tampered and mismatched rules before debiting inputs', async () => { - const root = await owner() - const key = wallet() - const value = rules() - expect( - ( - await send( - root, - { - keyAuthorization: authorization(root, key, { - admins: [root.address], - rules: value, - }), - }, - key, - ) - ).status, - ).toBe('0x1') - for (const policyRules of [ - undefined, - '0xab' as const, - FundingPolicy.encode({ ...value, maxSlippageBps: 101 }), - ]) { - expect( - (await send(root, { requireFunds: [requirement(policyRules)] }, key)) - .status, - ).toBe('0x0') - expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) - } - }) - - test('rejects excessive slippage, reversed sources and unapproved output', async () => { - const root = await owner() - const key = wallet() - const value = rules() - expect( - ( - await send( - root, - { - keyAuthorization: authorization(root, key, { - admins: [root.address], - rules: value, - }), - }, - key, - ) - ).status, - ).toBe('0x1') - const request = requirement(FundingPolicy.encode(value)) - for (const entry of [ - { ...request, slippageBps: 101 }, - { ...request, sources: [...request.sources].reverse() }, - { ...request, token: inputs[0]! }, - { - ...request, - sources: [ - { - target: source, - data: NativeDexFunding.encode({ tokenIn: output }), - }, - ], - }, - ]) { - expect((await send(root, { requireFunds: [entry] }, key)).status).toBe( - '0x0', - ) - expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) - } - }) - - test('uses existing balances and treats repeated requirements as target balances', async () => { - const root = await owner() - expect( - ( - await send(maker, { - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [ - root.address, - 50n * unit, - ]), - }, - ], - }) - ).status, - ).toBe('0x1') - const entry = { - token: output, - amount: 50n * unit, - sources: [], - slippageBps: 0, - } - expect( - ( - await send(root, { - requireFunds: [entry, entry], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), - }, - ], - }) - ).status, - ).toBe('0x1') - expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) - }) - - test('insufficient input and caller caps roll back earlier contributions', async () => { - const root = await owner() - const entry = { ...requirement(), sources: [requirement().sources[0]!] } - expect((await send(root, { requireFunds: [entry] })).status).toBe('0x0') - expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) - const empty = wallet() - expect((await send(empty, { requireFunds: [requirement()] })).status).toBe( - '0x0', - ) - }) - - test('owner funding rejects policy rules', async () => { - const root = await owner() - expect( - ( - await send(root, { - requireFunds: [requirement(FundingPolicy.encode(rules()))], - }) - ).status, - ).toBe('0x0') - }) - - test('updated policy rules invalidate the previous witness', async () => { - const root = await owner() - const key = wallet() - const value = rules() - const installed = await send( - root, - { - keyAuthorization: authorization(root, key, { - admins: [root.address], - rules: value, - }), - }, - key, - ) - expect(installed.status).toBe('0x1') - const event = installed.logs.find( - (log) => log.address.toLowerCase() === policyAddress, - )! - const id = BigInt(event.topics[1]!) - const setRules = AbiFunction.from( - 'function setRules(uint64 policyId, (uint16 maxSlippageBps, (address token, (address target, bytes data)[] sources)[] routes) rules)', - ) - const updated = { ...value, maxSlippageBps: 50 } - expect( - ( - await send(root, { - calls: [ - { - to: policyAddress, - data: AbiFunction.encodeData(setRules, [ - id, - { maxSlippageBps: 50, routes: FundingPolicy.toRoutes(updated) }, - ]), - }, - ], - }) - ).status, - ).toBe('0x1') - expect( - ( - await send( - root, - { requireFunds: [requirement(FundingPolicy.encode(value))] }, - key, - ) - ).status, - ).toBe('0x0') - expect( - ( - await send( - root, - { - requireFunds: [ - { - ...requirement(FundingPolicy.encode(updated)), - slippageBps: undefined, - }, - ], - }, - key, - ) - ).status, - ).toBe('0x1') - }) -}) - -describe('key validity and signature families', () => { - test.each(['p256', 'webAuthn'] as const)( - 'funds with a %s access key', - async (type) => { - const root = await owner() - const privateKey = P256.randomPrivateKey() - const publicKey = P256.getPublicKey({ privateKey }) - const key = { - address: Address.fromPublicKey(publicKey), - privateKey, - sign(payload: Hex.Hex): SignatureEnvelope.Primitive { - if (type === 'p256') - return { - type, - publicKey, - prehash: false, - signature: P256.sign({ payload, privateKey }), - } - const webAuthn = WebAuthnP256.getSignPayload({ - challenge: payload, - rpId: 'localhost', - origin: 'http://localhost', - }) - return { - type, - publicKey, - metadata: webAuthn.metadata, - signature: P256.sign({ - payload: webAuthn.payload, - privateKey, - hash: true, - }), - } - }, - } - const policy = rules() - const unsigned = { - chainId: BigInt(chainId), - address: key.address, - type, - fundingPolicy: { admins: [root.address], rules: policy }, - limits: [{ token: output, limit: 50n * unit }], - } - const keyAuthorization = KeyAuthorization.from(unsigned, { - signature: SignatureEnvelope.from( - Secp256k1.sign({ - privateKey: root.privateKey, - payload: KeyAuthorization.getSignPayload(unsigned), - }), - ), - }) - expect( - ( - await send( - root, - { - keyAuthorization, - requireFunds: [requirement(FundingPolicy.encode(policy))], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [ - recipient, - 50n * unit, - ]), - }, - ], - }, - key, - ) - ).status, - ).toBe('0x1') - }, - ) - - test('rejects a revoked funding key', async () => { - const root = await owner() - const key = wallet() - const value = rules() - expect( - ( - await send( - root, - { - keyAuthorization: authorization(root, key, { - admins: [root.address], - rules: value, - }), - }, - key, - ) - ).status, - ).toBe('0x1') - const revoke = AbiFunction.from('function revokeKey(address keyId)') - expect( - ( - await send(root, { - calls: [ - { - to: '0xaaaaaaaa00000000000000000000000000000000', - data: AbiFunction.encodeData(revoke, [key.address]), - }, - ], - }) - ).status, - ).toBe('0x1') - await expect( - send( - root, - { requireFunds: [requirement(FundingPolicy.encode(value))] }, - key, - ), - ).rejects.toThrow() - expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) - }) -}) - -test('owner funding also works without a sponsor and cannot fund its own fees', async () => { - const root = await owner() - await expect( - send(root, { feePayerSignature: undefined, requireFunds: [requirement()] }), - ).rejects.toThrow() - expect( - ( - await send(maker, { - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [root.address, 10n * unit]), - }, - ], - }) - ).status, - ).toBe('0x1') - expect( - ( - await send(root, { - feePayerSignature: undefined, - requireFunds: [requirement()], - calls: [ - { - to: output, - data: AbiFunction.encodeData(transfer, [recipient, 50n * unit]), - }, - ], - }) - ).status, - ).toBe('0x1') -}) - -test('rejects an installed funding key after expiry', async () => { - const root = await owner() - const key = wallet() - const value = rules() - const block = await rpc<{ timestamp: Hex.Hex }>('eth_getBlockByNumber', [ - 'latest', - false, - ]) - const expiry = Number(block.timestamp) + 10 - const unsigned = { - chainId: BigInt(chainId), - address: key.address, - type: 'secp256k1' as const, - expiry, - fundingPolicy: { admins: [root.address], rules: value }, - } - const keyAuthorization = KeyAuthorization.from(unsigned, { - signature: SignatureEnvelope.from( - Secp256k1.sign({ - privateKey: root.privateKey, - payload: KeyAuthorization.getSignPayload(unsigned), - }), - ), - }) - expect((await send(root, { keyAuthorization }, key)).status).toBe('0x1') - for (let i = 0; i < 200; i++) { - const latest = await rpc<{ timestamp: Hex.Hex }>('eth_getBlockByNumber', [ - 'latest', - false, - ]) - if (Number(latest.timestamp) > expiry) break - await new Promise((resolve) => setTimeout(resolve, 100)) - } - await expect( - send( - root, - { requireFunds: [requirement(FundingPolicy.encode(value))] }, - key, - ), - ).rejects.toThrow() - expect(await balance(inputs[0]!, root.address)).toBe(500n * unit) -}) diff --git a/test/tempo/funding.md b/test/tempo/funding.md deleted file mode 100644 index 47926d639..000000000 --- a/test/tempo/funding.md +++ /dev/null @@ -1,26 +0,0 @@ -# Funding Conformance Tests - -Run the TIP-1120 integration suite with Docker running: - -```sh -pnpm test run --project tempo-funding -``` - -The suite starts an isolated development node through the existing Prool harness and creates its own tokens, DEX liquidity, funding policies, and access keys. No public-network account or deployment is required. - -- Image: `ghcr.io/tempoxyz/tempo@sha256:485e878fb0a68894ceb4743280f1d2747932ae3728e94cf51302d0cae5476f5a` -- Build: https://github.com/tempoxyz/tempo/actions/runs/35911644417 -- Node revision: `492639758d54b83ddd68f4a4b7a67e5c51221c0e` -- Specification: `tempoxyz/tempo` TIP-1120 at `245acb48b13423ffeb84e2204bc8bcfcc61be663` -- Prool port: `3112` - -The tests assert the node revision before submitting transactions. They cover owner and delegated funding, sponsorship, existing and inline policies, spending limits, source permissions, stale commitments, rollback, and key expiry/revocation. P256 and WebAuthn access keys use the same funding fields as secp256k1 keys. - -The unit fixtures in `src/tempo/Funding.test.ts` also contain independent Rust RLP vectors. Reproduce them at the pinned node revision with: - -```sh -cargo test -p tempo-primitives funding_requirement_golden -cargo test -p tempo-primitives policy_reference_has_canonical_trailing_encoding -``` - -Policy ABI encoding and commitment hashes are independently checked against `createPolicy`/`getPolicy` on the node. Signed transactions accepted by the node verify the sender and sponsor encodings against its Rust implementation. diff --git a/test/tempo/funding.ts b/test/tempo/funding.ts deleted file mode 100644 index 7dc765ce3..000000000 --- a/test/tempo/funding.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const port = 3112 - -// Run 35911644417, commit 492639758d54b83ddd68f4a4b7a67e5c51221c0e. -export const tag = - 'sha256:485e878fb0a68894ceb4743280f1d2747932ae3728e94cf51302d0cae5476f5a' diff --git a/test/tempo/prool.ts b/test/tempo/prool.ts index 22754b01d..9a004a3ab 100644 --- a/test/tempo/prool.ts +++ b/test/tempo/prool.ts @@ -47,7 +47,7 @@ export async function createServer(options: createServer.Options = {}) { instance: tag ? TestContainers.Instance.tempo({ ...args, - image: `ghcr.io/tempoxyz/tempo${tag.startsWith('sha256:') ? '@' : ':'}${tag}`, + image: `ghcr.io/tempoxyz/tempo:${tag}`, }) : Instance.tempo(args), port: serverPort, diff --git a/test/tempo/setup.global.funding.ts b/test/tempo/setup.global.funding.ts deleted file mode 100644 index 479b1df00..000000000 --- a/test/tempo/setup.global.funding.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { port, tag } from './funding.js' -import { createServer } from './prool.js' - -export default async function () { - const server = await createServer({ port, tag }) - return server.start() -} diff --git a/vite.config.ts b/vite.config.ts index 5604ba374..83ae42bc7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,11 +6,6 @@ import { tag as tempoMultisigTag, } from './test/tempo/multisig.js' -import { - port as tempoFundingPort, - tag as tempoFundingTag, -} from './test/tempo/funding.js' - const root = import.meta.dirname /** @@ -196,24 +191,9 @@ export default defineConfig({ 'src/tempo/**/*.test.ts', '!src/tempo/e2e.test.ts', '!src/tempo/multisig.e2e.test.ts', - '!src/tempo/funding.e2e.test.ts', ], }, }, - { - extends: true, - test: { - name: 'tempo-funding', - include: ['src/tempo/funding.e2e.test.ts'], - globalSetup: [join(root, 'test/tempo/setup.global.funding.ts')], - hookTimeout: 180_000, - testTimeout: 60_000, - env: { - VITE_TEMPO_PORT: String(tempoFundingPort), - VITE_TEMPO_TAG: tempoFundingTag, - }, - }, - }, { extends: true, test: { From 6cdf9bd82684dd2cac79d2521758a39dc4fd34b4 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:40:04 +1000 Subject: [PATCH 04/28] feat(tempo): add funding requirement constructor --- .changeset/tempo-funding.md | 16 +- package.json | 20 +- src/tempo/Funding.test-d.ts | 28 --- src/tempo/FundingPolicy.test.ts | 61 ++++++ src/tempo/FundingPolicy.ts | 11 +- src/tempo/FundingRequirement.test-d.ts | 47 +++++ ...ing.test.ts => FundingRequirement.test.ts} | 176 +++++++++--------- .../{Funding.ts => FundingRequirement.ts} | 61 ++++-- src/tempo/NativeDexFunding.test.ts | 16 ++ src/tempo/Transaction.ts | 10 +- src/tempo/TransactionRequest.ts | 10 +- src/tempo/TxEnvelopeTempo.ts | 12 +- src/tempo/index.ts | 2 +- src/zod/tempo/FundingPolicy.ts | 7 +- .../{Funding.ts => FundingRequirement.ts} | 16 +- src/zod/tempo/Transaction.ts | 8 +- src/zod/tempo/TransactionRequest.ts | 20 +- src/zod/tempo/TxEnvelopeTempo.ts | 4 +- src/zod/tempo/_test/Funding.test.ts | 74 -------- .../tempo/_test/FundingRequirement.test.ts | 84 +++++++++ src/zod/tempo/z.ts | 2 +- 21 files changed, 408 insertions(+), 277 deletions(-) delete mode 100644 src/tempo/Funding.test-d.ts create mode 100644 src/tempo/FundingPolicy.test.ts create mode 100644 src/tempo/FundingRequirement.test-d.ts rename src/tempo/{Funding.test.ts => FundingRequirement.test.ts} (67%) rename src/tempo/{Funding.ts => FundingRequirement.ts} (76%) create mode 100644 src/tempo/NativeDexFunding.test.ts rename src/zod/tempo/{Funding.ts => FundingRequirement.ts} (76%) delete mode 100644 src/zod/tempo/_test/Funding.test.ts create mode 100644 src/zod/tempo/_test/FundingRequirement.test.ts diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md index a474640b7..5b0a46e20 100644 --- a/.changeset/tempo-funding.md +++ b/.changeset/tempo-funding.md @@ -1,19 +1,5 @@ --- -"ox": minor +"ox": patch --- Added Tempo funding transaction codecs, committed policy rules, native DEX funding payloads, and access key funding authorization. - -```ts -import { FundingPolicy, NativeDexFunding } from 'ox/tempo' - -const policyRules = FundingPolicy.encode({ - maxSlippageBps: 100, - sources: { - [outputToken]: [{ - target: nativeDexSource, - data: NativeDexFunding.encode({ tokenIn: inputToken, maxAmountIn: 30_000_000n }), - }], - }, -}) -``` diff --git a/package.json b/package.json index c7a351828..f43797df8 100644 --- a/package.json +++ b/package.json @@ -781,16 +781,16 @@ "types": "./dist/tempo/EarnShares.d.ts", "default": "./dist/tempo/EarnShares.js" }, - "./tempo/Funding": { - "src": "./src/tempo/Funding.ts", - "types": "./dist/tempo/Funding.d.ts", - "default": "./dist/tempo/Funding.js" - }, "./tempo/FundingPolicy": { "src": "./src/tempo/FundingPolicy.ts", "types": "./dist/tempo/FundingPolicy.d.ts", "default": "./dist/tempo/FundingPolicy.js" }, + "./tempo/FundingRequirement": { + "src": "./src/tempo/FundingRequirement.ts", + "types": "./dist/tempo/FundingRequirement.d.ts", + "default": "./dist/tempo/FundingRequirement.js" + }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", @@ -1241,16 +1241,16 @@ "types": "./dist/zod/tempo/AuthorizationTempo.d.ts", "default": "./dist/zod/tempo/AuthorizationTempo.js" }, - "./zod/tempo/Funding": { - "src": "./src/zod/tempo/Funding.ts", - "types": "./dist/zod/tempo/Funding.d.ts", - "default": "./dist/zod/tempo/Funding.js" - }, "./zod/tempo/FundingPolicy": { "src": "./src/zod/tempo/FundingPolicy.ts", "types": "./dist/zod/tempo/FundingPolicy.d.ts", "default": "./dist/zod/tempo/FundingPolicy.js" }, + "./zod/tempo/FundingRequirement": { + "src": "./src/zod/tempo/FundingRequirement.ts", + "types": "./dist/zod/tempo/FundingRequirement.d.ts", + "default": "./dist/zod/tempo/FundingRequirement.js" + }, "./zod/tempo/KeyAuthorization": { "src": "./src/zod/tempo/KeyAuthorization.ts", "types": "./dist/zod/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/Funding.test-d.ts b/src/tempo/Funding.test-d.ts deleted file mode 100644 index 23db44800..000000000 --- a/src/tempo/Funding.test-d.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expectTypeOf, test } from 'vp/test' -import * as Funding from './Funding.js' -import * as FundingPolicy from './FundingPolicy.js' -import * as KeyAuthorization from './KeyAuthorization.js' -import * as TxEnvelopeTempo from './TxEnvelopeTempo.js' - -test('funding requirements use executable values', () => { - expectTypeOf().toEqualTypeOf() - expectTypeOf().toEqualTypeOf< - number | undefined - >() - expectTypeOf().toEqualTypeOf< - readonly Funding.Requirement[] | undefined - >() - expectTypeOf().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}`>() -}) diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts new file mode 100644 index 000000000..2a5a5b796 --- /dev/null +++ b/src/tempo/FundingPolicy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vp/test' +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 index 18b59aafd..be1880728 100644 --- a/src/tempo/FundingPolicy.ts +++ b/src/tempo/FundingPolicy.ts @@ -3,7 +3,7 @@ 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 Funding from './Funding.js' +import type * as FundingRequirement from './FundingRequirement.js' /** * Funding permissions, represented by output token. */ @@ -13,7 +13,9 @@ export type Rules = { maxSlippageBps: number /** * Ordered source permissions for each output token. */ - sources: Readonly> + sources: Readonly< + Record + > } /** @@ -50,7 +52,7 @@ export type Route = { token: Address.Address /** * Ordered source permissions. */ - sources: readonly Funding.Source[] + sources: readonly FundingRequirement.Source[] } /** @@ -277,7 +279,8 @@ 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 new file mode 100644 index 000000000..58194ac19 --- /dev/null +++ b/src/tempo/FundingRequirement.test-d.ts @@ -0,0 +1,47 @@ +import { expectTypeOf, test } from 'vp/test' +import * as FundingRequirement from './FundingRequirement.js' +import * as FundingPolicy from './FundingPolicy.js' +import * as KeyAuthorization from './KeyAuthorization.js' +import * 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/Funding.test.ts b/src/tempo/FundingRequirement.test.ts similarity index 67% rename from src/tempo/Funding.test.ts rename to src/tempo/FundingRequirement.test.ts index fb7840a9a..e81f4d7d9 100644 --- a/src/tempo/Funding.test.ts +++ b/src/tempo/FundingRequirement.test.ts @@ -1,10 +1,9 @@ import { describe, expect, test } from 'vp/test' import type * as Hex from '../core/Hex.js' import * as Rlp from '../core/Rlp.js' -import * as Funding from './Funding.js' +import * as FundingRequirement from './FundingRequirement.js' import * as FundingPolicy from './FundingPolicy.js' import * as KeyAuthorization from './KeyAuthorization.js' -import * as NativeDexFunding from './NativeDexFunding.js' import * as TransactionRequest from './TransactionRequest.js' import * as TxEnvelopeTempo from './TxEnvelopeTempo.js' @@ -22,13 +21,30 @@ const envelope = TxEnvelopeTempo.from({ requireFunds: [requirement], }) -describe('funding transaction codecs', () => { +describe('toTuple', () => { test('matches the Rust requirement golden vector', () => { // tempo 4926397: funding::funding_requirement_golden. - expect(Rlp.fromHex(Funding.toTuple(requirement))).toBe( + 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 }, @@ -62,14 +78,7 @@ describe('funding transaction codecs', () => { expect(rpc.requireFunds?.[0]?.amount).toBe('0x32') expect(TransactionRequest.fromRpc(rpc).requireFunds).toEqual([requirement]) }) - test('distinguishes omitted and explicit zero slippage', () => { - expect( - Funding.toTuple({ ...requirement, slippageBps: undefined })[3], - ).toEqual([]) - expect(Funding.toTuple({ ...requirement, slippageBps: 0 })[3]).toEqual([ - '0x', - ]) - }) + test('commits funding to the sender signature', () => { expect(TxEnvelopeTempo.getSignPayload(envelope)).not.toBe( TxEnvelopeTempo.getSignPayload({ @@ -78,13 +87,16 @@ describe('funding transaction codecs', () => { }), ) }) + test('rejects invalid values and malformed extensions', () => { - expect(() => Funding.toTuple({ ...requirement, amount: -1n })).toThrow() expect(() => - Funding.toTuple({ ...requirement, slippageBps: 10_001 }), + FundingRequirement.toTuple({ ...requirement, amount: -1n }), + ).toThrow() + expect(() => + FundingRequirement.toTuple({ ...requirement, slippageBps: 10_001 }), ).toThrow() expect(() => - Funding.toTuple({ ...requirement, policyRules: '0x' }), + FundingRequirement.toTuple({ ...requirement, policyRules: '0x' }), ).toThrow() const raw = Rlp.toHex( TxEnvelopeTempo.serialize(envelope).replace( @@ -98,32 +110,7 @@ describe('funding transaction codecs', () => { ), ).toThrow() }) -}) -describe('malformed funding encodings', () => { - test('rejects unsafe numeric RPC inputs', () => { - expect(() => - Funding.toRpc({ ...requirement, amount: Number.MAX_SAFE_INTEGER + 1 }), - ).toThrow() - expect(() => FundingPolicy.toRpc(Number.MAX_SAFE_INTEGER + 1)).toThrow() - }) - - 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(() => Funding.fromTuple(tuple as never)).toThrow() - }) - test('accepts uint256 maximum and rejects overflow', () => { - const value = { ...requirement, amount: 2n ** 256n - 1n } - expect(Funding.fromTuple(Funding.toTuple(value))).toEqual(value) - expect(() => Funding.toTuple({ ...value, amount: 2n ** 256n })).toThrow() - }) test('rejects a stray authorization placeholder and trailing fields', () => { const base = Rlp.toHex( TxEnvelopeTempo.serialize({ @@ -134,7 +121,7 @@ describe('malformed funding encodings', () => { for (const trailing of [ ['0x'], ['0x', []], - ['0x', [Funding.toTuple(requirement)], [], '0x'], + ['0x', [FundingRequirement.toTuple(requirement)], [], '0x'], ]) expect(() => TxEnvelopeTempo.deserialize( @@ -142,20 +129,7 @@ describe('malformed funding encodings', () => { ), ).toThrow() }) -}) -describe('policy codecs', () => { - const rules = { - maxSlippageBps: 100, - sources: { [token]: requirement.sources }, - } - 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() - }) test('extends key authorization with policy ID and inline rules', () => { for (const fundingPolicy of [7n, { admins: [token], rules }]) { const authorization = { @@ -169,6 +143,7 @@ describe('policy codecs', () => { ).toEqual(authorization) } }) + test('matches independent Rust key authorization vector', () => { // tempo 4926397: funding_policy::policy_reference_has_canonical_trailing_encoding. const authorization = { @@ -181,47 +156,72 @@ describe('policy codecs', () => { '0xde018094111111111111111111111111111111111111111180808080808007', ) }) - 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('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() }) - test('rejects invalid policy IDs and admins', () => { - for (const value of [ - 0n, - 2n ** 64n, - { admins: [], rules }, - { admins: [token, token], rules }, +}) + +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(() => FundingPolicy.toTuple(value)).toThrow() + expect(() => FundingRequirement.fromTuple(tuple as never)).toThrow() }) }) -describe('native DEX payload', () => { - test('preserves zero and defaults to unlimited input', () => { +describe('assert', () => { + test('accepts uint256 maximum and rejects overflow', () => { + const value = { ...requirement, amount: 2n ** 256n - 1n } expect( - NativeDexFunding.decode(NativeDexFunding.encode({ tokenIn: token })), - ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + FundingRequirement.fromTuple(FundingRequirement.toTuple(value)), + ).toEqual(value) + expect(() => + FundingRequirement.toTuple({ ...value, amount: 2n ** 256n }), + ).toThrow() + }) +}) + +describe('from', () => { + test('default', () => { expect( - NativeDexFunding.decode( - NativeDexFunding.encode({ tokenIn: token, maxAmountIn: 0n }), - ), - ).toEqual({ tokenIn: token, maxAmountIn: 0n }) + 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/Funding.ts b/src/tempo/FundingRequirement.ts similarity index 76% rename from src/tempo/Funding.ts rename to src/tempo/FundingRequirement.ts index 301de0613..eaedcc467 100644 --- a/src/tempo/Funding.ts +++ b/src/tempo/FundingRequirement.ts @@ -16,7 +16,7 @@ export type Source = { /** * Target balance to satisfy before application calls. */ -export type Requirement = { +export type FundingRequirement = { /** * Requested output token. */ token: Address.Address @@ -36,7 +36,7 @@ export type Requirement = { /** * RPC funding requirement. */ -export type Rpc = Requirement +export type Rpc = FundingRequirement /** * RLP funding requirement tuple. */ @@ -53,13 +53,13 @@ export type Tuple = readonly [ * * @example * ```ts - * import { Funding } from 'ox/tempo' + * import { FundingRequirement } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * Funding.assert({ token, amount: 50n, sources: [] }) + * FundingRequirement.assert({ token, amount: 50n, sources: [] }) * ``` */ -export function assert(value: Requirement): void { +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.') @@ -89,18 +89,39 @@ export function assert(value: Requirement): void { } } +/** + * 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 { Funding } from 'ox/tempo' + * import { FundingRequirement } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * Funding.toTuple({ token, amount: 50n, sources: [] }) + * FundingRequirement.toTuple({ token, amount: 50n, sources: [] }) * ``` */ -export function toTuple(value: Requirement): Tuple { +export function toTuple(value: FundingRequirement): Tuple { assert(value) return [ value.token, @@ -124,13 +145,13 @@ export function toTuple(value: Requirement): Tuple { * * @example * ```ts - * import { Funding } from 'ox/tempo' + * import { FundingRequirement } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * Funding.fromTuple([token, '0x32', [], []]) + * FundingRequirement.fromTuple([token, '0x32', [], []]) * ``` */ -export function fromTuple(value: Tuple): Requirement { +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 @@ -149,7 +170,7 @@ export function fromTuple(value: Tuple): Requirement { throw new InvalidRequirementError('Noncanonical funding integer.') return hex === '0x' ? 0n : BigInt(hex) } - const requirement: Requirement = { + const requirement: FundingRequirement = { token, amount: decode(amount), sources: sources.map((source) => { @@ -169,15 +190,15 @@ export function fromTuple(value: Tuple): Requirement { * * @example * ```ts - * import { Funding } from 'ox/tempo' + * import { FundingRequirement } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * Funding.fromRpc({ token, amount: '0x32', sources: [] }) + * FundingRequirement.fromRpc({ token, amount: '0x32', sources: [] }) * ``` */ -export function fromRpc(value: Rpc): Requirement { +export function fromRpc(value: Rpc): FundingRequirement { const { amount, slippageBps, ...rest } = value - const result: Requirement = { + const result: FundingRequirement = { ...rest, amount: BigInt(amount), ...(slippageBps === undefined ? {} : { slippageBps: Number(slippageBps) }), @@ -191,14 +212,14 @@ export function fromRpc(value: Rpc): Requirement { * * @example * ```ts - * import { Funding } from 'ox/tempo' + * import { FundingRequirement } from 'ox/tempo' * * const token = '0x20c0000000000000000000000000000000000001' as const - * Funding.toRpc({ token, amount: 50n, sources: [] }) + * FundingRequirement.toRpc({ token, amount: 50n, sources: [] }) * ``` */ export function toRpc( - value: Requirement, + value: FundingRequirement, ): Rpc { assert({ ...value, @@ -219,7 +240,7 @@ export function toRpc( /** * Thrown when funding fields violate the protocol encoding. */ export class InvalidRequirementError extends Errors.BaseError { - override readonly name = 'Funding.InvalidRequirementError' + override readonly name = 'FundingRequirement.InvalidRequirementError' constructor(message: string) { super(message) } diff --git a/src/tempo/NativeDexFunding.test.ts b/src/tempo/NativeDexFunding.test.ts new file mode 100644 index 000000000..40212f41a --- /dev/null +++ b/src/tempo/NativeDexFunding.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vp/test' +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/Transaction.ts b/src/tempo/Transaction.ts index b3e96d201..7d2e83c4e 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -7,7 +7,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 Funding from './Funding.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' @@ -82,7 +82,7 @@ export type Tempo< /** Key authorization for provisioning a new access key. */ /** Required balances before application calls. */ requireFunds?: - | readonly Funding.Requirement[] + | readonly FundingRequirement.FundingRequirement[] | undefined keyAuthorization?: | KeyAuthorization.KeyAuthorization @@ -257,7 +257,9 @@ export function fromRpc< if (transaction.validBefore) transaction_.validBefore = Number(transaction.validBefore) if (transaction.requireFunds) - transaction_.requireFunds = transaction.requireFunds.map(Funding.fromRpc) + transaction_.requireFunds = transaction.requireFunds.map( + FundingRequirement.fromRpc, + ) if (transaction.keyAuthorization) transaction_.keyAuthorization = KeyAuthorization.fromRpc( transaction.keyAuthorization, @@ -370,7 +372,7 @@ export function toRpc( })) if (transaction.feeToken) rpc.feeToken = transaction.feeToken if (transaction.requireFunds) - rpc.requireFunds = transaction.requireFunds.map(Funding.toRpc) + 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 cdc73b4f7..201c25def 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -6,7 +6,7 @@ import type { Compute } from '../core/internal/types.js' import * as Signature from '../core/Signature.js' import * as ox_TransactionRequest from '../core/TransactionRequest.js' import * as AuthorizationTempo from './AuthorizationTempo.js' -import * as Funding from './Funding.js' +import * as FundingRequirement from './FundingRequirement.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as MultisigSimulation from './MultisigSimulation.js' import * as SignatureEnvelope from './SignatureEnvelope.js' @@ -43,7 +43,7 @@ export type TransactionRequest< feeToken?: Address.Address | undefined /** Required balances before application calls. */ requireFunds?: - | readonly Funding.Requirement[] + | readonly FundingRequirement.FundingRequirement[] | undefined keyAuthorization?: KeyAuthorization.KeyAuthorization | undefined keyAuthorizationSimulation?: MultisigSimulation.Spec | undefined @@ -139,7 +139,7 @@ export function fromRpc(request: Rpc): TransactionRequest { if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken if (request.requireFunds) - request_.requireFunds = request.requireFunds.map(Funding.fromRpc) + request_.requireFunds = request.requireFunds.map(FundingRequirement.fromRpc) if (request.keyAuthorization) request_.keyAuthorization = KeyAuthorization.fromRpc( request.keyAuthorization, @@ -281,7 +281,9 @@ export function toRpc(request: toRpc.Input): Rpc { ) request_rpc.feeToken = request.feeToken if (request.requireFunds) - request_rpc.requireFunds = request.requireFunds.map(Funding.toRpc) + request_rpc.requireFunds = request.requireFunds.map( + FundingRequirement.toRpc, + ) if (request.keyAuthorization) request_rpc.keyAuthorization = KeyAuthorization.toRpc( request.keyAuthorization, diff --git a/src/tempo/TxEnvelopeTempo.ts b/src/tempo/TxEnvelopeTempo.ts index 79ac79de5..899cd3425 100644 --- a/src/tempo/TxEnvelopeTempo.ts +++ b/src/tempo/TxEnvelopeTempo.ts @@ -15,7 +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 Funding from './Funding.js' +import * as FundingRequirement from './FundingRequirement.js' import * as KeyAuthorization from './KeyAuthorization.js' import * as SignatureEnvelope from './SignatureEnvelope.js' import type * as TransactionRequest from './TransactionRequest.js' @@ -101,7 +101,7 @@ export type TxEnvelopeTempo< | undefined /** Required balances processed before application calls. */ requireFunds?: - | readonly Funding.Requirement[] + | readonly FundingRequirement.FundingRequirement[] | undefined /** Total fee per gas in wei (gasPrice/baseFeePerGas + maxPriorityFeePerGas). */ maxFeePerGas?: bigintType | undefined @@ -186,7 +186,7 @@ export function assert(envelope: PartialBy) { } = envelope for (const requirement of envelope.requireFunds ?? []) - Funding.assert(requirement) + FundingRequirement.assert(requirement) // Calls must not be empty if (!calls || calls.length === 0) throw new CallsEmptyError() @@ -328,7 +328,7 @@ export function deserialize(serialized: Serialized): Compute { type, }) const requireFunds = funding?.map((value) => - Funding.fromTuple(value as Funding.Tuple), + FundingRequirement.fromTuple(value as FundingRequirement.Tuple), ) let transaction = { @@ -743,7 +743,9 @@ export function serialize( : requireFunds?.length ? ['0x' as const] : []), - ...(requireFunds?.length ? [requireFunds.map(Funding.toTuple)] : []), + ...(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 c82274870..6fb918636 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -640,7 +640,7 @@ export * as ZoneRpcAuthentication from './ZoneRpcAuthentication.js' * * @category Reference */ -export * as Funding from './Funding.js' +export * as FundingRequirement from './FundingRequirement.js' /** * Funding policy commitments and authorization codecs. diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index 2d45914ce..e675754e8 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -3,13 +3,16 @@ import * as core_FundingPolicy from '../../tempo/FundingPolicy.js' import * as z_Address from '../Address.js' import * as z_Hex from '../Hex.js' import { uintBigintNumberish } from '../internal/Integer.js' -import * as z_Funding from './Funding.js' +import * as z_FundingRequirement from './FundingRequirement.js' /** Funding permissions keyed by output token. */ export const Rules = z .object({ maxSlippageBps: z.number(), - sources: z.record(z_Address.Address, z.readonly(z.array(z_Funding.Source))), + sources: z.record( + z_Address.Address, + z.readonly(z.array(z_FundingRequirement.Source)), + ), }) .check( z.refine((value) => { diff --git a/src/zod/tempo/Funding.ts b/src/zod/tempo/FundingRequirement.ts similarity index 76% rename from src/zod/tempo/Funding.ts rename to src/zod/tempo/FundingRequirement.ts index 3939f4ba0..58c982046 100644 --- a/src/zod/tempo/Funding.ts +++ b/src/zod/tempo/FundingRequirement.ts @@ -1,5 +1,5 @@ import * as z from 'zod/mini' -import * as core_Funding from '../../tempo/Funding.js' +import * as core_FundingRequirement from '../../tempo/FundingRequirement.js' import * as z_Address from '../Address.js' import * as z_Hex from '../Hex.js' import { @@ -31,7 +31,7 @@ export const Domain = z .check( z.refine((value) => { try { - core_Funding.assert(value) + core_FundingRequirement.assert(value) return true } catch { return false @@ -49,13 +49,13 @@ export const DomainToRpc = z.object({ }) /** RPC funding requirement codec. */ -export const Requirement = z.codec(Rpc, Domain, { - decode: core_Funding.fromRpc, - encode: core_Funding.toRpc, +export const FundingRequirement = z.codec(Rpc, Domain, { + decode: core_FundingRequirement.fromRpc, + encode: core_FundingRequirement.toRpc, }) /** Encode-only funding codec accepting numberish quantities. */ -export const RequirementToRpc = z.codec(Rpc, DomainToRpc, { - decode: core_Funding.fromRpc, - encode: core_Funding.toRpc, +export const FundingRequirementToRpc = z.codec(Rpc, DomainToRpc, { + decode: core_FundingRequirement.fromRpc, + encode: core_FundingRequirement.toRpc, }) diff --git a/src/zod/tempo/Transaction.ts b/src/zod/tempo/Transaction.ts index c8e3716e8..419be849d 100644 --- a/src/zod/tempo/Transaction.ts +++ b/src/zod/tempo/Transaction.ts @@ -1,5 +1,5 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ -import * as z_Funding from './Funding.js' +import * as z_FundingRequirement from './FundingRequirement.js' import * as z_AccessList from '../AccessList.js' import * as z_Address from '../Address.js' import * as z_Hex from '../Hex.js' @@ -125,7 +125,7 @@ export const Tempo = wire( z_Number.Number, Call, z_AuthorizationTempo.ListSigned, - z_Funding.Requirement, + z_FundingRequirement.FundingRequirement, ), ), ) @@ -138,7 +138,7 @@ export const TempoToRpc = wire( z_Number.NumberToRpc, CallToRpc, z_AuthorizationTempo.ListSignedToRpc, - z_Funding.RequirementToRpc, + z_FundingRequirement.FundingRequirementToRpc, ), ), ) @@ -151,7 +151,7 @@ export const PendingTempo = wire( z_Number.Number, Call, z_AuthorizationTempo.ListSigned, - z_Funding.Requirement, + z_FundingRequirement.FundingRequirement, ), blockHash: z.null(), blockNumber: z.null(), diff --git a/src/zod/tempo/TransactionRequest.ts b/src/zod/tempo/TransactionRequest.ts index 532ff0eb9..33fc54f51 100644 --- a/src/zod/tempo/TransactionRequest.ts +++ b/src/zod/tempo/TransactionRequest.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ -import * as core_Funding from '../../tempo/Funding.js' -import * as z_Funding from './Funding.js' +import * as core_FundingRequirement from '../../tempo/FundingRequirement.js' +import * as z_FundingRequirement from './FundingRequirement.js' import * as core_Hex from '../../core/Hex.js' import type * as core_TransactionRequest from '../../tempo/TransactionRequest.js' import * as z_AccessList from '../AccessList.js' @@ -79,7 +79,7 @@ export const Rpc = z.object({ gas: z.optional(z_Hex.Hex), gasPrice: z.optional(z_Hex.Hex), input: z.optional(z_Hex.Hex), - requireFunds: z.optional(z.readonly(z.array(z_Funding.Rpc))), + requireFunds: z.optional(z.readonly(z.array(z_FundingRequirement.Rpc))), keyAuthorization: z.optional(z_KeyAuthorization.Rpc), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -134,7 +134,7 @@ export const Domain = z.object({ gas: z.optional(z.bigint()), gasPrice: z.optional(z.bigint()), input: z.optional(z_Hex.Hex), - requireFunds: z.optional(z.readonly(z.array(z_Funding.Domain))), + requireFunds: z.optional(z.readonly(z.array(z_FundingRequirement.Domain))), keyAuthorization: z.optional(z_KeyAuthorization.Domain), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -175,7 +175,9 @@ export const DomainToRpc = z.object({ gas: z.optional(uintBigintNumberish()), gasPrice: z.optional(uintBigintNumberish()), input: z.optional(z_Hex.Hex), - requireFunds: z.optional(z.readonly(z.array(z_Funding.DomainToRpc))), + requireFunds: z.optional( + z.readonly(z.array(z_FundingRequirement.DomainToRpc)), + ), keyAuthorization: z.optional(z_KeyAuthorization.DomainToRpc), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -248,7 +250,9 @@ function fromRpc( if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken if (request.requireFunds) - request_.requireFunds = request.requireFunds.map(core_Funding.fromRpc) + request_.requireFunds = request.requireFunds.map( + core_FundingRequirement.fromRpc, + ) if (request.keyAuthorization) request_.keyAuthorization = z.decode( z_KeyAuthorization.KeyAuthorization, @@ -342,7 +346,9 @@ function toRpc( ) request_rpc.feeToken = request.feeToken if (request.requireFunds) - request_rpc.requireFunds = request.requireFunds.map(core_Funding.toRpc) + request_rpc.requireFunds = request.requireFunds.map( + core_FundingRequirement.toRpc, + ) if (request.keyAuthorization) request_rpc.keyAuthorization = z.encode( z_KeyAuthorization.KeyAuthorizationToRpc, diff --git a/src/zod/tempo/TxEnvelopeTempo.ts b/src/zod/tempo/TxEnvelopeTempo.ts index c7ec66c05..1b7ecdfd7 100644 --- a/src/zod/tempo/TxEnvelopeTempo.ts +++ b/src/zod/tempo/TxEnvelopeTempo.ts @@ -1,5 +1,5 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ -import * as z_Funding from './Funding.js' +import * as z_FundingRequirement from './FundingRequirement.js' import * as core_TxEnvelopeTempo from '../../tempo/TxEnvelopeTempo.js' import * as z_AccessList from '../AccessList.js' import * as z_Address from '../Address.js' @@ -35,7 +35,7 @@ const baseFields = { feeToken: z.optional(z_Address.Address), from: z.optional(z_Address.Address), gas: z.optional(z.bigint()), - requireFunds: z.optional(z.readonly(z.array(z_Funding.Domain))), + requireFunds: z.optional(z.readonly(z.array(z_FundingRequirement.Domain))), keyAuthorization: z.optional(z_KeyAuthorization.Domain), maxFeePerGas: z.optional(z.bigint()), maxPriorityFeePerGas: z.optional(z.bigint()), diff --git a/src/zod/tempo/_test/Funding.test.ts b/src/zod/tempo/_test/Funding.test.ts deleted file mode 100644 index 472350cef..000000000 --- a/src/zod/tempo/_test/Funding.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import * as z from 'zod/mini' -import { expect, test } from 'vp/test' -import * as Funding from '../Funding.js' -import * as FundingPolicy from '../FundingPolicy.js' -import * as z_KeyAuthorization from '../KeyAuthorization.js' -import * as TransactionRequest from '../TransactionRequest.js' -import * as TxEnvelopeTempo from '../TxEnvelopeTempo.js' - -const token = '0x0101010101010101010101010101010101010101' as const -const requirement = { - token, - amount: 50n, - slippageBps: 0, - sources: [{ target: token, data: '0xab' as const }], -} - -test('funding RPC and request codecs retain all fields', () => { - const rpc = z.encode(Funding.Requirement, requirement) - expect(rpc.amount).toBe('0x32') - expect(rpc.slippageBps).toBe('0x0') - expect(z.decode(Funding.Requirement, rpc)).toEqual(requirement) - const request = z.encode(TransactionRequest.TransactionRequest, { - requireFunds: [requirement], - }) - expect(request.type).toBe('0x76') - expect( - z.decode(TransactionRequest.TransactionRequest, request).requireFunds, - ).toEqual([requirement]) -}) - -test('envelope schema preserves funding and rejects invalid slippage', () => { - const envelope = { - type: 'tempo' as const, - chainId: 1, - calls: [{ to: token }], - requireFunds: [requirement], - } - expect( - z.parse(TxEnvelopeTempo.TxEnvelopeTempo, envelope).requireFunds, - ).toEqual([requirement]) - expect( - z.safeParse(Funding.Domain, { ...requirement, slippageBps: 10001 }).success, - ).toBe(false) -}) - -test('key schema preserves existing and inline policies', () => { - for (const fundingPolicy of [ - 7n, - { - admins: [token], - rules: { maxSlippageBps: 100, sources: { [token]: requirement.sources } }, - }, - ]) { - const value = { - address: token, - chainId: 1n, - type: 'secp256k1' as const, - fundingPolicy, - signature: { - type: 'secp256k1' as const, - signature: { - r: `0x${'01'.repeat(32)}` as const, - s: `0x${'02'.repeat(32)}` as const, - yParity: 0 as const, - }, - }, - } - const encoded = z.encode(z_KeyAuthorization.KeyAuthorization, value) - expect( - z.decode(z_KeyAuthorization.KeyAuthorization, encoded).fundingPolicy, - ).toEqual(fundingPolicy) - } - expect(z.safeParse(FundingPolicy.Authorization, 0n).success).toBe(false) -}) diff --git a/src/zod/tempo/_test/FundingRequirement.test.ts b/src/zod/tempo/_test/FundingRequirement.test.ts new file mode 100644 index 000000000..7ba0b18e0 --- /dev/null +++ b/src/zod/tempo/_test/FundingRequirement.test.ts @@ -0,0 +1,84 @@ +import * as z from 'zod/mini' +import { describe, expect, test } from 'vp/test' +import * as FundingRequirement from '../FundingRequirement.js' +import * as FundingPolicy from '../FundingPolicy.js' +import * as z_KeyAuthorization from '../KeyAuthorization.js' +import * as TransactionRequest from '../TransactionRequest.js' +import * as TxEnvelopeTempo from '../TxEnvelopeTempo.js' + +const token = '0x0101010101010101010101010101010101010101' as const +const requirement = { + token, + amount: 50n, + slippageBps: 0, + sources: [{ target: token, data: '0xab' as const }], +} + +describe('behavior', () => { + test('funding RPC and request codecs retain all fields', () => { + const rpc = z.encode(FundingRequirement.FundingRequirement, requirement) + expect(rpc.amount).toBe('0x32') + expect(rpc.slippageBps).toBe('0x0') + expect(z.decode(FundingRequirement.FundingRequirement, rpc)).toEqual( + requirement, + ) + const request = z.encode(TransactionRequest.TransactionRequest, { + requireFunds: [requirement], + }) + expect(request.type).toBe('0x76') + expect( + z.decode(TransactionRequest.TransactionRequest, request).requireFunds, + ).toEqual([requirement]) + }) + + test('envelope schema preserves funding and rejects invalid slippage', () => { + const envelope = { + type: 'tempo' as const, + chainId: 1, + calls: [{ to: token }], + requireFunds: [requirement], + } + expect( + z.parse(TxEnvelopeTempo.TxEnvelopeTempo, envelope).requireFunds, + ).toEqual([requirement]) + expect( + z.safeParse(FundingRequirement.Domain, { + ...requirement, + slippageBps: 10001, + }).success, + ).toBe(false) + }) + + test('key schema preserves existing and inline policies', () => { + for (const fundingPolicy of [ + 7n, + { + admins: [token], + rules: { + maxSlippageBps: 100, + sources: { [token]: requirement.sources }, + }, + }, + ]) { + const value = { + address: token, + chainId: 1n, + type: 'secp256k1' as const, + fundingPolicy, + signature: { + type: 'secp256k1' as const, + signature: { + r: `0x${'01'.repeat(32)}` as const, + s: `0x${'02'.repeat(32)}` as const, + yParity: 0 as const, + }, + }, + } + const encoded = z.encode(z_KeyAuthorization.KeyAuthorization, value) + expect( + z.decode(z_KeyAuthorization.KeyAuthorization, encoded).fundingPolicy, + ).toEqual(fundingPolicy) + } + expect(z.safeParse(FundingPolicy.Authorization, 0n).success).toBe(false) + }) +}) diff --git a/src/zod/tempo/z.ts b/src/zod/tempo/z.ts index 095fc7919..ec3d48892 100644 --- a/src/zod/tempo/z.ts +++ b/src/zod/tempo/z.ts @@ -1,6 +1,6 @@ /* eslint-disable jsdoc-js/require-jsdoc, jsdoc-js/require-description, jsdoc-js/require-example */ export * as AuthorizationTempo from './AuthorizationTempo.js' -export * as Funding from './Funding.js' +export * as FundingRequirement from './FundingRequirement.js' export * as FundingPolicy from './FundingPolicy.js' export * as KeyAuthorization from './KeyAuthorization.js' export * as MultisigConfig from './MultisigConfig.js' From 729d50ca1eb30b6754b06302eb60568e990999dd Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:44:16 +1000 Subject: [PATCH 05/28] 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 e81f4d7d9..e94089a2c 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 3c2408b86a275fcdb15475b5ac65cd3ad930ea95 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:51:16 +1000 Subject: [PATCH 06/28] docs: format jsdoc examples --- src/core/Frame.ts | 5 +++- src/core/TxEnvelope.ts | 4 +++- src/tempo/FundingRequirement.ts | 41 +++++++++++++++++++++++++-------- src/tempo/NativeDexFunding.ts | 15 ++++++++---- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/core/Frame.ts b/src/core/Frame.ts index a55b1cf12..6f4937f9f 100644 --- a/src/core/Frame.ts +++ b/src/core/Frame.ts @@ -241,7 +241,10 @@ export declare namespace fromRpc { * ```ts twoslash * import { Frame } from 'ox' * - * const frame = Frame.from({ executionGas: 50_000n, mode: 'verify' }) + * const frame = Frame.from({ + * executionGas: 50_000n, + * mode: 'verify' + * }) * const rpc = Frame.toRpc(frame) * ``` * diff --git a/src/core/TxEnvelope.ts b/src/core/TxEnvelope.ts index 568b86b5e..02564cf86 100644 --- a/src/core/TxEnvelope.ts +++ b/src/core/TxEnvelope.ts @@ -242,7 +242,9 @@ export declare namespace deserialize { * * const envelope = TransactionEnvelope.from({ * chainId: 1, - * frames: [Frame.from({ executionGas: 50_000n, mode: 'sender' })], + * frames: [ + * Frame.from({ executionGas: 50_000n, mode: 'sender' }) + * ], * sender: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' * }) * ``` diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index eaedcc467..ef4a88c68 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -55,8 +55,13 @@ export type Tuple = readonly [ * ```ts * import { FundingRequirement } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const - * FundingRequirement.assert({ token, amount: 50n, sources: [] }) + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.assert({ + * token, + * amount: 50n, + * sources: [] + * }) * ``` */ export function assert(value: FundingRequirement): void { @@ -99,7 +104,7 @@ export function assert(value: FundingRequirement): void { * const requirement = FundingRequirement.from({ * token: '0x20c0000000000000000000000000000000000001', * amount: 50n, - * sources: [], + * sources: [] * }) * ``` */ @@ -117,8 +122,13 @@ export function from( * ```ts * import { FundingRequirement } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const - * FundingRequirement.toTuple({ token, amount: 50n, sources: [] }) + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.toTuple({ + * token, + * amount: 50n, + * sources: [] + * }) * ``` */ export function toTuple(value: FundingRequirement): Tuple { @@ -147,7 +157,8 @@ export function toTuple(value: FundingRequirement): Tuple { * ```ts * import { FundingRequirement } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const + * const token = + * '0x20c0000000000000000000000000000000000001' as const * FundingRequirement.fromTuple([token, '0x32', [], []]) * ``` */ @@ -192,8 +203,13 @@ export function fromTuple(value: Tuple): FundingRequirement { * ```ts * import { FundingRequirement } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const - * FundingRequirement.fromRpc({ token, amount: '0x32', sources: [] }) + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.fromRpc({ + * token, + * amount: '0x32', + * sources: [] + * }) * ``` */ export function fromRpc(value: Rpc): FundingRequirement { @@ -214,8 +230,13 @@ export function fromRpc(value: Rpc): FundingRequirement { * ```ts * import { FundingRequirement } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const - * FundingRequirement.toRpc({ token, amount: 50n, sources: [] }) + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * FundingRequirement.toRpc({ + * token, + * amount: 50n, + * sources: [] + * }) * ``` */ export function toRpc( diff --git a/src/tempo/NativeDexFunding.ts b/src/tempo/NativeDexFunding.ts index 6bb09591c..525e23fd6 100644 --- a/src/tempo/NativeDexFunding.ts +++ b/src/tempo/NativeDexFunding.ts @@ -22,8 +22,12 @@ const parameters = AbiParameters.from('address tokenIn, uint256 maxAmountIn') * ```ts * import { NativeDexFunding } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const - * NativeDexFunding.encode({ tokenIn: token, maxAmountIn: 30_000_000n }) + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * NativeDexFunding.encode({ + * tokenIn: token, + * maxAmountIn: 30_000_000n + * }) * ``` */ export function encode(request: Request): Hex.Hex { @@ -40,8 +44,11 @@ export function encode(request: Request): Hex.Hex { * ```ts * import { NativeDexFunding } from 'ox/tempo' * - * const token = '0x20c0000000000000000000000000000000000001' as const - * NativeDexFunding.decode(NativeDexFunding.encode({ tokenIn: token })) + * const token = + * '0x20c0000000000000000000000000000000000001' as const + * NativeDexFunding.decode( + * NativeDexFunding.encode({ tokenIn: token }) + * ) * ``` */ export function decode(data: Hex.Hex): Required { From 99c0b75af9ba334fe4f1aa9ebfe3467e285c5bed Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:26:04 +1000 Subject: [PATCH 07/28] 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 | 2 ++ src/tempo/TransactionRequest.ts | 2 ++ src/zod/tempo/FundingPolicy.ts | 5 +-- src/zod/tempo/FundingRequirement.ts | 6 ++-- .../tempo/_test/FundingRequirement.test.ts | 9 ++++-- 9 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/tempo/FundingPolicy.ts b/src/tempo/FundingPolicy.ts index be1880728..e9dc9db61 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 58194ac19..8496906e7 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 e94089a2c..50f109798 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 ef4a88c68..00bdfffda 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. */ @@ -85,7 +87,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 @@ -136,7 +138,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 ? [] : [ @@ -187,7 +189,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 } : {}), @@ -213,10 +215,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) @@ -248,10 +251,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 7d2e83c4e..2566861ef 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -109,6 +109,7 @@ export type TempoRpc = Compute< Omit< Tempo, | 'authorizationList' + | 'requireFunds' | 'calls' | 'feePayerSignature' | 'gasPrice' @@ -118,6 +119,7 @@ export type TempoRpc = Compute< | 'validAfter' | 'validBefore' > & { + requireFunds?: readonly FundingRequirement.Rpc[] | undefined aaAuthorizationList?: AuthorizationTempo.ListRpc | undefined calls: | readonly { diff --git a/src/tempo/TransactionRequest.ts b/src/tempo/TransactionRequest.ts index 201c25def..57cdccd50 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -62,6 +62,7 @@ export type TransactionRequest< export type Rpc = Omit< TransactionRequest, | 'authorizationList' + | 'requireFunds' | 'feePayerSignature' | 'feeToken' | 'keyAuthorization' @@ -69,6 +70,7 @@ export type Rpc = Omit< | 'keyAuthorizationSimulation' | 'signature' > & { + requireFunds?: readonly FundingRequirement.Rpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feePayerSignature?: Signature.Rpc | null | undefined feeToken?: Hex.Hex | undefined diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index e675754e8..0e4e9b8cd 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -3,7 +3,6 @@ import * as core_FundingPolicy from '../../tempo/FundingPolicy.js' import * as z_Address from '../Address.js' import * as z_Hex from '../Hex.js' import { uintBigintNumberish } from '../internal/Integer.js' -import * as z_FundingRequirement from './FundingRequirement.js' /** Funding permissions keyed by output token. */ export const Rules = z @@ -11,7 +10,9 @@ export const Rules = z maxSlippageBps: z.number(), sources: z.record( z_Address.Address, - z.readonly(z.array(z_FundingRequirement.Source)), + z.readonly( + z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), + ), ), }) .check( diff --git a/src/zod/tempo/FundingRequirement.ts b/src/zod/tempo/FundingRequirement.ts index 58c982046..3070a7e09 100644 --- a/src/zod/tempo/FundingRequirement.ts +++ b/src/zod/tempo/FundingRequirement.ts @@ -8,7 +8,7 @@ import { } from '../internal/Integer.js' /** Concrete funding source schema. */ -export const Source = z.object({ target: z_Address.Address, data: z_Hex.Hex }) +export const Source = z.object({ to: z_Address.Address, data: z_Hex.Hex }) /** RPC funding requirement schema. */ export const Rpc = z.object({ @@ -16,7 +16,9 @@ export const Rpc = z.object({ amount: z_Hex.Hex, policyRules: z.optional(z_Hex.Hex), slippageBps: z.optional(z_Hex.Hex), - sources: z.readonly(z.array(Source)), + sources: z.readonly( + z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), + ), }) /** Domain funding requirement schema. */ diff --git a/src/zod/tempo/_test/FundingRequirement.test.ts b/src/zod/tempo/_test/FundingRequirement.test.ts index 7ba0b18e0..fc1bb947f 100644 --- a/src/zod/tempo/_test/FundingRequirement.test.ts +++ b/src/zod/tempo/_test/FundingRequirement.test.ts @@ -11,7 +11,7 @@ const requirement = { token, amount: 50n, slippageBps: 0, - sources: [{ target: token, data: '0xab' as const }], + sources: [{ to: token, data: '0xab' as const }], } describe('behavior', () => { @@ -56,7 +56,12 @@ describe('behavior', () => { admins: [token], rules: { maxSlippageBps: 100, - sources: { [token]: requirement.sources }, + sources: { + [token]: requirement.sources.map(({ to, data }) => ({ + target: to, + data, + })), + }, }, }, ]) { From 45c2c9d10b4c4b1eb8f95db41e27438dab72a472 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:23 +1000 Subject: [PATCH 08/28] refactor(tempo): rename dex funding source --- package.json | 10 +++++----- ...tiveDexFunding.test.ts => DexFundingSource.test.ts} | 8 ++++---- src/tempo/{NativeDexFunding.ts => DexFundingSource.ts} | 10 +++++----- src/tempo/index.ts | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) rename src/tempo/{NativeDexFunding.test.ts => DexFundingSource.test.ts} (63%) rename src/tempo/{NativeDexFunding.ts => DexFundingSource.ts} (86%) diff --git a/package.json b/package.json index f43797df8..7b74a98bf 100644 --- a/package.json +++ b/package.json @@ -776,6 +776,11 @@ "types": "./dist/tempo/Channel.d.ts", "default": "./dist/tempo/Channel.js" }, + "./tempo/DexFundingSource": { + "src": "./src/tempo/DexFundingSource.ts", + "types": "./dist/tempo/DexFundingSource.d.ts", + "default": "./dist/tempo/DexFundingSource.js" + }, "./tempo/EarnShares": { "src": "./src/tempo/EarnShares.ts", "types": "./dist/tempo/EarnShares.d.ts", @@ -811,11 +816,6 @@ "types": "./dist/tempo/MultisigSimulation.d.ts", "default": "./dist/tempo/MultisigSimulation.js" }, - "./tempo/NativeDexFunding": { - "src": "./src/tempo/NativeDexFunding.ts", - "types": "./dist/tempo/NativeDexFunding.d.ts", - "default": "./dist/tempo/NativeDexFunding.js" - }, "./tempo/Period": { "src": "./src/tempo/Period.ts", "types": "./dist/tempo/Period.d.ts", 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 40212f41a..c5a96c49e 100644 --- a/src/tempo/NativeDexFunding.test.ts +++ b/src/tempo/DexFundingSource.test.ts @@ -1,15 +1,15 @@ import { describe, expect, test } from 'vp/test' -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 86% rename from src/tempo/NativeDexFunding.ts rename to src/tempo/DexFundingSource.ts index 525e23fd6..005ee78e5 100644 --- a/src/tempo/NativeDexFunding.ts +++ b/src/tempo/DexFundingSource.ts @@ -20,11 +20,11 @@ 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({ + * DexFundingSource.encode({ * tokenIn: token, * maxAmountIn: 30_000_000n * }) @@ -42,12 +42,12 @@ 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 }) * ) * ``` */ diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 6fb918636..6e1e0b54c 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -653,4 +653,4 @@ export * as FundingPolicy from './FundingPolicy.js' * * @category Reference */ -export * as NativeDexFunding from './NativeDexFunding.js' +export * as DexFundingSource from './DexFundingSource.js' From 913cd5c995445ae6903eda18674a19b711d36b0d 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 09/28] feat: consolidate tempo funding source API --- package.json | 10 ++-- src/tempo/DexFundingSource.test.ts | 16 ------ src/tempo/DexFundingSource.ts | 57 -------------------- 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 | 4 +- 8 files changed, 131 insertions(+), 92 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/package.json b/package.json index 7b74a98bf..7617964b3 100644 --- a/package.json +++ b/package.json @@ -776,11 +776,6 @@ "types": "./dist/tempo/Channel.d.ts", "default": "./dist/tempo/Channel.js" }, - "./tempo/DexFundingSource": { - "src": "./src/tempo/DexFundingSource.ts", - "types": "./dist/tempo/DexFundingSource.d.ts", - "default": "./dist/tempo/DexFundingSource.js" - }, "./tempo/EarnShares": { "src": "./src/tempo/EarnShares.ts", "types": "./dist/tempo/EarnShares.d.ts", @@ -796,6 +791,11 @@ "types": "./dist/tempo/FundingRequirement.d.ts", "default": "./dist/tempo/FundingRequirement.js" }, + "./tempo/FundingSource": { + "src": "./src/tempo/FundingSource.ts", + "types": "./dist/tempo/FundingSource.d.ts", + "default": "./dist/tempo/FundingSource.js" + }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/DexFundingSource.test.ts b/src/tempo/DexFundingSource.test.ts deleted file mode 100644 index c5a96c49e..000000000 --- a/src/tempo/DexFundingSource.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from 'vp/test' -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 005ee78e5..000000000 --- a/src/tempo/DexFundingSource.ts +++ /dev/null @@ -1,57 +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 00bdfffda..2b28dc36a 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..38c8129dd --- /dev/null +++ b/src/tempo/FundingSource.test-d.ts @@ -0,0 +1,12 @@ +import { expectTypeOf, test } from 'vp/test' +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..e5d5ffd5a --- /dev/null +++ b/src/tempo/FundingSource.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vp/test' +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 6e1e0b54c..70371872c 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -649,8 +649,8 @@ export * as FundingRequirement from './FundingRequirement.js' */ export * as FundingPolicy from './FundingPolicy.js' /** - * Native DEX funding payload codecs. + * Funding source shapes and native DEX payload codecs. * * @category Reference */ -export * as DexFundingSource from './DexFundingSource.js' +export * as FundingSource from './FundingSource.js' From 049a963b7a29a34ed91c4df0342eee320be5492e 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 10/28] refactor: split tempo dex funding source --- package.json | 5 ++ 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 +- 7 files changed, 121 insertions(+), 104 deletions(-) delete mode 100644 src/tempo/FundingSource.test.ts rename src/tempo/{FundingSource.test-d.ts => FundingSourceDex.test-d.ts} (60%) create mode 100644 src/tempo/FundingSourceDex.test.ts create mode 100644 src/tempo/FundingSourceDex.ts diff --git a/package.json b/package.json index 7617964b3..d1cc350d3 100644 --- a/package.json +++ b/package.json @@ -796,6 +796,11 @@ "types": "./dist/tempo/FundingSource.d.ts", "default": "./dist/tempo/FundingSource.js" }, + "./tempo/FundingSourceDex": { + "src": "./src/tempo/FundingSourceDex.ts", + "types": "./dist/tempo/FundingSourceDex.d.ts", + "default": "./dist/tempo/FundingSourceDex.js" + }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/FundingSource.test.ts b/src/tempo/FundingSource.test.ts deleted file mode 100644 index e5d5ffd5a..000000000 --- a/src/tempo/FundingSource.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from 'vp/test' -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 60% rename from src/tempo/FundingSource.test-d.ts rename to src/tempo/FundingSourceDex.test-d.ts index 38c8129dd..63ae7574b 100644 --- a/src/tempo/FundingSource.test-d.ts +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -1,8 +1,9 @@ import { expectTypeOf, test } from 'vp/test' -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..89ee19f0d --- /dev/null +++ b/src/tempo/FundingSourceDex.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vp/test' +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 70371872c..42aa4e087 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -649,8 +649,14 @@ export * as FundingRequirement from './FundingRequirement.js' */ export * as FundingPolicy from './FundingPolicy.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' From 6ca8bd2a0b0bd8f4ac7bc6afe8975ee2bc14b1f0 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 11/28] 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 ae6f28a77d2a4d16ec3027905d09a211565c9768 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 12/28] 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 89ee19f0d..45badeb17 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 7cfe239ae38ddc10f28dce76494ace72e04deb2d 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 13/28] feat: expose native dex source on FundingSource --- package.json | 5 -- ...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 +- 7 files changed, 101 insertions(+), 121 deletions(-) rename src/tempo/{FundingSourceDex.test-d.ts => FundingSource.test-d.ts} (60%) 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/package.json b/package.json index d1cc350d3..7617964b3 100644 --- a/package.json +++ b/package.json @@ -796,11 +796,6 @@ "types": "./dist/tempo/FundingSource.d.ts", "default": "./dist/tempo/FundingSource.js" }, - "./tempo/FundingSourceDex": { - "src": "./src/tempo/FundingSourceDex.ts", - "types": "./dist/tempo/FundingSourceDex.d.ts", - "default": "./dist/tempo/FundingSourceDex.js" - }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/FundingSourceDex.test-d.ts b/src/tempo/FundingSource.test-d.ts similarity index 60% rename from src/tempo/FundingSourceDex.test-d.ts rename to src/tempo/FundingSource.test-d.ts index 63ae7574b..38c8129dd 100644 --- a/src/tempo/FundingSourceDex.test-d.ts +++ b/src/tempo/FundingSource.test-d.ts @@ -1,9 +1,8 @@ import { expectTypeOf, test } from 'vp/test' -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..40e57f2c2 --- /dev/null +++ b/src/tempo/FundingSource.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vp/test' +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 45badeb17..000000000 --- a/src/tempo/FundingSourceDex.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'vp/test' -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 42aa4e087..4d45790d6 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -649,14 +649,8 @@ export * as FundingRequirement from './FundingRequirement.js' */ export * as FundingPolicy from './FundingPolicy.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' From 972e0dd453cdf557642df4ca3589ace75a5d1a41 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:15:27 +1000 Subject: [PATCH 14/28] 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 +- src/zod/tempo/FundingPolicy.ts | 31 +++++++- .../tempo/_test/FundingRequirement.test.ts | 5 +- 7 files changed, 143 insertions(+), 32 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 2a5a5b796..a11a75836 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 e9dc9db61..bfc5f6be0 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 50f109798..704bc1db0 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 f2f07e7b6..694dae993 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -110,7 +110,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. */ diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index 0e4e9b8cd..3ed999b76 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -10,9 +10,7 @@ export const Rules = z maxSlippageBps: z.number(), sources: z.record( z_Address.Address, - z.readonly( - z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), - ), + z.readonly(z.array(z.object({ to: z_Address.Address, data: z_Hex.Hex }))), ), }) .check( @@ -46,6 +44,31 @@ export const Authorization = z.union([ Inline, ]) /** RPC policy authorization schema. */ -export const Rpc = z.union([z_Hex.Hex, Inline]) +export const Rpc = z.union([ + z_Hex.Hex, + z + .object({ + admins: z.readonly(z.array(z_Address.Address)), + rules: z.object({ + maxSlippageBps: z.number(), + sources: z.record( + z_Address.Address, + z.readonly( + z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), + ), + ), + }), + }) + .check( + z.refine((value) => { + try { + core_FundingPolicy.fromRpc(value) + return true + } catch { + return false + } + }, 'Invalid inline policy'), + ), +]) /** Encode-only policy authorization schema. */ export const AuthorizationToRpc = z.union([uintBigintNumberish(), Inline]) diff --git a/src/zod/tempo/_test/FundingRequirement.test.ts b/src/zod/tempo/_test/FundingRequirement.test.ts index fc1bb947f..203feea88 100644 --- a/src/zod/tempo/_test/FundingRequirement.test.ts +++ b/src/zod/tempo/_test/FundingRequirement.test.ts @@ -57,10 +57,7 @@ describe('behavior', () => { rules: { maxSlippageBps: 100, sources: { - [token]: requirement.sources.map(({ to, data }) => ({ - target: to, - data, - })), + [token]: requirement.sources, }, }, }, From d69316057324b843e2e55af453c540f7254a90e7 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:43:13 +1000 Subject: [PATCH 15/28] 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 bfc5f6be0..73373041f 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 38c8129dd..4424041d1 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 40e57f2c2..76b4be81b 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 57a47648731f1b8e562a6d2f72a50ccd5e880bd2 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 16/28] refactor(tempo): restore dex funding source module --- package.json | 5 ++ 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 | 7 +++ 8 files changed, 192 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/package.json b/package.json index 7617964b3..d1cc350d3 100644 --- a/package.json +++ b/package.json @@ -796,6 +796,11 @@ "types": "./dist/tempo/FundingSource.d.ts", "default": "./dist/tempo/FundingSource.js" }, + "./tempo/FundingSourceDex": { + "src": "./src/tempo/FundingSourceDex.ts", + "types": "./dist/tempo/FundingSourceDex.d.ts", + "default": "./dist/tempo/FundingSourceDex.js" + }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts index 4424041d1..38c8129dd 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 76b4be81b..4e3f50990 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..39940bed6 --- /dev/null +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -0,0 +1,29 @@ +import { expectTypeOf, test } from 'vp/test' +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..daa264e05 --- /dev/null +++ b/src/tempo/FundingSourceDex.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'vp/test' +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 4d45790d6..112315f82 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -654,3 +654,10 @@ export * as FundingPolicy from './FundingPolicy.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' From 1c12f194225b8b06883943c6ac3ee5b0b57416a8 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:44:52 +1000 Subject: [PATCH 17/28] feat: add earn funding source codecs --- .changeset/tempo-funding.md | 2 +- package.json | 5 ++ src/tempo/FundingSourceEarn.test-d.ts | 26 +++++++++ src/tempo/FundingSourceEarn.test.ts | 73 +++++++++++++++++++++++++ src/tempo/FundingSourceEarn.ts | 79 +++++++++++++++++++++++++++ src/tempo/index.ts | 7 +++ 6 files changed, 191 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/package.json b/package.json index d1cc350d3..a39fe151f 100644 --- a/package.json +++ b/package.json @@ -801,6 +801,11 @@ "types": "./dist/tempo/FundingSourceDex.d.ts", "default": "./dist/tempo/FundingSourceDex.js" }, + "./tempo/FundingSourceEarn": { + "src": "./src/tempo/FundingSourceEarn.ts", + "types": "./dist/tempo/FundingSourceEarn.d.ts", + "default": "./dist/tempo/FundingSourceEarn.js" + }, "./tempo/KeyAuthorization": { "src": "./src/tempo/KeyAuthorization.ts", "types": "./dist/tempo/KeyAuthorization.d.ts", diff --git a/src/tempo/FundingSourceEarn.test-d.ts b/src/tempo/FundingSourceEarn.test-d.ts new file mode 100644 index 000000000..5c9c8a7e2 --- /dev/null +++ b/src/tempo/FundingSourceEarn.test-d.ts @@ -0,0 +1,26 @@ +import { expectTypeOf, test } from 'vp/test' +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..aacfb8c70 --- /dev/null +++ b/src/tempo/FundingSourceEarn.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vp/test' +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 112315f82..896989e39 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -661,3 +661,10 @@ 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' From 07c47b2f6fb3625c5d3937daa8a713c2d501fc62 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:49:08 +1000 Subject: [PATCH 18/28] 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 | 21 +++++++++++++++++---- src/tempo/FundingSourceEarn.ts | 22 +++++++++++++++++++--- 7 files changed, 90 insertions(+), 17 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 39940bed6..e348324de 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 daa264e05..20c2f8bd0 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 5c9c8a7e2..e99af532a 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 aacfb8c70..5d33e707a 100644 --- a/src/tempo/FundingSourceEarn.test.ts +++ b/src/tempo/FundingSourceEarn.test.ts @@ -35,10 +35,13 @@ 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}`)) - .toMatchInlineSnapshot(` + expect( + FundingSourceEarn.decodeExecutionData( + `0x${addressWord}${thirty}${fifty}`, + ), + ).toMatchInlineSnapshot(` { "maxAmountIn": 30n, "maxValueIn": 50n, @@ -48,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 ff994c01d533473184081183179d9f821ada71a0 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:53:44 +1000 Subject: [PATCH 19/28] 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 38c8129dd..8c8abc406 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 4e3f50990..738fab6ed 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 e99af532a..95a26e2a3 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 5d33e707a..afe55a868 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 d03dd390eb9610f3f4fd044a30698788604f7fb8 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:44:18 +1000 Subject: [PATCH 20/28] 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 | 68 ++++++++++++++++++++++++---- src/tempo/TxEnvelopeTempo.test.ts | 14 ++++++ src/tempo/TxEnvelopeTempo.ts | 7 +++ 8 files changed, 184 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 73373041f..3ed2a2deb 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 704bc1db0..cfa01d975 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 2b28dc36a..2bf70c7f3 100644 --- a/src/tempo/FundingRequirement.ts +++ b/src/tempo/FundingRequirement.ts @@ -57,6 +57,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 e29b18c33..092556611 100644 --- a/src/tempo/KeyAuthorization.test.ts +++ b/src/tempo/KeyAuthorization.test.ts @@ -2739,3 +2739,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 694dae993..32a70e63e 100644 --- a/src/tempo/KeyAuthorization.ts +++ b/src/tempo/KeyAuthorization.ts @@ -99,6 +99,16 @@ export type SignatureRpc = /** Input type for a Key Authorization. */ export type Input = KeyAuthorization +/** 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 = { /** Optional account address binding (TIP-1049). */ @@ -551,14 +561,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 @@ -590,7 +620,6 @@ export function fromRpc(authorization: Rpc): Signed { : {}), })), ...(scopes ? { scopes } : {}), - signature, type: keyType, ...(witness !== undefined ? { witness } : {}), ...(authorization.fundingPolicy !== undefined @@ -986,6 +1015,34 @@ export declare namespace serialize { * @returns An RPC-formatted Key Authorization. */ export function toRpc(authorization: toRpc.Input): 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: Omit< + KeyAuthorization, + 'signature' + >, +): UnsignedRpc { assertAccountBinding(authorization.type, authorization.account) const { address, @@ -994,12 +1051,10 @@ export function toRpc(authorization: toRpc.Input): 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 @@ -1038,9 +1093,6 @@ export function toRpc(authorization: toRpc.Input): Rpc { limit: Quantity.fromNumberish(limit), ...(period ? { period: Quantity.fromNumberish(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 260afc55c..fc1185960 100644 --- a/src/tempo/TxEnvelopeTempo.test.ts +++ b/src/tempo/TxEnvelopeTempo.test.ts @@ -1666,3 +1666,17 @@ describe('toTransactionRequest', () => { expect(request.feePayerSignature).toEqual(feePayerSignature) }) }) + +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 899cd3425..c2d0ce3fa 100644 --- a/src/tempo/TxEnvelopeTempo.ts +++ b/src/tempo/TxEnvelopeTempo.ts @@ -185,6 +185,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 480b38b3f2aafe3ddbd8cf274478b370f602bac9 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:27:47 +1000 Subject: [PATCH 21/28] 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 ++++------ src/zod/tempo/FundingPolicy.ts | 2 +- src/zod/tempo/FundingRequirement.ts | 2 +- 7 files changed, 21 insertions(+), 34 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 a11a75836..3ac0d0a19 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 3ed2a2deb..7c33ca934 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 cfa01d975..4d033375d 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 2bf70c7f3..ba12d095a 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. */ @@ -182,7 +180,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]!)) } : {}), @@ -213,7 +211,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) @@ -249,7 +247,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) }), diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index 3ed999b76..5a1d76055 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -54,7 +54,7 @@ export const Rpc = z.union([ sources: z.record( z_Address.Address, z.readonly( - z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), + z.array(z.object({ to: z_Address.Address, data: z_Hex.Hex })), ), ), }), diff --git a/src/zod/tempo/FundingRequirement.ts b/src/zod/tempo/FundingRequirement.ts index 3070a7e09..a2fc2bff8 100644 --- a/src/zod/tempo/FundingRequirement.ts +++ b/src/zod/tempo/FundingRequirement.ts @@ -17,7 +17,7 @@ export const Rpc = z.object({ policyRules: z.optional(z_Hex.Hex), slippageBps: z.optional(z_Hex.Hex), sources: z.readonly( - z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), + z.array(z.object({ to: z_Address.Address, data: z_Hex.Hex })), ), }) From aa34265b8965d977c4dd67a52eef7df26550e0de Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:32:14 +1000 Subject: [PATCH 22/28] 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 ++++++---- src/zod/tempo/FundingPolicy.ts | 2 +- src/zod/tempo/FundingRequirement.ts | 2 +- 7 files changed, 34 insertions(+), 21 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 3ac0d0a19..a11a75836 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 7c33ca934..3ed2a2deb 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 4d033375d..cfa01d975 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 ba12d095a..2bf70c7f3 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. */ @@ -180,7 +182,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]!)) } : {}), @@ -211,7 +213,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) @@ -247,7 +249,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) }), diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index 5a1d76055..3ed999b76 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -54,7 +54,7 @@ export const Rpc = z.union([ sources: z.record( z_Address.Address, z.readonly( - z.array(z.object({ to: z_Address.Address, data: z_Hex.Hex })), + z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), ), ), }), diff --git a/src/zod/tempo/FundingRequirement.ts b/src/zod/tempo/FundingRequirement.ts index a2fc2bff8..3070a7e09 100644 --- a/src/zod/tempo/FundingRequirement.ts +++ b/src/zod/tempo/FundingRequirement.ts @@ -17,7 +17,7 @@ export const Rpc = z.object({ policyRules: z.optional(z_Hex.Hex), slippageBps: z.optional(z_Hex.Hex), sources: z.readonly( - z.array(z.object({ to: z_Address.Address, data: z_Hex.Hex })), + z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), ), }) From 9e96afb36e025939a39bdc4448b31d172dd62a13 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:36:49 +1000 Subject: [PATCH 23/28] 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 +- src/zod/tempo/FundingPolicy.ts | 2 +- src/zod/tempo/FundingRequirement.ts | 2 +- src/zod/tempo/_test/FundingRequirement.test.ts | 4 ++-- 18 files changed, 42 insertions(+), 44 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 a11a75836..82283fb51 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 3ed2a2deb..3c13c2b94 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 8496906e7..58194ac19 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 cfa01d975..eea030b62 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 2bf70c7f3..64c6277b3 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. */ @@ -81,7 +79,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 @@ -132,7 +130,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 ? [] : [ @@ -183,7 +181,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 } : {}), @@ -213,7 +211,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) @@ -249,7 +247,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 8c8abc406..87de0ed99 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 738fab6ed..be093f595 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 e348324de..68c431e5e 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 20c2f8bd0..28c976dd3 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 95a26e2a3..637b1186c 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 afe55a868..82da79447 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 } diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index 3ed999b76..d691711f4 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -10,7 +10,7 @@ export const Rules = z maxSlippageBps: z.number(), sources: z.record( z_Address.Address, - z.readonly(z.array(z.object({ to: z_Address.Address, data: z_Hex.Hex }))), + z.readonly(z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex }))), ), }) .check( diff --git a/src/zod/tempo/FundingRequirement.ts b/src/zod/tempo/FundingRequirement.ts index 3070a7e09..c61bc0f1e 100644 --- a/src/zod/tempo/FundingRequirement.ts +++ b/src/zod/tempo/FundingRequirement.ts @@ -8,7 +8,7 @@ import { } from '../internal/Integer.js' /** Concrete funding source schema. */ -export const Source = z.object({ to: z_Address.Address, data: z_Hex.Hex }) +export const Source = z.object({ target: z_Address.Address, data: z_Hex.Hex }) /** RPC funding requirement schema. */ export const Rpc = z.object({ diff --git a/src/zod/tempo/_test/FundingRequirement.test.ts b/src/zod/tempo/_test/FundingRequirement.test.ts index 203feea88..e1c09b0aa 100644 --- a/src/zod/tempo/_test/FundingRequirement.test.ts +++ b/src/zod/tempo/_test/FundingRequirement.test.ts @@ -11,7 +11,7 @@ const requirement = { token, amount: 50n, slippageBps: 0, - sources: [{ to: token, data: '0xab' as const }], + sources: [{ target: token, data: '0xab' as const }], } describe('behavior', () => { @@ -35,7 +35,7 @@ describe('behavior', () => { const envelope = { type: 'tempo' as const, chainId: 1, - calls: [{ to: token }], + calls: [{ target: token }], requireFunds: [requirement], } expect( From a1001d37f4c6cd4ddd734822eaf82e68653bd4dd Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:37:47 +1000 Subject: [PATCH 24/28] style(tempo): format funding sources --- src/tempo/FundingPolicy.test.ts | 5 +---- src/zod/tempo/FundingPolicy.ts | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index 82283fb51..dd004a720 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( diff --git a/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index d691711f4..0a30037da 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -10,7 +10,9 @@ export const Rules = z maxSlippageBps: z.number(), sources: z.record( z_Address.Address, - z.readonly(z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex }))), + z.readonly( + z.array(z.object({ target: z_Address.Address, data: z_Hex.Hex })), + ), ), }) .check( From b639383b8044052f124eaef883f56643dea6682a Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:42:19 +1000 Subject: [PATCH 25/28] feat(tempo): support unsigned funding intent --- .changeset/tempo-funding.md | 8 +- src/tempo/FundingRequirement.test.ts | 84 ++++++++++- src/tempo/FundingRequirement.ts | 131 ++++++++++++++---- src/tempo/TransactionRequest.test-d.ts | 22 +++ src/tempo/TransactionRequest.ts | 31 +++-- src/zod/tempo/TransactionRequest.ts | 35 +++-- .../tempo/_test/FundingRequirement.test.ts | 14 ++ 7 files changed, 280 insertions(+), 45 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 eea030b62..de935aa93 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,83 @@ 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() + }) +}) + +describe('toEnvelope', () => { + test('requires resolved funding', () => { + for (const requireFunds of [true, [{}], [{ token, amount: 0n }]] as const) + expect(() => + TransactionRequest.toEnvelope({ ...envelope, requireFunds }), + ).toThrow('resolved before signing') + expect( + TransactionRequest.toEnvelope({ + ...envelope, + requireFunds: [requirement], + }).requireFunds, + ).toEqual([requirement]) + }) +}) diff --git a/src/tempo/FundingRequirement.ts b/src/tempo/FundingRequirement.ts index 64c6277b3..f1d6d7614 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 [ @@ -54,38 +64,16 @@ export type Tuple = readonly [ * }) * ``` */ -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) } /** @@ -254,6 +242,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 0d2b34219..d0e71ff43 100644 --- a/src/tempo/TransactionRequest.test-d.ts +++ b/src/tempo/TransactionRequest.test-d.ts @@ -127,3 +127,25 @@ describe('round-trip: Request → Envelope → Request', () => { ).toEqualTypeOf() }) }) + +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 57cdccd50..d9ffc1c30 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -41,9 +41,10 @@ export type TransactionRequest< feePayer?: boolean | undefined feePayerSignature?: Signature.Signature | null | undefined feeToken?: Address.Address | 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 keyAuthorization?: KeyAuthorization.KeyAuthorization | undefined keyAuthorizationSimulation?: MultisigSimulation.Spec | undefined @@ -70,7 +71,7 @@ export type Rpc = Omit< | 'keyAuthorizationSimulation' | 'signature' > & { - requireFunds?: readonly FundingRequirement.Rpc[] | undefined + requireFunds?: true | readonly FundingRequirement.IntentRpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feePayerSignature?: Signature.Rpc | null | undefined feeToken?: Hex.Hex | undefined @@ -141,7 +142,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, @@ -283,9 +287,10 @@ export function toRpc(request: toRpc.Input): Rpc { ) request_rpc.feeToken = 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, @@ -373,6 +378,16 @@ export function toEnvelope( request: TransactionRequest, options: toEnvelope.Options = {}, ): TxEnvelopeTempo.TxEnvelopeTempo { + const requireFunds = (() => { + if (request.requireFunds === true) + throw new FundingRequirement.InvalidRequirementError( + 'Funding requirements must be resolved before signing.', + ) + return request.requireFunds?.map((requirement) => { + FundingRequirement.assert(requirement) + return requirement + }) + })() const calls = (() => { if (request.calls) return request.calls const to = @@ -418,7 +433,7 @@ export function toEnvelope( : {}), ...(typeof request.from !== 'undefined' ? { from: request.from } : {}), ...(typeof request.gas !== 'undefined' ? { gas: request.gas } : {}), - ...(request.requireFunds ? { requireFunds: request.requireFunds } : {}), + ...(requireFunds ? { requireFunds } : {}), ...(typeof request.keyAuthorization !== 'undefined' ? { keyAuthorization: request.keyAuthorization } : {}), diff --git a/src/zod/tempo/TransactionRequest.ts b/src/zod/tempo/TransactionRequest.ts index 33fc54f51..8965fe10b 100644 --- a/src/zod/tempo/TransactionRequest.ts +++ b/src/zod/tempo/TransactionRequest.ts @@ -79,7 +79,12 @@ export const Rpc = z.object({ gas: z.optional(z_Hex.Hex), gasPrice: z.optional(z_Hex.Hex), input: z.optional(z_Hex.Hex), - requireFunds: z.optional(z.readonly(z.array(z_FundingRequirement.Rpc))), + requireFunds: z.optional( + z.union([ + z.literal(true), + z.readonly(z.array(z.partial(z_FundingRequirement.Rpc))), + ]), + ), keyAuthorization: z.optional(z_KeyAuthorization.Rpc), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -134,7 +139,14 @@ export const Domain = z.object({ gas: z.optional(z.bigint()), gasPrice: z.optional(z.bigint()), input: z.optional(z_Hex.Hex), - requireFunds: z.optional(z.readonly(z.array(z_FundingRequirement.Domain))), + requireFunds: z.optional( + z.union([ + z.literal(true), + z.readonly( + z.array(z.partial(z.object(z_FundingRequirement.Domain.shape))), + ), + ]), + ), keyAuthorization: z.optional(z_KeyAuthorization.Domain), keyData: z.optional(z_Hex.Hex), keyId: z.optional(z_Address.Address), @@ -176,7 +188,10 @@ export const DomainToRpc = z.object({ gasPrice: z.optional(uintBigintNumberish()), input: z.optional(z_Hex.Hex), requireFunds: z.optional( - z.readonly(z.array(z_FundingRequirement.DomainToRpc)), + z.union([ + z.literal(true), + z.readonly(z.array(z.partial(z_FundingRequirement.DomainToRpc))), + ]), ), keyAuthorization: z.optional(z_KeyAuthorization.DomainToRpc), keyData: z.optional(z_Hex.Hex), @@ -250,9 +265,10 @@ function fromRpc( if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken if (request.requireFunds) - request_.requireFunds = request.requireFunds.map( - core_FundingRequirement.fromRpc, - ) + request_.requireFunds = + request.requireFunds === true + ? true + : request.requireFunds.map(core_FundingRequirement.fromRpcIntent) if (request.keyAuthorization) request_.keyAuthorization = z.decode( z_KeyAuthorization.KeyAuthorization, @@ -346,9 +362,10 @@ function toRpc( ) request_rpc.feeToken = request.feeToken if (request.requireFunds) - request_rpc.requireFunds = request.requireFunds.map( - core_FundingRequirement.toRpc, - ) + request_rpc.requireFunds = + request.requireFunds === true + ? true + : request.requireFunds.map(core_FundingRequirement.toRpcIntent) if (request.keyAuthorization) request_rpc.keyAuthorization = z.encode( z_KeyAuthorization.KeyAuthorizationToRpc, diff --git a/src/zod/tempo/_test/FundingRequirement.test.ts b/src/zod/tempo/_test/FundingRequirement.test.ts index e1c09b0aa..3d9159cfb 100644 --- a/src/zod/tempo/_test/FundingRequirement.test.ts +++ b/src/zod/tempo/_test/FundingRequirement.test.ts @@ -84,3 +84,17 @@ describe('behavior', () => { expect(z.safeParse(FundingPolicy.Authorization, 0n).success).toBe(false) }) }) + +describe('unsigned intent', () => { + test.each([true, [], [{ sources: [] }, { amount: 0n }]] as const)( + 'round-trips partial request fields (%s)', + (requireFunds) => { + const rpc = z.encode(TransactionRequest.TransactionRequest, { + requireFunds, + }) + expect( + z.decode(TransactionRequest.TransactionRequest, rpc).requireFunds, + ).toEqual(requireFunds) + }, + ) +}) From c3b62421d0f8424c7626d46c50660937c10d17e9 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:51:20 +1000 Subject: [PATCH 26/28] refactor(tempo): rename funding request types --- src/tempo/FundingRequirement.test.ts | 6 +++--- src/tempo/FundingRequirement.ts | 20 ++++++++++---------- src/tempo/TransactionRequest.ts | 8 ++++---- src/zod/tempo/TransactionRequest.ts | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/tempo/FundingRequirement.test.ts b/src/tempo/FundingRequirement.test.ts index de935aa93..f6751e542 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 f1d6d7614..753d15c4b 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. */ @@ -64,7 +64,7 @@ export type Tuple = readonly [ * }) * ``` */ -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.', @@ -249,10 +249,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, @@ -273,12 +273,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), @@ -298,7 +298,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 d9ffc1c30..ed6502721 100644 --- a/src/tempo/TransactionRequest.ts +++ b/src/tempo/TransactionRequest.ts @@ -44,7 +44,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 keyAuthorization?: KeyAuthorization.KeyAuthorization | undefined keyAuthorizationSimulation?: MultisigSimulation.Spec | undefined @@ -71,7 +71,7 @@ export type Rpc = Omit< | 'keyAuthorizationSimulation' | 'signature' > & { - requireFunds?: true | readonly FundingRequirement.IntentRpc[] | undefined + requireFunds?: true | readonly FundingRequirement.RequestRpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feePayerSignature?: Signature.Rpc | null | undefined feeToken?: Hex.Hex | undefined @@ -145,7 +145,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, @@ -290,7 +290,7 @@ export function toRpc(request: toRpc.Input): 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, diff --git a/src/zod/tempo/TransactionRequest.ts b/src/zod/tempo/TransactionRequest.ts index 8965fe10b..cc485aa18 100644 --- a/src/zod/tempo/TransactionRequest.ts +++ b/src/zod/tempo/TransactionRequest.ts @@ -268,7 +268,7 @@ function fromRpc( request_.requireFunds = request.requireFunds === true ? true - : request.requireFunds.map(core_FundingRequirement.fromRpcIntent) + : request.requireFunds.map(core_FundingRequirement.fromRpcRequest) if (request.keyAuthorization) request_.keyAuthorization = z.decode( z_KeyAuthorization.KeyAuthorization, @@ -365,7 +365,7 @@ function toRpc( request_rpc.requireFunds = request.requireFunds === true ? true - : request.requireFunds.map(core_FundingRequirement.toRpcIntent) + : request.requireFunds.map(core_FundingRequirement.toRpcRequest) if (request.keyAuthorization) request_rpc.keyAuthorization = z.encode( z_KeyAuthorization.KeyAuthorizationToRpc, From f5cb157a26d622c5367c6a3452c30c6c9a4e7b6d Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:22:24 +1000 Subject: [PATCH 27/28] feat(tempo): support optional funding source ordering --- .changeset/tempo-funding.md | 2 +- .github/workflows/verify.yml | 7 +- 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 ++++++++++++++++- src/zod/tempo/FundingPolicy.ts | 2 + .../tempo/_test/FundingRequirement.test.ts | 30 ++++++ test/tempo/prool.ts | 6 +- 10 files changed, 264 insertions(+), 19 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 a9f737d87..df1e51551 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -94,12 +94,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. + # TIP-1120 image from tempoxyz/tempo/actions/runs/36383271463. tag: - - edge - # - https://rpc.moderato.tempo.xyz + - sha-6e27f81 steps: - name: Clone repository diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts index dd004a720..8dd9540d5 100644 --- a/src/tempo/FundingPolicy.test.ts +++ b/src/tempo/FundingPolicy.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from 'vp/test' +import * as Rlp from '../core/Rlp.js' +import * as KeyAuthorization from './KeyAuthorization.js' import * as FundingPolicy from './FundingPolicy.js' const token = '0x0101010101010101010101010101010101010101' 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 3c13c2b94..78ce7ca5c 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 58194ac19..58b2c26d5 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 f6751e542..a20caa6cc 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 0e9d3775f..830f42953 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 'vp/test' import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, + FundingPolicy, + FundingSourceDex, KeyAuthorization, Period, SignatureEnvelope, @@ -869,6 +871,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/src/zod/tempo/FundingPolicy.ts b/src/zod/tempo/FundingPolicy.ts index 0a30037da..94f1f6467 100644 --- a/src/zod/tempo/FundingPolicy.ts +++ b/src/zod/tempo/FundingPolicy.ts @@ -7,6 +7,7 @@ import { uintBigintNumberish } from '../internal/Integer.js' /** Funding permissions keyed by output token. */ export const Rules = z .object({ + enforceOrder: z.optional(z.boolean()), maxSlippageBps: z.number(), sources: z.record( z_Address.Address, @@ -52,6 +53,7 @@ export const Rpc = z.union([ .object({ admins: z.readonly(z.array(z_Address.Address)), rules: z.object({ + enforceOrder: z.optional(z.boolean()), maxSlippageBps: z.number(), sources: z.record( z_Address.Address, diff --git a/src/zod/tempo/_test/FundingRequirement.test.ts b/src/zod/tempo/_test/FundingRequirement.test.ts index 3d9159cfb..0ea1a6634 100644 --- a/src/zod/tempo/_test/FundingRequirement.test.ts +++ b/src/zod/tempo/_test/FundingRequirement.test.ts @@ -98,3 +98,33 @@ describe('unsigned intent', () => { }, ) }) + +describe('funding policy ordering', () => { + test.each([undefined, false, true])( + 'preserves ordering (%s)', + (enforceOrder) => { + const policy = { + admins: [token], + rules: { + enforceOrder, + maxSlippageBps: 100, + sources: { [token]: requirement.sources }, + }, + } + expect(z.parse(FundingPolicy.Inline, policy)).toEqual(policy) + expect(z.parse(FundingPolicy.Rpc, policy)).toEqual(policy) + }, + ) + + test.each([0, 1, 'false', null])( + 'rejects invalid ordering (%s)', + (enforceOrder) => { + const policy = { + admins: [token], + rules: { enforceOrder, maxSlippageBps: 0, sources: {} }, + } + expect(z.safeParse(FundingPolicy.Inline, policy).success).toBe(false) + expect(z.safeParse(FundingPolicy.Rpc, policy).success).toBe(false) + }, + ) +}) 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 1e8e72f682ecc62b0271c2acf5ee8d5a5bd01198 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:32:28 +1000 Subject: [PATCH 28/28] 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 df1e51551..a9f737d87 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -94,9 +94,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 830f42953..0e9d3775f 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 'vp/test' import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, - FundingPolicy, - FundingSourceDex, KeyAuthorization, Period, SignatureEnvelope, @@ -871,98 +869,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)