UNPKG

accounts

Version:

Tempo Accounts SDK

1,267 lines 58.5 kB
import { AbiEvent, Hex, RpcRequest, RpcResponse } from 'ox';
import { Transaction as core_Transaction } from 'ox/tempo';
import { createClient, decodeFunctionData, formatUnits, http, isAddress, parseEventLogs, zeroAddress, } from 'viem';
import { simulateCalls } from 'viem/actions';
import { Abis, Actions, Addresses, Capabilities, Transaction, VirtualAddress } from 'viem/tempo';
import { tempo, tempoDevnet, tempoModerato } from 'viem/tempo/chains';
import * as ExecutionError from '../../../core/ExecutionError.js';
import * as Schema from '../../../core/Schema.js';
import { from } from '../../Handler.js';
import * as Kv from '../../Kv.js';
import { cached } from '../kv.js';
import * as Tokenlist from '../tokenlist.js';
import * as Multisig from './multisig.js';
import * as Sponsorship from './sponsorship.js';
import * as Utils from './utils.js';
/** Default cache TTL in seconds (10 minutes) for the verified tokenlist. */
const defaultCacheTtl = 10 * 60;
/**
 * Instantiates a relay handler that proxies `eth_fillTransaction`
 * with wallet-aware enrichment (fee token resolution, simulation,
 * sponsorship, AMM resolution).
 *
 * @example
 * ```ts
 * import { Handler } from 'accounts/server'
 *
 * const handler = Handler.relay()
 *
 * // Plug handler into your server framework of choice:
 * createServer(handler.listener)              // Node.js
 * Bun.serve(handler)                          // Bun
 * Deno.serve(handler)                         // Deno
 * app.use(handler.listener)                   // Express
 * app.all('*', c => handler.fetch(c.req.raw)) // Hono
 * export const GET = handler.fetch            // Next.js
 * export const POST = handler.fetch           // Next.js
 * ```
 *
 * @example
 * With sponsorship
 *
 * ```ts
 * import { privateKeyToAccount } from 'viem/accounts'
 * import { Handler } from 'accounts/server'
 *
 * const handler = Handler.relay({
 *   feePayer: {
 *     account: privateKeyToAccount('0x...'),
 *   },
 * })
 * ```
 *
 * @param options - Options.
 * @returns Request handler.
 */
