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