diff --git a/.changeset/tempo-funding.md b/.changeset/tempo-funding.md new file mode 100644 index 00000000..737ae904 --- /dev/null +++ b/.changeset/tempo-funding.md @@ -0,0 +1,11 @@ +--- +"ox": patch +--- + +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' + +TransactionRequest.toRpc({ requireFunds: [{ sources: [] }] }) +``` diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 6470b548..504a3a2d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -89,7 +89,7 @@ jobs: # `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: + tag: - edge # - https://rpc.moderato.tempo.xyz diff --git a/src/tempo/FundingPolicy.test.ts b/src/tempo/FundingPolicy.test.ts new file mode 100644 index 00000000..27ce1b93 --- /dev/null +++ b/src/tempo/FundingPolicy.test.ts @@ -0,0 +1,204 @@ +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 +const requirement = { + token, + amount: 50n, + sources: [{ target, data: '0xab' as const }], + slippageBps: 100, +} + +const rules = { + enforceOrder: false, + 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() + }) +}) + +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: { + enforceOrder: false, + maxSlippageBps: 100, + sources: { [token]: [{ target, data: '0xab' }] }, + }, + }) + }) +}) + +describe('fromRpc', () => { + test('preserves RPC source targets', () => { + expect( + FundingPolicy.fromRpc({ + admins: [token], + rules: { + enforceOrder: false, + maxSlippageBps: 100, + sources: { [token]: [{ target, data: '0xab' }] }, + }, + }), + ).toEqual({ admins: [token], rules }) + }) +}) + +describe('fromTuple', () => { + test('decodes source targets', () => { + expect( + FundingPolicy.fromTuple([ + [token], + ['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 new file mode 100644 index 00000000..3bcd6ffc --- /dev/null +++ b/src/tempo/FundingPolicy.ts @@ -0,0 +1,431 @@ +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' + +/** An approved funding source and its configuration data. */ +export type Source = { + /** Source-specific configuration passed to funding hooks as `configData`. */ + data: Hex.Hex + /** Funding source address. */ + target: Address.Address +} + +/** + * 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 + /** + * Source permissions for each output token. */ + sources: Readonly> +} + +/** + * Inline policy creation parameters. */ +export type Inline = { + /** + * Accounts authorized to modify the policy. */ + admins: readonly Address.Address[] + /** + * Committed funding permissions. */ + rules: Rules +} + +/** + * Policy authorization: an existing nonzero uint64 ID or inline creation. */ +export type Authorization = bigintType | Inline + +/** Policy authorization in RPC form. */ +export type Rpc = + | Hex.Hex + | { + /** Accounts authorized to modify the policy. */ + 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 + /** Source permissions for each output token. */ + sources: Readonly> + } + } + +/** + * 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 + /** + * Source permissions. */ + sources: readonly { + /** Funding source address in the contract ABI. */ + target: Address.Address + /** Source-specific configuration passed to funding hooks as `configData`. */ + data: Hex.Hex + }[] +} + +/** + * 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])[], + ])[], + '0x' | '0x01', + ], + ] + +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' }, + ], + }, + ], + }, + { name: 'enforceOrder', type: 'bool' }, + ], + }, +] as const + +const domain = Hash.keccak256(Hex.fromString('tempo.funding-policy.rules.v1')) + +/** + * Converts a token map to canonical ABI 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 ( + rules.enforceOrder !== undefined && + typeof rules.enforceOrder !== 'boolean' + ) + throw new InvalidPolicyError('Ordering enforcement must be a boolean.') + 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 address or data.') + } + return { + token, + sources: sources.map(({ target, data }) => ({ target, data })), + } + }) + 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, [ + { + enforceOrder: rules.enforceOrder ?? false, + 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 = { + enforceOrder: value.enforceOrder, + maxSlippageBps: value.maxSlippageBps, + sources: Object.fromEntries( + value.routes.map(({ token, sources }) => [ + token, + sources.map(({ target, data }) => ({ target, data })), + ]), + ), + } + 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' && + (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.') + 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, + ), + value.rules.enforceOrder ? '0x01' : '0x', + ], + ] +} + +/** + * 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 !== 3 + ) + throw new InvalidPolicyError('Invalid inline policy tuple.') + const [admins, [slippage, routes, enforceOrder]] = value + if ( + typeof slippage !== 'string' || + !Hex.validate(slippage, { strict: true }) || + slippage.startsWith('0x00') || + !Array.isArray(routes) || + (enforceOrder !== '0x' && enforceOrder !== '0x01') + ) + 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: { + enforceOrder: enforceOrder === '0x01', + 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: 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 }) => ({ target, data })), + ]), + ), + }, + } + 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): Rpc { + 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, + rules: { + ...value.rules, + sources: Object.fromEntries( + toRoutes(value.rules).map(({ token, sources }) => [token, sources]), + ), + }, + } +} + +/** + * Thrown when a funding policy cannot be canonically encoded. */ +export class InvalidPolicyError extends Errors.BaseError { + override readonly name = 'FundingPolicy.InvalidPolicyError' +} diff --git a/src/tempo/FundingRequirement.test-d.ts b/src/tempo/FundingRequirement.test-d.ts new file mode 100644 index 00000000..4289147b --- /dev/null +++ b/src/tempo/FundingRequirement.test-d.ts @@ -0,0 +1,53 @@ +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< + boolean | undefined + >() + expectTypeOf< + Exclude['rules']['enforceOrder'] + >().toEqualTypeOf() + 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 00000000..e0bb6b01 --- /dev/null +++ b/src/tempo/FundingRequirement.test.ts @@ -0,0 +1,339 @@ +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('toRpc', () => { + test('preserves source targets', () => { + expect(FundingRequirement.toRpc(requirement).sources).toEqual([ + { target, data: '0xab' }, + ]) + }) + + 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('fromRpc', () => { + test('decodes source targets', () => { + expect( + FundingRequirement.fromRpc({ + token, + amount: '0x32', + sources: [{ target, data: '0xab' }], + }), + ).toEqual({ token, amount: 50n, sources: [{ target, data: '0xab' }] }) + }) +}) + +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 = { + enforceOrder: false, + 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 !== true && 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('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.toRpcRequest) + expect(rpc).toEqual([ + {}, + { token }, + { amount: '0x0', sources: [] }, + { sources: [{ target, data: '0xab' }], slippageBps: '0x0' }, + ]) + expect(rpc.map(FundingRequirement.fromRpcRequest)).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.toRpcRequest(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 new file mode 100644 index 00000000..2a983185 --- /dev/null +++ b/src/tempo/FundingRequirement.ts @@ -0,0 +1,319 @@ +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' +import type * as FundingSource from './FundingSource.js' + +/** + * 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 FundingSource.Source[] +} + +/** + * RPC funding requirement. */ +export type Rpc = FundingRequirement + +/** Unsigned funding fields to resolve before signing. */ +export type Request = { + [key in keyof FundingRequirement]?: + | FundingRequirement[key] + | undefined +} + +/** RPC funding intent with optional token, amount, and sources. */ +export type RequestRpc = Request + +/** + * 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: Request): asserts value is FundingRequirement { + if (!Array.isArray(value.sources)) + throw new InvalidRequirementError( + 'Funding sources must be resolved before signing.', + ) + if (value.token === undefined || value.amount === undefined) + throw new InvalidRequirementError( + 'Funding token and amount must be resolved before signing.', + ) + assertFields(value) +} + +/** + * 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, sources, ...rest } = value + const result: FundingRequirement = { + ...rest, + amount: BigInt(amount), + sources: sources.map(({ target, data }) => ({ target, data })), + ...(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, sources, ...rest } = value + return { + ...rest, + amount: Quantity.fromNumberish(amount), + sources: sources.map(({ target, data }) => ({ target, data })), + ...(slippageBps === undefined + ? {} + : { slippageBps: Quantity.fromNumberish(slippageBps) }), + } +} + +/** + * Decodes unsigned funding intent, preserving omitted fields for inference. + * + * @example + * ```ts + * import { FundingRequirement } from 'ox/tempo' + * + * FundingRequirement.fromRpcRequest({ amount: '0x32' }) + * ``` + */ +export function fromRpcRequest(value: RequestRpc): Request { + 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.toRpcRequest({ amount: 50n }) + * ``` + */ +export function toRpcRequest( + value: Request, +): RequestRpc { + 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: 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 }) + 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 { + override readonly name = 'FundingRequirement.InvalidRequirementError' +} diff --git a/src/tempo/FundingSource.test-d.ts b/src/tempo/FundingSource.test-d.ts new file mode 100644 index 00000000..5454196d --- /dev/null +++ b/src/tempo/FundingSource.test-d.ts @@ -0,0 +1,25 @@ +import { expectTypeOf, test } from 'vitest' +import * as FundingSource from './FundingSource.js' + +test('dex', () => { + const source = FundingSource.dex({ + tokenIn: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.target, + ).toEqualTypeOf<'0x1120000000000000000000000000000000000001'>() + expectTypeOf(source).toExtend() +}) + +test('earn', () => { + const source = FundingSource.earn({ + source: '0x0202020202020202020202020202020202020202', + vault: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.target, + ).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 new file mode 100644 index 00000000..028c015a --- /dev/null +++ b/src/tempo/FundingSource.test.ts @@ -0,0 +1,29 @@ +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', + target: '0x1120000000000000000000000000000000000001', + }) + }) +}) + +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', + target: '0x0202020202020202020202020202020202020202', + }) + }) +}) diff --git a/src/tempo/FundingSource.ts b/src/tempo/FundingSource.ts new file mode 100644 index 00000000..417216c0 --- /dev/null +++ b/src/tempo/FundingSource.ts @@ -0,0 +1,45 @@ +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 = { + /** ABI-encoded execution data for funding, or configuration data for policies and discovery. */ + data: Hex.Hex + /** Funding source address. */ + target: Address.Address +} + +/** + * Creates a native DEX source for funding, policy rules, or discovery. + * + * @example + * ```ts + * import { FundingSource } from 'ox/tempo' + * + * FundingSource.dex({ + * maxAmountIn: 30_000_000n, + * tokenIn: '0x20c0000000000000000000000000000000000000' + * }) + * ``` + */ +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/FundingSourceDex.test-d.ts b/src/tempo/FundingSourceDex.test-d.ts new file mode 100644 index 00000000..fc92ea79 --- /dev/null +++ b/src/tempo/FundingSourceDex.test-d.ts @@ -0,0 +1,41 @@ +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.target, + ).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}`>() +}) + +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 new file mode 100644 index 00000000..9c1928c4 --- /dev/null +++ b/src/tempo/FundingSourceDex.test.ts @@ -0,0 +1,66 @@ +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', + target: '0x1120000000000000000000000000000000000001', + }, + ) + }) +}) + +describe('encodeExecutionData', () => { + test('preserves zero and defaults to unlimited input', () => { + expect( + FundingSourceDex.decodeExecutionData( + FundingSourceDex.encodeExecutionData({ tokenIn: token }), + ), + ).toEqual({ tokenIn: token, maxAmountIn: 2n ** 256n - 1n }) + expect( + FundingSourceDex.decodeExecutionData( + 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.decodeConfigData( + FundingSourceDex.encodeConfigData({ tokenIn: token }), + ), + ).toEqual({ maxAmountIn: 2n ** 256n - 1n, tokenIn: token }) + expect( + 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 new file mode 100644 index 00000000..d8f7ccfe --- /dev/null +++ b/src/tempo/FundingSourceDex.ts @@ -0,0 +1,109 @@ +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), + target: 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 data, returning the concrete input cap. + * + * @example + * ```ts + * import { FundingSourceDex } from 'ox/tempo' + * + * FundingSourceDex.decodeExecutionData( + * FundingSourceDex.encodeExecutionData({ + * tokenIn: '0x20c0000000000000000000000000000000000001' + * }) + * ) + * ``` + */ +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 new file mode 100644 index 00000000..ee980971 --- /dev/null +++ b/src/tempo/FundingSourceEarn.test-d.ts @@ -0,0 +1,47 @@ +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('decodeExecutionData', () => { + expectTypeOf(FundingSourceEarn.decodeExecutionData('0x')).toEqualTypeOf< + Required + >() +}) + +test('decodeConfigData', () => { + expectTypeOf(FundingSourceEarn.decodeConfigData('0x')).toEqualTypeOf< + Required + >() +}) + +test('from', () => { + const source = FundingSourceEarn.from({ + source: '0x0202020202020202020202020202020202020202', + vault: '0x0101010101010101010101010101010101010101', + }) + expectTypeOf( + source.target, + ).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 new file mode 100644 index 00000000..3e8ceaf9 --- /dev/null +++ b/src/tempo/FundingSourceEarn.test.ts @@ -0,0 +1,102 @@ +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('decodeExecutionData', () => { + test('decodes an independently encoded contract request', () => { + expect( + FundingSourceEarn.decodeExecutionData( + `0x${addressWord}${thirty}${fifty}`, + ), + ).toMatchInlineSnapshot(` + { + "maxAmountIn": 30n, + "maxValueIn": 50n, + "vault": "0x0101010101010101010101010101010101010101", + } + `) + }) +}) + +describe('decodeConfigData', () => { + test('decodes configuration bytes', () => { + expect( + FundingSourceEarn.decodeConfigData( + '0x000000000000000000000000010101010101010101010101010101010101010100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002', + ), + ).toEqual({ maxAmountIn: 1n, maxValueIn: 2n, vault }) + }) +}) + +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', + target: '0x0202020202020202020202020202020202020202', + }) + }) +}) + +describe('behavior', () => { + test('rejects malformed data', () => { + expect(() => + FundingSourceEarn.decodeExecutionData('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 00000000..91206897 --- /dev/null +++ b/src/tempo/FundingSourceEarn.ts @@ -0,0 +1,129 @@ +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 = { + /** 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', +) + +/** + * 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), + target: 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`. + * + * 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, returning both concrete caps. + * + * @example + * ```ts + * import { FundingSourceEarn } from 'ox/tempo' + * + * FundingSourceEarn.decodeExecutionData(FundingSourceEarn.encodeExecutionData({ + * vault: '0x0101010101010101010101010101010101010101', + * })) + * ``` + */ +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) +} diff --git a/src/tempo/KeyAuthorization.test.ts b/src/tempo/KeyAuthorization.test.ts index 3de1a496..c387b95d 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 4bbd2141..a4057777 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. */ @@ -114,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`). */ @@ -122,6 +135,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.Rpc | undefined /** Whether this authorization provisions an admin access key (TIP-1049). */ isAdmin?: boolean | null | undefined /** Key identifier. */ @@ -201,6 +216,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 +471,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 = { @@ -555,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 @@ -594,11 +641,13 @@ export function fromRpc(authorization: Rpc): Signed { : {}), })), ...(scopes ? { scopes } : {}), - signature, ...(keyType === 'multisig' ? { 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 +728,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 +798,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 } : {}), } @@ -949,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, @@ -957,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 @@ -999,12 +1087,12 @@ 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 } : {}), + ...(authorization.fundingPolicy !== undefined + ? { fundingPolicy: FundingPolicy.toRpc(authorization.fundingPolicy) } + : {}), ...(account !== undefined ? { account } : {}), } } @@ -1127,7 +1215,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 +1236,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/Transaction.ts b/src/tempo/Transaction.ts index e8f417d1..97c8ec3c 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 @@ -102,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 { @@ -220,6 +230,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 +324,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.test-d.ts b/src/tempo/TransactionRequest.test-d.ts index f50f19cb..21576dad 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 9062346e..208ba45a 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,11 @@ export type TransactionRequest< authorizationList?: | AuthorizationTempo.ListSigned | undefined + /** Required balances before application calls. Use true or omit fields to request inference before signing. */ + requireFunds?: + | true + | readonly FundingRequirement.Request[] + | undefined calls?: readonly Call[] | undefined feePayer?: boolean | undefined feeToken?: TokenId.TokenIdOrAddress | undefined @@ -52,11 +58,13 @@ export type TransactionRequest< export type Rpc = Omit< TransactionRequest, | 'authorizationList' + | 'requireFunds' | 'feeToken' | 'keyAuthorization' | 'multisigSimulation' | 'keyAuthorizationSimulation' > & { + requireFunds?: true | readonly FundingRequirement.RequestRpc[] | undefined authorizationList?: AuthorizationTempo.ListRpc | undefined feeToken?: Hex.Hex | undefined keyAuthorization?: KeyAuthorization.Rpc | undefined @@ -118,6 +126,11 @@ export function fromRpc(request: Rpc): TransactionRequest { }) if (typeof request.feeToken !== 'undefined') request_.feeToken = request.feeToken + if (request.requireFunds) + request_.requireFunds = + request.requireFunds === true + ? true + : request.requireFunds.map(FundingRequirement.fromRpcRequest) if (request.keyAuthorization) request_.keyAuthorization = KeyAuthorization.fromRpc( request.keyAuthorization, @@ -224,6 +237,11 @@ 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 === true + ? true + : request.requireFunds.map(FundingRequirement.toRpcRequest) if (request.keyAuthorization) request_rpc.keyAuthorization = KeyAuthorization.toRpc( request.keyAuthorization, @@ -250,6 +268,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.test.ts b/src/tempo/TxEnvelopeTempo.test.ts index 64a18dc2..7f9b8788 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 e9ed16d6..8e0ae186 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,16 @@ 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) + // Calls must not be empty if (!calls || calls.length === 0) throw new CallsEmptyError() @@ -270,23 +285,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 +320,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 +388,8 @@ export function deserialize(serialized: Serialized): Compute { ) } + if (requireFunds) transaction.requireFunds = requireFunds + if (keyAuthorization) transaction.keyAuthorization = KeyAuthorization.fromTuple( keyAuthorization as never, @@ -641,6 +658,7 @@ export function serialize( feeToken, gas, keyAuthorization, + requireFunds, nonce, nonceKey, maxFeePerGas, @@ -722,7 +740,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 b6ec4361..d813555c 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -84,6 +84,37 @@ 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' +/** + * Funding source constructors and native DEX payload codecs. + * + * @category Reference + */ +export * as FundingSource from './FundingSource.js' + +/** + * Native DEX funding source construction and payload encoding. + * + * @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. * @@ -199,7 +230,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 +294,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 +570,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. *