export function relay(options = {}) {
    const { cacheTtl = defaultCacheTtl, chains = [tempo, tempoModerato, tempoDevnet], internal_allowUnsafeUrls = false, kv = Kv.memory(), onRequest, path = '/', resolveTokens, transports = {}, ...rest } = options;
    const feePayerOptions = options.feePayer;
    const multisigOptions = options.multisig;
    // Resolves the verified tokenlist for `chainId`, sharing the KV cache with
    // `Handler.exchange` so a single `kv: Kv.cloudflare(env.KV)` covers both.
    // The relay only needs addresses, so map down from the full metadata.
    const getTokens = async (chainId) => {
        const tokens = await Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens });
        return tokens.map((t) => t.address);
    };
    const features = {
        autoSwap: options.autoSwap ?? options.features === 'all',
        feeTokenResolution: options.resolveTokens ?? options.features === 'all',
        simulate: options.features === 'all',
    };
    const autoSwap = (() => {
        if (!features.autoSwap)
            return undefined;
        if (options.autoSwap === false)
            return undefined;
        return {
            slippage: (typeof options.autoSwap === 'object' ? options.autoSwap?.slippage : undefined) ?? 0.05,
        };
    })();
    const clients = new Map();
    for (const chain of chains) {
        const transport = transports[chain.id] ?? http();
        clients.set(chain.id, createClient({ chain, batch: { multicall: { deployless: true } }, transport }));
    }
    function getClient(chainId) {
        if (chainId) {
            const client = clients.get(chainId);
            if (!client)
                throw new Error(`Chain ${chainId} not configured`);
            return client;
        }
        return clients.get(chains[0].id);
    }
    async function handleRequest(request, options) {
        await onRequest?.(request);
        // Resolve chainId from: 1) explicit option (URL path), 2) first param object, 3) default chain.
        const params = 'params' in request && Array.isArray(request.params) ? request.params : [];
        const first = typeof params[0] === 'object' && params[0]
            ? params[0]
            : undefined;
        const chainId = Utils.resolveChainId(first?.chainId) ?? options?.chainId ?? chains[0].id;
        const client = getClient(chainId);
        switch (request.method) {
            case 'eth_fillTransaction': {
                const parameters = params[0];
                const capabilities = (parameters.capabilities ?? {});
                try {
                    const from = typeof parameters.from === 'string' ? parameters.from : undefined;
                    const requestFeeToken = typeof parameters.feeToken === 'string' ? parameters.feeToken : undefined;
                    const externalFeePayerUrl = typeof parameters.feePayer === 'string'
                        ? normalizeExternalFeePayerUrl(parameters.feePayer, {
                            allowUnsafe: internal_allowUnsafeUrls,
                        })
                        : undefined;
                    const requestsSponsorship = (!!feePayerOptions || !!externalFeePayerUrl) && parameters.feePayer !== false;
                    // Default to `true`. Dapps that don't render diffs can pass
                    // `capabilities.balanceDiffs: false` to skip the post-fill
                    // `tempo_simulateV1` round trip (~250-400ms).
                    const requireBalanceDiffs = capabilities.balanceDiffs !== false;
                    const { feePayer: _feePayer, ...normalized } = Utils.normalizeFillTransactionRequest(parameters);
                    // When sponsoring, the sponsor's preferred fee token (if set)
                    // overrides whatever the consumer asked for — the sponsor is
                    // paying, so the sponsor picks. Falls back to the request's
                    // feeToken otherwise.
                    const sponsoredFeeToken = requestsSponsorship
                        ? (feePayerOptions?.feeToken ?? requestFeeToken)
                        : requestFeeToken;
                    const baseTx = {
                        ...normalized,
                        ...(typeof chainId !== 'undefined' ? { chainId } : {}),
                        ...(sponsoredFeeToken ? { feeToken: sponsoredFeeToken } : {}),
                    };
                    let filled;
                    let sponsored = false;
                    let feeToken = sponsoredFeeToken;
                    // Lazily resolve a swap source token when autoSwap needs one.
                    const resolveFeeTokenForSwap = from
                        ? async (insufficientToken) => resolveFeeToken(client, {
                            account: from,
                            feeToken: undefined,
                            kv,
                            tokens: (await getTokens(chainId)).filter((t) => t.toLowerCase() !== insufficientToken.toLowerCase()),
                        })
                        : undefined;
                    // When no sponsor will pay, prefer fee tokens the user actually
                    // holds. Extend the configured token list with any TIP20 token
                    // the transaction is calling — typically the token being
                    // transferred — so a user transferring USDC.e can pay gas in
                    // USDC.e even when the configured list defaults to pathUSD.
                    const configuredTokens = await getTokens(chainId);
                    const unsponsoredTokens = [
                        ...configuredTokens,
                        ...callTargetTokens(baseTx).filter((t) => !configuredTokens.some((rt) => rt.toLowerCase() === t.toLowerCase())),
                    ];
                    // When the app provides its own fee payer URL, route the fill
                    // through that service so it can sign the transaction.
                    const fillClient = externalFeePayerUrl
                        ? createClient({
                            chain: client.chain,
                            batch: { multicall: { deployless: true } },
                            transport: http(externalFeePayerUrl),
                        })
                        : client;
                    if (requestsSponsorship && (externalFeePayerUrl || !feePayerOptions?.validate)) {
                        // Path A: sponsorship guaranteed — skip fee token resolution,
                        // fill once with feePayer, then parallelize the rest. Taken when:
                        //   - no validate is configured (no gating), OR
                        //   - the app supplied its own external fee payer URL (that
                        //     relay is the sponsorship authority — the wallet's own
                        //     `validate` only governs the wallet's own fee payer).
                        // Default to the chain's first token so the broadcast envelope
                        // always carries a feeToken the chain can charge.
                        if (!feeToken)
                            feeToken = (await getTokens(chainId))[0];
                        const transaction = {
                            ...baseTx,
                            feePayer: true,
                            ...(feeToken ? { feeToken } : {}),
                        };
                        if (Sponsorship.isPreparedTransaction(transaction)) {
                            filled = {
                                transaction: Utils.normalizeTempoTransaction(transaction),
                                sponsor: undefined,
                            };
                        }
                        else {
                            filled = await fill(fillClient, {
                                autoSwap,
                                feeToken,
                                kv,
                                resolveFeeToken: resolveFeeTokenForSwap,
                                transaction,
                            });
                            // The chain echoes feeToken: null for sponsored fills served
                            // by viem clients that strip feeToken pre-sign; re-inject
                            // before the spread in mergeCallsFromRequest drops it.
                            if (feeToken &&
                                filled?.transaction &&
                                filled.transaction.feeToken == null)
                                filled.transaction.feeToken = feeToken;
                        }
                        sponsored = true;
                    }
                    else if (requestsSponsorship && feePayerOptions?.validate) {
                        // Path B: sponsorship possible but may be rejected.
                        // Speculatively fill the sponsored variant, validate, and only
                        // fall back to the unsponsored path on rejection. The accept
                        // case (common) avoids the upfront `resolveFeeToken` round
                        // trips and the second `eth_fillTransaction`.
                        const sponsoredTx = { ...baseTx, feePayer: true };
                        if (Sponsorship.isPreparedTransaction(sponsoredTx)) {
                            // Already prepared — skip fills, just validate sponsorship.
                            const prepared = {
                                transaction: Utils.normalizeTempoTransaction(sponsoredTx),
                                sponsor: undefined,
                            };
                            sponsored = await Sponsorship.shouldSponsor({
                                sender: from,
                                transaction: prepared.transaction,
                                validate: feePayerOptions.validate,
                            });
                            filled = prepared;
                        }
                        else {
                            const options = {
                                autoSwap,
                                kv,
                                resolveFeeToken: resolveFeeTokenForSwap,
                            };
                            const fill_sponsored = await fill(fillClient, {
                                ...options,
                                feeToken,
                                transaction: sponsoredTx,
                            });
                            sponsored = await Sponsorship.shouldSponsor({
                                sender: from,
                                transaction: fill_sponsored.transaction,
                                validate: feePayerOptions.validate,
                            });
                            if (sponsored) {
                                filled = fill_sponsored;
                            }
                            else {
                                // Sponsor rejected — resolve fee token and fill unsponsored.
                                const feeToken_unsponsored = features.feeTokenResolution
                                    ? await resolveFeeToken(client, {
                                        account: from,
                                        feeToken: requestFeeToken,
                                        kv,
                                        tokens: unsponsoredTokens,
                                    })
                                    : requestFeeToken;
                                const tx_unsponsored = {
                                    ...baseTx,
                                    ...(feeToken_unsponsored ? { feeToken: feeToken_unsponsored } : {}),
                                };
                                filled = await fill(client, {
                                    ...options,
                                    feeToken: feeToken_unsponsored,
                                    transaction: tx_unsponsored,
                                });
                                feeToken = feeToken_unsponsored;
                            }
                        }
                    }
                    else {
                        // Path C: no sponsorship configured — resolve fee token, fill once.
                        feeToken = features.feeTokenResolution
                            ? await resolveFeeToken(client, {
                                account: from,
                                feeToken: requestFeeToken,
                                kv,
                                tokens: unsponsoredTokens,
                            })
                            : requestFeeToken;
                        const transaction = { ...baseTx, ...(feeToken ? { feeToken } : {}) };
                        filled = await fill(client, {
                            autoSwap,
                            feeToken,
                            kv,
                            resolveFeeToken: resolveFeeTokenForSwap,
                            transaction,
                        });
                    }
                    const transaction_filled = filled.transaction;
                    const swap = 'swap' in filled ? filled.swap : undefined;
                    if (!feeToken)
                        feeToken =
                            transaction_filled.feeToken ?? (await getTokens(chainId))[0];
                    // Parallelize: simulate, fee payer signing, and autoSwap metadata.
                    const alreadySigned = 'feePayerSignature' in transaction_filled &&
                        transaction_filled.feePayerSignature != null;
                    const calls = extractCalls(transaction_filled);
                    const [simulation, transaction_final, autoSwap_, virtualAddresses] = await Promise.all([
                        // Simulate and compute balance diffs + fee.
                        // When `capabilities.balanceDiffs` is `false`, skip simulate
                        // and compute fee directly from cached metadata.
                        (async () => {
                            if (!features.simulate)
                                return { balanceDiffs: undefined, fee: undefined };
                            if (requireBalanceDiffs)
                                return simulateAndParseDiffs(client, {
                                    account: from,
                                    calls,
                                    swap,
                                    feeToken,
                                    gas: transaction_filled.gas,
                                    kv,
                                    maxFeePerGas: transaction_filled.maxFeePerGas,
                                });
                            const fee = await computeFee(client, {
                                feeToken,
                                gas: transaction_filled.gas,
                                kv,
                                maxFeePerGas: transaction_filled.maxFeePerGas,
                            }).catch(() => undefined);
                            return { balanceDiffs: undefined, fee };
                        })(),
                        // Sign as fee payer (if sponsored and not already signed).
                        (async () => {
                            if (!(sponsored && feePayerOptions && !alreadySigned))
                                return transaction_filled;
                            return Sponsorship.sign({
                                account: feePayerOptions.account,
                                sender: from,
                                transaction: transaction_filled,
                            });
                        })(),
                        // Resolve autoSwap metadata (when AMM path was taken).
                        resolveAutoSwapMetadata(client, { autoSwap, kv, swap }),
                        // Resolve virtual-address recipients so wallets can show the
                        // eventual master address before signing.
                        resolveVirtualAddresses(client, { calls }),
                    ]);
                    const { balanceDiffs, fee } = simulation;
                    const sponsor = (() => {
                        if (!sponsored)
                            return undefined;
                        // App-provided fee payer: relay back the sponsor from the upstream response.
                        if (externalFeePayerUrl)
                            return filled.sponsor;
                        if (feePayerOptions)
                            return Sponsorship.getSponsor(feePayerOptions);
                        return filled.sponsor;
                    })();
                    return RpcResponse.from({
                        result: {
                            ...(sponsor ? { sponsor } : {}),
                            tx: core_Transaction.toRpc(transaction_final),
                            capabilities: {
                                balanceDiffs,
                                fee,
                                sponsored: !!sponsor,
                                ...(sponsor ? { sponsor } : {}),
                                ...(autoSwap_ ? { autoSwap: autoSwap_ } : {}),
                                ...(virtualAddresses ? { virtualAddresses } : {}),
                            },
                        },
                    }, { request });
                }
                catch (error) {
                    if (!(error instanceof Error))
                        return Utils.rpcErrorJson(request, error);
                    if (capabilities.errors !== true)
                        return Utils.rpcErrorJson(request, error);
                    const revert = ExecutionError.parse(error);
                    const parameters = request.params[0];
                    const stub = {
                        from: parameters.from,
                        to: parameters.to ?? null,
                        gas: '0x0',
                        nonce: '0x0',
                        value: '0x0',
                        maxFeePerGas: '0x0',
                        maxPriorityFeePerGas: '0x0',
                    };
                    if (revert?.errorName === 'InsufficientBalance') {
                        const args = revert.args;
                        const [available, required, token] = args;
                        const normalized = Utils.normalizeFillTransactionRequest(parameters);
                        // Simulate from zero address for optimistic balance diffs.
                        const optimisticCalls = normalized ? extractCalls(normalized) : undefined;
                        const [{ balanceDiffs }, virtualAddresses] = optimisticCalls
                            ? await Promise.all([
                                simulateAndParseDiffs(client, {
                                    account: zeroAddress,
                                    calls: optimisticCalls,
                                    kv,
                                }),
                                resolveVirtualAddresses(client, { calls: optimisticCalls }),
                            ])
                            : [{ balanceDiffs: undefined }, undefined];
                        // Re-key balance diffs from zero address to the real sender.
                        const senderDiffs = parameters.from && balanceDiffs
                            ? { [parameters.from]: balanceDiffs[zeroAddress] ?? [] }
                            : balanceDiffs;
                        const metadata = await resolveTokenMetadata(client, { token, kv }).catch(() => undefined);
                        const deficit = required - available;
                        return RpcResponse.from({
                            result: {
                                tx: stub,
                                capabilities: {
                                    balanceDiffs: senderDiffs,
                                    error: ExecutionError.serialize(revert),
                                    requireFunds: metadata
                                        ? {
                                            amount: Hex.fromNumber(deficit),
                                            decimals: metadata.decimals,
                                            formatted: formatUnits(deficit, metadata.decimals),
                                            token,
                                            symbol: metadata.symbol,
                                        }
                                        : undefined,
                                    sponsored: false,
                                    ...(virtualAddresses ? { virtualAddresses } : {}),
                                },
                            },
                        }, { request });
                    }
                    const normalized = Utils.normalizeFillTransactionRequest(parameters);
                    const virtualAddresses = normalized
                        ? await resolveVirtualAddresses(client, { calls: extractCalls(normalized) }).catch(() => undefined)
                        : undefined;
                    return RpcResponse.from({
                        result: {
                            tx: stub,
                            capabilities: {
                                error: ExecutionError.serialize(revert),
                                sponsored: false,
                                ...(virtualAddresses ? { virtualAddresses } : {}),
                            },
                        },
                    }, { request });
                }
            }
            // @ts-expect-error
            case 'eth_signRawTransaction':
            case 'eth_sendRawTransaction':
            case 'eth_sendRawTransactionSync': {
                try {
                    const serialized = params[0];
                    if (multisigOptions &&
                        (request.method === 'eth_sendRawTransaction' ||
                            request.method === 'eth_sendRawTransactionSync') &&
                        typeof serialized === 'string' &&
                        Multisig.isMultisigTransaction(serialized)) {
                        const result = await Multisig.handleRawTransaction({
                            claimTtl: multisigOptions.claimTtl,
                            finalize: multisigOptions.finalize,
                            getClient,
                            method: request.method,
                            request: { params: 'params' in request ? request.params : undefined },
                            resolveConfig: multisigOptions.resolveConfig,
                            sponsor: feePayerOptions
                                ? {
                                    account: feePayerOptions.account,
                                    feeToken: feePayerOptions.feeToken,
                                    resolveFeeToken: async (chainId) => (await getTokens(chainId))[0],
                                    validate: feePayerOptions.validate,
                                }
                                : undefined,
                            store: multisigOptions.store,
                        });
                        return RpcResponse.from({ result }, { request });
                    }
                    if (!feePayerOptions) {
                        // eth_signRawTransaction is a signing method that only the
                        // relay can fulfill — forwarding it to the RPC node returns
                        // an opaque "Method not found". Return an actionable error.
                        if (request.method === 'eth_signRawTransaction') {
                            return RpcResponse.from({
                                error: {
                                    code: -32601,
                                    message: 'eth_signRawTransaction requires a fee payer to be configured on the relay. ' +
                                        'Set the `feePayer` option in `Handler.relay()` to enable transaction sponsorship.',
                                },
                            }, { request });
                        }
                        const result = await client.request(request);
                        return RpcResponse.from({ result }, { request });
                    }
                    if (typeof serialized !== 'string' ||
                        !Sponsorship.requestsRawSponsorship(serialized)) {
                        const result = await client.request(request);
                        return RpcResponse.from({ result }, { request });
                    }
                    const result = await Sponsorship.handleRawTransaction({
                        account: feePayerOptions.account,
                        feeToken: feePayerOptions.feeToken,
                        getFeeToken: async (chainId) => (await getTokens(chainId))[0],
                        getClient,
                        method: request.method,
                        request: { params: 'params' in request ? request.params : undefined },
                        validate: feePayerOptions.validate,
                    });
                    return RpcResponse.from({ result }, { request });
                }
                catch (error) {
                    return Utils.rpcErrorJson(request, error);
                }
            }
            case 'eth_getTransactionByHash':
            case 'eth_getTransactionReceipt': {
                try {
                    if (multisigOptions) {
                        const aliased = await Multisig.handleGetTransaction({
                            getClient,
                            method: request.method,
                            request: { params: 'params' in request ? request.params : undefined },
                            store: multisigOptions.store,
                        });
                        if (aliased)
                            return RpcResponse.from({ result: aliased.result }, { request });
                    }
                    const result = await client.request(request);
                    return RpcResponse.from({ result }, { request });
                }
                catch (error) {
                    return Utils.rpcErrorJson(request, error);
                }
            }
            default: {
                try {
                    const result = await client.request(request);
                    return RpcResponse.from({ result }, { request });
                }
                catch (error) {
                    return Utils.rpcErrorJson(request, error);
                }
            }
        }
    }
    const router = from(rest);
    async function handlePost(c) {
        const chainId = Utils.resolveChainId(c.req.param('chainId'));
        const body = await c.req.raw.json();
        const isBatch = Array.isArray(body);
        if (!isBatch) {
            const request = RpcRequest.from(body);
            return Response.json(await handleRequest(request, { chainId }));
        }
        const responses = await Promise.all(body.map((item) => handleRequest(RpcRequest.from(item), {
            chainId,
        })));
        return Response.json(responses);
    }
    router.post(path, handlePost);
    router.post(`${path === '/' ? '' : path}/:chainId`, handlePost);
    return router;
}
// TODO: cleanup
async function fill(client, options) {
    const { autoSwap, feeToken, kv, transaction: request } = options;
    // Skip re-formatting if already in RPC format (e.g. from viem's fillTransaction).
    const format = (value) => value.type === '0x76' ? value : Utils.formatFillTransactionRequest(client, value);
    // Re-fill the transaction with prepended swap calls so the user can
    // mint the missing `insufficientToken` (typically the fee token) from
    // a token they already hold. Returns null if no source token is
    // available or autoSwap is disabled.
    async function fillWithSwap(insufficientToken, deficit) {
        if (!autoSwap)
            return null;
        const sourceToken = feeToken && feeToken.toLowerCase() !== insufficientToken.toLowerCase()
            ? feeToken
            : await options.resolveFeeToken?.(insufficientToken);
        if (!sourceToken || sourceToken.toLowerCase() === insufficientToken.toLowerCase())
            return null;
        const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n;
        const originalCalls = request.calls ?? [];
        const swapCalls = buildSwapCalls(sourceToken, insufficientToken, deficit, maxAmountIn);
        const result = await client.request({
            method: 'eth_fillTransaction',
            params: [
                format({
                    ...request,
                    calls: [...swapCalls, ...originalCalls],
                }),
            ],
        });
        const sponsor = result.capabilities?.sponsor;
        const mergedTx = mergeCallsFromRequest(result.tx, {
            ...request,
            calls: [...swapCalls, ...originalCalls],
        });
        return {
            transaction: Utils.normalizeTempoTransaction(mergedTx),
            sponsor,
            swap: {
                calls: swapCalls,
                tokenIn: sourceToken,
                tokenOut: insufficientToken,
                amountOut: deficit,
                maxAmountIn,
            },
        };
    }
    try {
        const formatted = format(request);
        const result = await client.request({
            method: 'eth_fillTransaction',
            params: [formatted],
        });
        // FIXME: node estimates gas with secp256k1 dummy sig + null feePayerSignature.
        // Actual tx has larger keychain/webAuthn sigs + real fee payer sig, costing
        // more intrinsic gas. Mirror the bump from viem's tempo chainConfig.
        // Skip if another relay already bumped (indicated by feePayerSignature).
        // @ts-expect-error
        if (result.tx.gas && request.feePayer && !result.tx.feePayerSignature)
            result.tx.gas = Hex.fromNumber(BigInt(result.tx.gas) + 20000n);
        const upstreamCapabilities = result.capabilities;
        const sponsor = upstreamCapabilities?.sponsor;
        // External fee-payer relays surface chain reverts (e.g. InsufficientBalance,
        // InsufficientAllowance) inside `capabilities.error` with a stub `tx`
        // instead of throwing. Re-throw all upstream errors so the outer handler
        // either recovers via autoSwap (InsufficientBalance) or propagates the
        // error to the caller. Without this, the wallet would otherwise sign the
        // zero-stub tx with its own fee payer and broadcast garbage.
        const upstreamError = upstreamCapabilities?.error;
        if (upstreamError) {
            const synthetic = new Error(upstreamError.message ?? upstreamError.errorName ?? 'UpstreamRevert');
            synthetic.name = 'UpstreamRevertError';
            synthetic.data = upstreamError.data;
            throw synthetic;
        }
        // Reconstruct a `swap` shape from upstream's `capabilities.autoSwap` so the
        // wallet relay's outer code can re-resolve autoSwap metadata locally —
        // otherwise upstream-driven swaps are silently dropped from the response.
        const swap = extractSwapFromCapabilities(upstreamCapabilities?.autoSwap);
        // The chain's `eth_fillTransaction` doesn't echo back `calls`, so merge
        // them in from the original request before normalizing — otherwise the
        // typed envelope built for sponsorship signing throws CallsEmptyError.
        const mergedTx = mergeCallsFromRequest(result.tx, request);
        // The node's `eth_fillTransaction` may pick a fee token the user
        // doesn't yet hold (e.g. one this transaction will mint via a
        // swap). Tempo's gas check runs before the calls execute, so the
        // tx would revert at broadcast with `have 0 want N`. Validate the
        // sender's pre-tx balance against the computed gas cost and, if
        // short, autoSwap a token they do hold into the fee token.
        if (autoSwap && !swap) {
            const fromAddress = request.from;
            const resolvedFeeToken = (mergedTx.feeToken ?? feeToken);
            const gas = mergedTx.gas ? BigInt(mergedTx.gas) : 0n;
            const maxFeePerGas = mergedTx.maxFeePerGas
                ? BigInt(mergedTx.maxFeePerGas)
                : 0n;
            if (fromAddress && resolvedFeeToken && gas > 0n && maxFeePerGas > 0n) {
                const [balance, metadata] = await Promise.all([
                    Actions.token
                        .getBalance(client, { account: fromAddress, token: resolvedFeeToken })
                        .then(({ amount }) => amount)
                        .catch(() => 0n),
                    resolveTokenMetadata(client, { token: resolvedFeeToken, kv }).catch(() => undefined),
                ]);
                if (metadata) {
                    const requiredFee = (gas * maxFeePerGas) / 10n ** BigInt(Math.max(0, 18 - metadata.decimals));
                    if (balance < requiredFee) {
                        // Best-effort: if the swap itself can't be filled (e.g. the
                        // source token also has insufficient balance), fall through
                        // and return the original tx with the resolved feeToken
                        // rather than failing the whole request.
                        const swapResult = await fillWithSwap(resolvedFeeToken, requiredFee - balance).catch(() => null);
                        if (swapResult)
                            return swapResult;
                    }
                }
            }
        }
        return {
            transaction: Utils.normalizeTempoTransaction(mergedTx),
            sponsor,
            ...(swap ? { swap } : {}),
        };
    }
    catch (error) {
        if (!(error instanceof Error))
            throw error;
        if (!autoSwap)
            throw error;
        const revert = ExecutionError.parse(error);
        if (revert?.errorName !== 'InsufficientBalance')
            throw error;
        const [available, required, token] = revert.args;
        if (typeof available === 'undefined' || typeof required === 'undefined' || !token)
            throw error;
        const swapResult = await fillWithSwap(token, required - available);
        if (!swapResult)
            throw error;
        return swapResult;
    }
}
async function resolveFeeToken(client, options) {
    const { feeToken, account, kv, tokens } = options;
    if (feeToken)
        return feeToken;
    if (!account)
        return undefined;
    // Cache the user's preferred fee token for `userTokenCacheTtl` seconds.
    // The on-chain preference rarely changes; a short TTL avoids the
    // ~150-250ms `userTokens` multicall on every unsponsored fill while
    // still picking up updates within a minute. The cached value is just a
    // hint — if the user's balance is 0 we fall back to the highest-balance
    // token from the configured list.
    const getUserToken = () => Actions.fee.getUserToken(client, { account }).catch(() => null);
    const userTokenPromise = kv
        ? cached(kv, `fee.userToken:${client.chain?.id ?? 0}:${account.toLowerCase()}`, async () => {
            const result = await getUserToken();
            return result ? { address: result.address } : null;
        }, { ttl: options.userTokenCacheTtl ?? 60 })
        : getUserToken();
    const [userToken, balances] = await Promise.all([
        userTokenPromise,
        tokens
            ? Promise.all(tokens.map(async (token) => ({
                address: token,
                balance: await Actions.token
                    .getBalance(client, { account, token })
                    .then(({ amount }) => amount)
                    .catch(() => 0n),
            })))
            : [],
    ]);
    // If on-chain preference is set and user has balance, use it.
    if (userToken) {
        const match = balances.find((b) => b.address.toLowerCase() === userToken.address.toLowerCase() && b.balance > 0n);
        if (match)
            return userToken.address;
        // Token list may not include the preference — check on-chain directly.
        if (!match) {
            try {
                const balance = await Actions.token.getBalance(client, {
                    account,
                    token: userToken.address,
                });
                if (balance.amount > 0n)
                    return userToken.address;
            }
            catch { }
        }
    }
    // Pick the token with the highest balance.
    let best;
    for (const asset of balances) {
        if (asset.balance <= 0n)
            continue;
        if (!best || asset.balance > best.balance)
            best = asset;
    }
    if (best)
        return best.address;
}
/**
 * Extracts unique TIP20 token addresses (Tempo `0x20c0…` prefix) that the
 * transaction's calls target. Used as fallback fee-token candidates so a
 * user transferring a token they hold can pay gas in that token even when
 * the configured fee-token list defaults to one they don't hold.
 */
