Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fee-payer-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mppx": patch
---

Added support for `feePayer` as a URL string on `tempo` method.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions src/tempo/internal/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (() => {
Expand All @@ -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 {
Expand All @@ -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
}
)
}
59 changes: 58 additions & 1 deletion src/tempo/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -487,6 +488,62 @@ describe('tempo', () => {
httpServer.close()
})

test('behavior: fee payer URL (withFeePayer transport)', async () => {
const feePayerHandler = Handler.feePayer({
account: accounts[0],
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(
Expand Down
7 changes: 4 additions & 3 deletions src/tempo/server/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@ export function charge<const parameters extends charge.Parameters>(
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,
})
Expand Down Expand Up @@ -82,7 +83,7 @@ export function charge<const parameters extends charge.Parameters>(

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
Expand Down Expand Up @@ -235,7 +236,7 @@ export function charge<const parameters extends charge.Parameters>(
recipient,
})

if (feePayer && methodDetails?.feePayer !== false)
if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false)
FeePayer.validateCalls(calls, { amount, currency, recipient })

const resolvedFeeToken =
Expand Down
6 changes: 4 additions & 2 deletions src/tempo/server/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,14 @@ export function session<const parameters extends session.Parameters>(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
Expand Down Expand Up @@ -154,7 +156,7 @@ export function session<const parameters extends session.Parameters>(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
Expand Down
25 changes: 21 additions & 4 deletions src/viem/Client.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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,
Expand All @@ -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,
})
}
}
Expand Down
4 changes: 2 additions & 2 deletions test/tempo/viem.ts
Original file line number Diff line number Diff line change
@@ -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 { 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'
Expand All @@ -17,7 +17,7 @@ export const accounts = Array.from({ length: 20 }, (_, i) =>
mnemonicToAccount(accountsMnemonic, {
accountIndex: i,
}),
) as unknown as FixedArray<Account, 20>
) as unknown as FixedArray<LocalAccount, 20>

export const chain = (() => {
switch (nodeEnv) {
Expand Down
Loading