import { AbiFunction, Address, type Hex, Solidity } from 'ox' import * as z from 'zod/mini' import * as OpenApi from '../OpenApi.js' /** * Executable source-chain actions returned by funding transfer creation. A * strict discriminated union: transfer reads never include an action. */ const approve = AbiFunction.from('function approve(address spender, uint256 amount)') const approveSelector = AbiFunction.getSelector(approve) /** Zod schemas owned by funding actions. */ export namespace schema { /** One unsigned EVM call, in the exchange quote API's call shape. */ export const Call = OpenApi.component( z .object({ data: z .string() .check( z.regex(/^0x(?:[0-9a-fA-F]{2})*$/), z.describe('ABI-encoded calldata.'), z.meta({ examples: ['0x095ea7b3'] }), ), to: z .string() .check( z.regex(/^0x[0-9a-fA-F]{40}$/), z.describe('Contract the call targets.'), z.meta({ examples: [`0x${'44'.repeat(20)}`] }), ), value: z .string() .check( z.regex(/^0x[0-9a-fA-F]+$/), z.describe('Native value to send, as hex.'), z.meta({ examples: ['0x0'] }), ), }) .check(z.describe('One unsigned EVM call.')), 'FundingActionCall', ) /** Ordered unsigned EVM calls the caller signs and submits on the source chain. */ export const EvmCalls = OpenApi.component( z .object({ calls: z .array(Call) .check( z.minLength(1), z.describe('Calls in submission order; they may require separate transactions.'), ), type: z.literal('evm:calls').check(z.describe('Action kind.')), }) .check(z.describe('Ordered unsigned EVM calls to sign and submit on the source chain.')), 'FundingEvmCallsAction', ) /** Executable source-chain action attached to a creation response. */ export const Action = OpenApi.component( z .discriminatedUnion('type', [EvmCalls]) .check(z.describe('Executable source-chain action. Never returned by transfer reads.')), 'FundingAction', ) } /** One unsigned EVM call (see {@link schema.Call}). */ export type Call = z.output /** Ordered unsigned EVM calls (see {@link schema.EvmCalls}). */ export type EvmCalls = z.output /** Executable source-chain action (see {@link schema.Action}). */ export type Action = z.output /** One exact native-value allowance established by provider-specific validation. */ export type NativeValue = { /** Contract that may receive the native value. */ to: string /** Exact native value allowed, as a JSON-RPC quantity. */ value: string } /** * Validates prepared EVM calls before persistence. Token calls, approval * targets, spenders, amounts, selectors, and native value must match the route. */ export function validateEvmCalls(options: validateEvmCalls.Options): void { const action = schema.EvmCalls.safeParse(options.action) if (!action.success) throw new InvalidActionError('Malformed EVM calls.') const nativeValues = new Set() for (const { to, value } of options.nativeValues ?? []) { if (!Address.validate(to, { strict: false }) || !/^0x[0-9a-fA-F]+$/.test(value)) throw new InvalidActionError('Malformed native value allowance.') nativeValues.add(`${to.toLowerCase()}:${BigInt(value).toString()}`) } for (const [index, call] of action.data.calls.entries()) { const callValue = BigInt(call.value) if (callValue > 0n && !nativeValues.has(`${call.to.toLowerCase()}:${callValue.toString()}`)) throw new InvalidActionError('Native value must match a validated provider fee.') const targetsSourceToken = Address.isEqual( call.to as Address.Address, options.sourceTokenAddress as Address.Address, ) const selector = call.data.slice(0, approveSelector.length).toLowerCase() if (targetsSourceToken && selector !== approveSelector) throw new InvalidActionError('Unsupported source token call.') if (selector !== approveSelector) continue if (!targetsSourceToken) throw new InvalidActionError('Approval must target the source token.') if (call.data.length !== approveSelector.length + 128) throw new InvalidActionError('Malformed approval calldata.') const [spender, amount] = AbiFunction.decodeData(approve, call.data as Hex.Hex) if ( !action.data.calls .slice(index + 1) .some((next) => Address.isEqual(next.to as Address.Address, spender)) ) throw new InvalidActionError('Approval spender must match a later prepared executor.') if (amount >= Solidity.maxUint256 || amount > BigInt(options.sourceAmount)) throw new InvalidActionError('Approval must stay bounded to the route amount.') } } export declare namespace validateEvmCalls { /** Expected route terms the calls must stay bounded to. */ type Options = { /** The prepared action to validate. */ action: EvmCalls /** Exact native values established by provider-specific validation. */ nativeValues?: readonly NativeValue[] | undefined /** Source amount in base units. */ sourceAmount: string /** Source token contract address. */ sourceTokenAddress: string } } /** Error thrown when a provider returns an action outside the route's bounds. */ export class InvalidActionError extends Error { override name = 'Funding.Action.InvalidActionError' }