function callTargetTokens(transaction) {
    const calls = transaction.calls;
    if (!calls)
        return [];
    const out = [];
    const seen = new Set();
    for (const c of calls) {
        if (!c.to)
            continue;
        const lower = c.to.toLowerCase();
        if (!lower.startsWith('0x20c0'))
            continue;
        if (seen.has(lower))
            continue;
        seen.add(lower);
        out.push(c.to);
    }
    return out;
}
async function resolveVirtualAddresses(client, options) {
    const targets = getVirtualAddressTargets(options.calls);
    if (targets.length === 0)
        return undefined;
    const masters = new Map();
    for (const address of targets) {
        const { masterId } = VirtualAddress.parse(address);
        const lower = masterId.toLowerCase();
        const entry = masters.get(lower) ?? { addresses: [], masterId };
        entry.addresses.push(address);
        masters.set(lower, entry);
    }
    const entries = await Promise.all([...masters.values()].map(async ({ addresses, masterId }) => {
        const master = await Actions.virtualAddress.getMasterAddress(client, { masterId });
        return addresses.map((address) => [address, master]);
    }));
    return Object.fromEntries(entries.flat());
}
function getVirtualAddressTargets(calls) {
    const targets = new Set();
    for (const call of calls) {
        for (const address of [call.to, decodeTransferRecipient(call.data)]) {
            if (!address || !isAddress(address) || !VirtualAddress.isVirtual(address))
                continue;
            targets.add(address.toLowerCase());
        }
    }
    return [...targets];
}
function decodeTransferRecipient(data) {
    if (!data)
        return undefined;
    const selector = data.slice(0, 10).toLowerCase();
    if (!transferSelectors.has(selector))
        return undefined;
    try {
        const { args, functionName } = decodeFunctionData({
            abi: Abis.tip20,
            data: data,
        });
        if ((functionName === 'transfer' || functionName === 'transferWithMemo') &&
            typeof args[0] === 'string')
            return args[0];
        if ((functionName === 'transferFrom' || functionName === 'transferFromWithMemo') &&
            typeof args[1] === 'string')
            return args[1];
    }
    catch { }
    return undefined;
}
const transferSelectors = new Set(['0xa9059cbb', '0x95777d59', '0x23b872dd', '0x929c2539']);
// TODO: cleanup/remove
function extractCalls(transaction) {
    const calls = transaction.calls;
    if (calls && calls.length > 0)
        return calls.map((c) => ({
            ...(c.to ? { to: c.to } : {}),
            ...(c.data ? { data: c.data } : {}),
            ...(c.value ? { value: c.value } : {}),
        }));
    return [
        {
            ...(transaction.to ? { to: transaction.to } : {}),
            ...(transaction.data ? { data: transaction.data } : {}),
            ...(transaction.value ? { value: transaction.value } : {}),
        },
    ];
}
// TODO: cleanup/remove
async function simulate(client, options) {
    const { account, calls } = options;
    try {
        return await Actions.simulate.simulateCalls(client, {
            ...(account ? { account } : {}),
            calls: calls,
            traceTransfers: true,
        });
    }
    catch (error) {
        // TODO: Remove fallback once all nodes support tempo_simulateV1.
        // Fall back to viem's simulateCalls (eth_simulateV1) if the Tempo
        // method (tempo_simulateV1) is not supported.
        const code = error.code ??
            error.cause?.code;
        if (code !== -32601)
            throw error;
        const { results } = await simulateCalls(client, {
            ...(account ? { account } : {}),
            calls: calls,
        });
        return { results, tokenMetadata: undefined };
    }
}
async function simulateAndParseDiffs(client, options) {
    const { account, calls, swap, feeToken, gas, kv, maxFeePerGas } = options;
    try {
        const { results, tokenMetadata } = await simulate(client, {
            account: account === zeroAddress ? undefined : account,
            calls,
        });
        // Collect all logs across all call results.
        const logs = [];
        for (const result of results)
            if (result.logs)
                logs.push(...result.logs);
        // Build per-token balance diffs relative to the sender.
        const balanceDiffs = account
            ? await buildBalanceDiffs(client, {
                account,
                kv,
                logs,
                swap,
                tokenMetadata: tokenMetadata,
            })
            : {};
        // Compute fee breakdown.
        const fee = await computeFee(client, {
            feeToken,
            gas,
            kv,
            maxFeePerGas,
            tokenMetadata: tokenMetadata,
        }).catch(() => undefined);
        return { balanceDiffs, fee };
    }
    catch {
        // Simulation failures should not block the fill response —
        // return empty diffs with fee computed from transaction fields.
        const fee = await computeFee(client, { feeToken, gas, kv, maxFeePerGas });
        return { balanceDiffs: undefined, fee };
    }
}
async function buildBalanceDiffs(client, options) {
    const { account, kv, logs, swap, tokenMetadata } = options;
    const accountLower = account.toLowerCase();
    const dexLower = Addresses.stablecoinDex.toLowerCase();
    const swapTokenIn = swap?.tokenIn.toLowerCase();
    const swapTokenOut = swap?.tokenOut.toLowerCase();
    const transferLogs = parseEventLogs({
        abi: [AbiEvent.fromAbi(Abis.tip20, 'Transfer')],
        eventName: 'Transfer',
        logs,
    });
    const approvalLogs = parseEventLogs({
        abi: [AbiEvent.fromAbi(Abis.tip20, 'Approval')],
        eventName: 'Approval',
        logs,
    });
    // Track net movement per token: incoming vs outgoing.
    const tokenMap = new Map();
    // Track total transferred per (token, spender) so we can suppress covered approvals.
    const transferredBySpender = new Map();
    for (const log of transferLogs) {
        const token = log.address.toLowerCase();
        const fromLower = log.args.from.toLowerCase();
        const toLower = log.args.to.toLowerCase();
        // Skip swap-related transfers (reported in capabilities.autoSwap instead).
        if (swap) {
            if (token === swapTokenIn && fromLower === accountLower && toLower === dexLower)
                continue;
            if (token === swapTokenOut && fromLower === dexLower && toLower === accountLower)
                continue;
        }
        const entry = tokenMap.get(token) ?? {
            incoming: 0n,
            outgoing: 0n,
            recipients: new Set(),
            token: log.address,
        };
        if (fromLower === accountLower) {
            entry.outgoing += log.args.amount;
            entry.recipients.add(log.args.to);
            const key = `${token}:${toLower}`;
            transferredBySpender.set(key, (transferredBySpender.get(key) ?? 0n) + log.args.amount);
        }
        if (toLower === accountLower)
            entry.incoming += log.args.amount;
        tokenMap.set(token, entry);
    }
    // Treat approvals as outgoing unless the spender already transferred >= approval amount.
    for (const log of approvalLogs) {
        if (log.args.owner.toLowerCase() !== accountLower)
            continue;
        const token = log.address.toLowerCase();
        // Skip swap-related approvals (reported in capabilities.autoSwap instead).
        if (swap && token === swapTokenIn && log.args.spender.toLowerCase() === dexLower)
            continue;
        const spenderKey = `${token}:${log.args.spender.toLowerCase()}`;
        const transferred = transferredBySpender.get(spenderKey) ?? 0n;
        if (log.args.amount <= transferred)
            continue;
        const entry = tokenMap.get(token) ?? {
            incoming: 0n,
            outgoing: 0n,
            recipients: new Set(),
            token: log.address,
        };
        entry.outgoing += log.args.amount - transferred;
        entry.recipients.add(log.args.spender);
        tokenMap.set(token, entry);
    }
    // Collect unique tokens that need decimals.
    const entries = [...tokenMap.values()].filter((e) => {
        const net = e.outgoing > e.incoming ? e.outgoing - e.incoming : e.incoming - e.outgoing;
        return net > 0n;
    });
    if (entries.length === 0)
        return {};
    // Resolve metadata for all tokens in parallel (simulation metadata first, RPC fallback).
    const metadataMap = new Map();
    await Promise.all(entries.map(async (entry) => {
        try {
            const metadata = await resolveTokenMetadata(client, {
                token: entry.token,
                tokenMetadata,
                kv,
            });
            metadataMap.set(entry.token.toLowerCase(), metadata);
        }
        catch { }
    }));
    // Build the diff array for this account.
    const diffs = [];
    for (const entry of entries) {
        const net = entry.outgoing > entry.incoming
            ? entry.outgoing - entry.incoming
            : entry.incoming - entry.outgoing;
        const direction = entry.outgoing > entry.incoming ? 'outgoing' : 'incoming';
        const meta = metadataMap.get(entry.token.toLowerCase());
        const decimals = meta?.decimals ?? 0;
        diffs.push({
            address: entry.token,
            decimals,
            direction,
            formatted: formatUnits(net, decimals),
            name: meta?.name ?? '',
            symbol: meta?.symbol ?? '',
            recipients: [...entry.recipients],
            value: Hex.fromNumber(net),
        });
    }
    return { [account]: diffs };
}
async function resolveTokenMetadata(client, options) {
    const { token, tokenMetadata, kv } = options;
    const meta = tokenMetadata?.[token] ?? tokenMetadata?.[token.toLowerCase()];
    // TIP-20 metadata (decimals/symbol/name) is immutable per token, so cache
    // long-term in KV. Skips the multicall RPC on cache hits.
    const fetcher = () => Actions.token.getMetadata(client, { token });
    const fallback = kv
        ? await cached(kv, `tokenMetadata:${client.chain?.id ?? 0}:${token.toLowerCase()}`, fetcher, {
            ttl: 24 * 60 * 60,
        })
        : await fetcher();
    return {
        decimals: fallback.decimals ?? 6,
        symbol: meta?.symbol || fallback.symbol,
        name: meta?.name || fallback.name,
    };
}
async function resolveAutoSwapMetadata(client, options) {
    const { autoSwap, kv, swap } = options;
    if (!autoSwap || !swap)
        return undefined;
    const [inMeta, outMeta] = await Promise.all([
        resolveTokenMetadata(client, { token: swap.tokenIn, kv }).catch(() => undefined),
        resolveTokenMetadata(client, { token: swap.tokenOut, kv }).catch(() => undefined),
    ]);
    if (!inMeta || !outMeta)
        return undefined;
    return {
        calls: swap.calls.map((c) => ({ to: c.to, data: c.data })),
        slippage: autoSwap.slippage,
        maxIn: {
            token: swap.tokenIn,
            value: Hex.fromNumber(swap.maxAmountIn),
            formatted: formatUnits(swap.maxAmountIn, inMeta.decimals),
            decimals: inMeta.decimals,
            symbol: inMeta.symbol,
            name: inMeta.name,
        },
        minOut: {
            token: swap.tokenOut,
            value: Hex.fromNumber(swap.amountOut),
            formatted: formatUnits(swap.amountOut, outMeta.decimals),
            decimals: outMeta.decimals,
            symbol: outMeta.symbol,
            name: outMeta.name,
        },
    };
}
async function computeFee(client, options) {
    const { feeToken, gas, kv, maxFeePerGas, tokenMetadata } = options;
    if (!feeToken || !gas || !maxFeePerGas)
        return undefined;
    try {
        const metadata = await resolveTokenMetadata(client, { token: feeToken, tokenMetadata, kv });
        const raw = gas * maxFeePerGas;
        const amount = raw / 10n ** BigInt(18 - metadata.decimals);
        return {
            amount: Hex.fromNumber(amount),
            decimals: metadata.decimals,
            formatted: formatUnits(amount, metadata.decimals),
            symbol: metadata.symbol,
        };
    }
    catch {
        return undefined;
    }
}
function buildSwapCalls(sourceToken, targetToken, deficit, maxAmountIn) {
    const approve = Actions.token.approve.call({
        token: sourceToken,
        spender: Addresses.stablecoinDex,
        amount: maxAmountIn,
    });
    const buy = Actions.dex.buy.call({
        tokenIn: sourceToken,
        tokenOut: targetToken,
        amountOut: deficit,
        maxAmountIn,
    });
    return [
        { to: approve.to, data: approve.data, value: 0n },
        { to: buy.to, data: buy.data, value: 0n },
    ];
}
/**
 * Merges the original fill request into the result tx. The chain's
 * `eth_fillTransaction` returns only the "filled" gas/nonce/fee fields and
 * omits envelope inputs like `calls`, `chainId`, `validBefore`, `nonceKey`,
 * `keyData`, `keyType`, `feePayer`. Without these the typed Tempo envelope
 * built for sponsorship signing throws `CallsEmptyError` or
 * `Cannot convert undefined to a BigInt` when serializing.
 *
 * Result fields take precedence (they are the chain's authoritative filled
 * values); request fields fill in everything else. Calls are normalized
 * separately so legacy `to`/`data`/`value` requests are also supported.
 */
