From d7fde915841d65aad0df4781b706c973230d05d7 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:43:40 +1300 Subject: [PATCH 1/3] feat: support feePayer as URL on Tempo server methods Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cfdda-8349-739e-829e-a89f3408776c --- .changeset/fee-payer-url.md | 5 +++ package.json | 3 +- pnpm-lock.yaml | 39 ++++++++++++++++++++++ src/tempo/internal/account.ts | 13 +++++--- src/tempo/server/Charge.test.ts | 59 ++++++++++++++++++++++++++++++++- src/tempo/server/Charge.ts | 7 ++-- src/tempo/server/Session.ts | 6 ++-- src/viem/Client.ts | 25 +++++++++++--- 8 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 .changeset/fee-payer-url.md diff --git a/.changeset/fee-payer-url.md b/.changeset/fee-payer-url.md new file mode 100644 index 00000000..a9b001f3 --- /dev/null +++ b/.changeset/fee-payer-url.md @@ -0,0 +1,5 @@ +--- +"mppx": patch +--- + +Added support for `feePayer` as a URL string on `tempo` method. diff --git a/package.json b/package.json index a5012d62..81f9f1b7 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,14 @@ "@vitest/coverage-v8": "^4.0.17", "browserslist": "^4.28.1", "elysia": "^1.4.27", - "file-type": "^21.3.2", "eslint": "^9.28.0", "eslint-plugin-compat": "^7.0.1", "express": "^5.2.1", + "file-type": "^21.3.2", "hono": "^4.11.9", "playwright": "^1.58.2", "prool": "^0.2.2", + "tempo.ts": "^0.14.2", "testcontainers": "^11.11.0", "tsx": "^4.21.0", "typescript": "~5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e48931f..6013e1ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: prool: specifier: ^0.2.2 version: 0.2.2(testcontainers@11.11.0) + tempo.ts: + specifier: ^0.14.2 + version: 0.14.2(typescript@5.9.3)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) testcontainers: specifier: ^11.11.0 version: 11.11.0 @@ -966,12 +969,21 @@ packages: '@remix-run/fetch-proxy@0.7.1': resolution: {integrity: sha512-rPLfOpAaCXtm1dLI45uIPKERNbXbrh0P9AJc1sliz8pWd/McaFYjdr5KzB4QrFSfPvEt/Wmy6F2521qB1kK0ug==} + '@remix-run/fetch-router@0.17.0': + resolution: {integrity: sha512-3FeJGrTqrKKCvZdQWijbCXTEHKcdttkLFbI2ogfpZ+iDYSNZ9036wgDXuuoZqg6d+D0E8Unhk5ZwrLKDCd/hOw==} + '@remix-run/headers@0.19.0': resolution: {integrity: sha512-+62NbkXuXm9r/NdG6KfH9OCKofCWm8VjkrVPICiHKtRl8Gf2Vi6eFTN4mGgBlZRhd5mmEVRV4hTIn/JUSHDAOw==} '@remix-run/node-fetch-server@0.13.0': resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} + '@remix-run/route-pattern@0.19.0': + resolution: {integrity: sha512-RXKaIJ2Lx01uyZc0iw+yLzowFCa1/NuB8jN7QTo4QUe2CaUGtvPGdhgrTUp75lyNNCSJIrM9SaAJ6c1pjZdmoA==} + + '@remix-run/session@0.4.1': + resolution: {integrity: sha512-Bm6aKYgutb/raHZ3laloz8g/Qu7f3CeK3o4gUVDMxtEiAdWCzJamwHoTpGOc5+g1Kuy7z85v4M6nGrF06MFDSg==} + '@rolldown/binding-android-arm64@1.0.0-rc.9': resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3139,6 +3151,14 @@ packages: resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} engines: {node: '>=18'} + tempo.ts@0.14.2: + resolution: {integrity: sha512-N4UkP2X/KDLmYUEIEWUDAk1m/USbKMzTjjUz1m0LwrIEVfoDlcSbBRc9jp14gLZcJVDlnq+fWHFVcH+GdrySgQ==} + peerDependencies: + viem: ^2.46.2 + peerDependenciesMeta: + viem: + optional: true + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -4305,10 +4325,19 @@ snapshots: dependencies: '@remix-run/headers': 0.19.0 + '@remix-run/fetch-router@0.17.0': + dependencies: + '@remix-run/route-pattern': 0.19.0 + '@remix-run/session': 0.4.1 + '@remix-run/headers@0.19.0': {} '@remix-run/node-fetch-server@0.13.0': {} + '@remix-run/route-pattern@0.19.0': {} + + '@remix-run/session@0.4.1': {} + '@rolldown/binding-android-arm64@1.0.0-rc.9': optional: true @@ -6530,6 +6559,16 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tempo.ts@0.14.2(typescript@5.9.3)(viem@2.46.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6): + dependencies: + '@remix-run/fetch-router': 0.17.0 + ox: 0.14.1(typescript@5.9.3)(zod@4.3.6) + optionalDependencies: + viem: 2.46.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - typescript + - zod + term-size@2.2.1: {} testcontainers@11.11.0: diff --git a/src/tempo/internal/account.ts b/src/tempo/internal/account.ts index 9ef92f11..c8a88ff9 100644 --- a/src/tempo/internal/account.ts +++ b/src/tempo/internal/account.ts @@ -6,9 +6,10 @@ import type { Account, Address } from 'viem' * Accepts either `account` or `recipient` as the parameter name. When the value * is an `Account`, its address is extracted. If `feePayer` is `true`, the * account also acts as the fee payer. Alternatively, a separate `Account` - * can be provided as the fee payer. + * can be provided as the fee payer, or a URL string pointing to a fee payer + * relay service (used with `withFeePayer` transport wrapping). * - * @returns An object with `account`, `feePayer`, and `recipient`. + * @returns An object with `account`, `feePayer`, `feePayerUrl`, and `recipient`. */ export function resolve(parameters: resolve.Parameters) { const account = (() => { @@ -20,13 +21,15 @@ export function resolve(parameters: resolve.Parameters) { if (typeof parameters.account === 'object') return parameters.account.address return parameters.account })() + const feePayerUrl = typeof parameters.feePayer === 'string' ? parameters.feePayer : undefined const feePayer = (() => { + if (typeof parameters.feePayer === 'string') return undefined if (typeof parameters.account === 'object' && parameters.feePayer === true) return parameters.account if (typeof parameters.feePayer === 'object') return parameters.feePayer return undefined })() - return { account, feePayer, recipient: recipient as Address | undefined } + return { account, feePayer, feePayerUrl, recipient: recipient as Address | undefined } } export declare namespace resolve { @@ -40,8 +43,8 @@ export declare namespace resolve { | { /** Address that receives payment. */ account?: Address | undefined - /** Optional fee payer account for covering transaction fees. */ - feePayer?: Account | undefined + /** Optional fee payer account or fee payer URL for covering transaction fees. */ + feePayer?: Account | string | undefined } ) } diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index 457f7b47..bce5fcd5 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -2,8 +2,9 @@ import { Challenge, Credential, Receipt } from 'mppx' import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client' import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' import type { Hex } from 'ox' +import { Handler } from 'tempo.ts/server' import { encodeFunctionData, parseUnits } from 'viem' -import { prepareTransactionRequest, signTransaction } from 'viem/actions' +import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions' import { Abis, Actions, Addresses, Tick } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vitest' import * as Http from '~test/Http.js' @@ -487,6 +488,62 @@ describe('tempo', () => { httpServer.close() }) + test('behavior: fee payer URL (withFeePayer transport)', async () => { + const feePayerHandler = Handler.feePayer({ + account: accounts[0] as any, + client, + }) + const feePayerServer = await Http.createServer(feePayerHandler.listener) + + const serverWithFeePayer = Mppx_server.create({ + methods: [ + tempo_server.charge({ + feePayer: feePayerServer.url, + getClient: () => client, + currency: asset, + }), + ], + realm, + secretKey, + }) + + const mppx = Mppx_client.create({ + polyfill: false, + methods: [ + tempo_client({ + account: accounts[1], + getClient: () => client, + }), + ], + }) + + const httpServer = await Http.createServer(async (req, res) => { + const result = await Mppx_server.toNodeListener( + serverWithFeePayer.charge({ + amount: '1', + currency: asset, + recipient: accounts[0].address, + }), + )(req, res) + if (result.status === 402) return + res.end('OK') + }) + + const response = await mppx.fetch(httpServer.url) + expect(response.status).toBe(200) + + const receipt = Receipt.fromResponse(response) + expect(receipt.status).toBe('success') + + const txReceipt = await getTransactionReceipt(client, { + hash: receipt.reference as Hex.Hex, + }) + expect((txReceipt as any).feePayer).toBe(accounts[0].address.toLowerCase()) + + httpServer.close() + feePayerServer.close() + }) + test('error: rejects fee-payer transaction with unauthorized calls', async () => { const httpServer = await Http.createServer(async (req, res) => { const result = await Mppx_server.toNodeListener( diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 848fca29..3d42b121 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -42,10 +42,11 @@ export function charge( waitForConfirmation = true, } = parameters - const { recipient, feePayer } = Account.resolve(parameters) + const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters) const getClient = Client.getResolver({ chain: { ...tempo_chain, experimental_preconfirmationTime: 500 }, + feePayerUrl, getClient: parameters.getClient, rpcUrl: defaults.rpcUrl, }) @@ -82,7 +83,7 @@ export function charge( const resolvedFeePayer = (() => { const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer - const requested = request.feePayer !== false && (account ?? feePayer) + const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl) if (credential) return account if (requested) return true return undefined @@ -235,7 +236,7 @@ export function charge( recipient, }) - if (feePayer && methodDetails?.feePayer !== false) + if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false) FeePayer.validateCalls(calls, { amount, currency, recipient }) const resolvedFeeToken = diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index c6c96de6..e8e8cb86 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -98,12 +98,14 @@ export function session(p?: paramet const store = ChannelStore.fromStore(rawStore) + const { account, recipient, feePayer, feePayerUrl } = Account.resolve(parameters) + const getClient = Client.getResolver({ chain: tempo_chain, + feePayerUrl, getClient: parameters.getClient, rpcUrl: defaults.rpcUrl, }) - const { account, recipient, feePayer } = Account.resolve(parameters) type Transport = parameters['sse'] extends false | undefined ? undefined : Transport.Sse const transport = parameters.sse @@ -154,7 +156,7 @@ export function session(p?: paramet // Extract feePayer. const resolvedFeePayer = (() => { const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer - const requested = request.feePayer !== false && (account ?? feePayer) + const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl) if (credential) return account if (requested) return true return undefined diff --git a/src/viem/Client.ts b/src/viem/Client.ts index 47b8ad64..6f84da1e 100644 --- a/src/viem/Client.ts +++ b/src/viem/Client.ts @@ -1,25 +1,41 @@ import { type Chain, type Client, createClient, http } from 'viem' +import { withFeePayer } from 'viem/tempo' import type { MaybePromise } from '../internal/types.js' export function getResolver( parameters: getResolver.Parameters & { /** Default chain to use if not provided. */ chain?: Chain | undefined + /** Fee payer relay URL. When set, the transport is wrapped with `withFeePayer`. */ + feePayerUrl?: string | undefined /** RPC URLs keyed by chain ID. */ rpcUrl?: ({ [chainId: number]: string } & object) | undefined }, ): (parameters: { chainId?: number | undefined }) => MaybePromise { - const { chain, getClient, rpcUrl } = parameters + const { chain, feePayerUrl, getClient, rpcUrl } = parameters if (getClient) { // When a default chain with serializers is provided (e.g. Tempo chain config), // ensure user-provided clients inherit those serializers. Without this, clients // created without the Tempo chain config will use the default viem serializer, // causing errors like "maxFeePerGas is not a valid Legacy Transaction attribute". - if (!chain?.serializers) return getClient + if (!chain?.serializers && !feePayerUrl) return getClient return async (params) => { const client = await getClient(params) - if (client.chain?.serializers?.transaction) return client + + // Wrap the client's transport with `withFeePayer` when a fee payer URL is provided. + if (feePayerUrl && client.transport.key !== 'feePayer') { + const url = (client.transport as { url?: string }).url + if (url) { + const wrapped = createClient({ + chain: client.chain, + transport: withFeePayer(http(url), http(feePayerUrl)), + }) + Object.assign(client, { transport: wrapped.transport, request: wrapped.request }) + } + } + + if (!chain?.serializers || client.chain?.serializers?.transaction) return client return Object.assign({}, client, { chain: { ...chain, @@ -40,9 +56,10 @@ export function getResolver( const resolvedChainId = chainId || Number(Object.keys(rpcUrl)[0])! const url = rpcUrl[resolvedChainId as keyof typeof rpcUrl] if (!url) throw new Error(`No \`rpcUrl\` configured for \`chainId\` (${resolvedChainId}).`) + const transport = feePayerUrl ? withFeePayer(http(url), http(feePayerUrl)) : http(url) return createClient({ chain: { ...chain, id: resolvedChainId } as never, - transport: http(url), + transport, }) } } From c21144e0d2a3d91e95d5033f1e3ea93fa767171a Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:04:00 +1300 Subject: [PATCH 2/3] chore: fmt --- src/tempo/server/Charge.test.ts | 2 +- test/tempo/viem.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tempo/server/Charge.test.ts b/src/tempo/server/Charge.test.ts index bce5fcd5..e0d1474c 100644 --- a/src/tempo/server/Charge.test.ts +++ b/src/tempo/server/Charge.test.ts @@ -490,7 +490,7 @@ describe('tempo', () => { test('behavior: fee payer URL (withFeePayer transport)', async () => { const feePayerHandler = Handler.feePayer({ - account: accounts[0] as any, + account: accounts[0], client, }) const feePayerServer = await Http.createServer(feePayerHandler.listener) diff --git a/test/tempo/viem.ts b/test/tempo/viem.ts index 9089bb6f..508e0f83 100644 --- a/test/tempo/viem.ts +++ b/test/tempo/viem.ts @@ -1,6 +1,6 @@ import type * as Hex from 'ox/Hex' import { createClient, defineChain, type HttpTransportConfig, http as viem_http } from 'viem' -import { type Account, english, generateMnemonic, mnemonicToAccount } from 'viem/accounts' +import { type Account, english, generateMnemonic, type LocalAccount, mnemonicToAccount } from 'viem/accounts' import { tempo, tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains' import { Actions } from 'viem/tempo' import { nodeEnv } from '../config.js' @@ -17,7 +17,7 @@ export const accounts = Array.from({ length: 20 }, (_, i) => mnemonicToAccount(accountsMnemonic, { accountIndex: i, }), -) as unknown as FixedArray +) as unknown as FixedArray export const chain = (() => { switch (nodeEnv) { From ca0e6dd85cf01f1b29ff94a1ae13e241461f8c32 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:23:05 +1300 Subject: [PATCH 3/3] chore: up --- test/tempo/viem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/tempo/viem.ts b/test/tempo/viem.ts index 508e0f83..bec5e5a3 100644 --- a/test/tempo/viem.ts +++ b/test/tempo/viem.ts @@ -1,6 +1,6 @@ import type * as Hex from 'ox/Hex' import { createClient, defineChain, type HttpTransportConfig, http as viem_http } from 'viem' -import { type Account, english, generateMnemonic, type LocalAccount, mnemonicToAccount } from 'viem/accounts' +import { english, generateMnemonic, type LocalAccount, mnemonicToAccount } from 'viem/accounts' import { tempo, tempoDevnet, tempoLocalnet, tempoModerato } from 'viem/chains' import { Actions } from 'viem/tempo' import { nodeEnv } from '../config.js'