function mergeCallsFromRequest(resultTx, request) {
    const merged = { ...request, ...resultTx };
    const resultCalls = resultTx.calls;
    if (Array.isArray(resultCalls) && resultCalls.length > 0)
        return merged;
    const reqCalls = request.calls;
    if (Array.isArray(reqCalls) && reqCalls.length > 0) {
        merged.calls = reqCalls;
        return merged;
    }
    const { to, data, value } = request;
    if (typeof to === 'undefined' && typeof data === 'undefined' && typeof value === 'undefined')
        return merged;
    merged.calls = [
        {
            ...(typeof to !== 'undefined' ? { to } : {}),
            ...(typeof data !== 'undefined' ? { data } : {}),
            ...(typeof value !== 'undefined' ? { value } : {}),
        },
    ];
    return merged;
}
/** Normalizes external fee-payer relay URLs before forwarding fill payloads. */
function normalizeExternalFeePayerUrl(value, options = {}) {
    let url;
    try {
        url = new URL(value);
    }
    catch {
        throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL.' });
    }
    const allowUnsafe = options.allowUnsafe === true;
    if (url.protocol !== 'http:' && url.protocol !== 'https:')
        throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' });
    if (!allowUnsafe && url.protocol !== 'https:')
        throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL protocol.' });
    if (!allowUnsafe && isUnsafeHostname(url.hostname))
        throw new RpcResponse.InvalidParamsError({ message: 'Invalid fee payer URL host.' });
    url.username = '';
    url.password = '';
    url.hash = '';
    return url.href;
}
function isUnsafeHostname(hostname) {
    const host = normalizeHostname(hostname);
    if (host === 'localhost' || host.endsWith('.localhost'))
        return true;
    const ipv4 = parseIpv4(host);
    if (ipv4)
        return isUnsafeIpv4(ipv4);
    if (!host.includes(':'))
        return false;
    const ipv6 = parseIpv6(host);
    if (!ipv6)
        return true;
    return isUnsafeIpv6(ipv6);
}
function normalizeHostname(hostname) {
    return hostname
        .toLowerCase()
        .replace(/^\[|\]$/g, '')
        .split('%')[0];
}
function parseIpv4(value) {
    const parts = value.split('.');
    if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part)))
        return undefined;
    const numbers = parts.map((part) => Number(part));
    if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
        return undefined;
    return numbers;
}
function isUnsafeIpv4(parts) {
    const [a, b] = parts;
    if (a === 0 || a === 10 || a === 127)
        return true;
    if (a === 100 && b >= 64 && b <= 127)
        return true;
    if (a === 169 && b === 254)
        return true;
    if (a === 172 && b >= 16 && b <= 31)
        return true;
    if (a === 192 && b === 168)
        return true;
    if (a >= 224)
        return true;
    return false;
}
function parseIpv6(value) {
    const halves = value.split('::');
    if (halves.length > 2)
        return undefined;
    const left = halves[0] ? halves[0].split(':') : [];
    const right = halves[1] ? halves[1].split(':') : [];
    const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
    if (missing < 0)
        return undefined;
    const parts = [...left, ...Array(missing).fill('0'), ...right];
    if (parts.length !== 8)
        return undefined;
    const numbers = parts.map((part) => /^[\da-f]{1,4}$/i.test(part) ? Number.parseInt(part, 16) : NaN);
    if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff))
        return undefined;
    return numbers;
}
function isUnsafeIpv6(parts) {
    const [a] = parts;
    if (parts.every((part) => part === 0))
        return true;
    if (parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1)
        return true;
    if ((a & 0xfe00) === 0xfc00)
        return true;
    if ((a & 0xffc0) === 0xfe80)
        return true;
    if ((a & 0xffc0) === 0xfec0)
        return true;
    if ((a & 0xff00) === 0xff00)
        return true;
    const ipv4 = ipv4FromIpv6(parts);
    if (ipv4)
        return isUnsafeIpv4(ipv4);
    return false;
}
function ipv4FromIpv6(parts) {
    const mapped = parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff;
    const compatible = parts.slice(0, 6).every((part) => part === 0);
    if (!mapped && !compatible)
        return undefined;
    return [parts[6] >> 8, parts[6] & 0xff, parts[7] >> 8, parts[7] & 0xff];
}
/**
 * Reconstructs a `swap` shape (matching the inner autoSwap branch's return
 * value) from an upstream relay's `capabilities.autoSwap`. Used so a wallet
 * relay forwarding to an external feePayer URL can re-emit autoSwap metadata
 * locally without losing track of the upstream's swap.
 */
function extractSwapFromCapabilities(autoSwap) {
    if (!autoSwap || typeof autoSwap !== 'object')
        return undefined;
    const a = autoSwap;
    if (!a.calls || !a.maxIn?.token || !a.maxIn.value || !a.minOut?.token || !a.minOut.value)
        return undefined;
    return {
        calls: a.calls,
        tokenIn: a.maxIn.token,
        tokenOut: a.minOut.token,
        amountOut: BigInt(a.minOut.value),
        maxAmountIn: BigInt(a.maxIn.value),
    };
}
//# sourceMappingURL=relay.js.map