Skip to content

Commit 95a1f0a

Browse files
jxomampcode-com
andcommitted
feat: support feePayer as URL on Tempo server methods
Amp-Thread-ID: https://ampcode.com/threads/T-019cfdda-8349-739e-829e-a89f3408776c Co-authored-by: Amp <amp@ampcode.com>
1 parent af5f30b commit 95a1f0a

8 files changed

Lines changed: 143 additions & 16 deletions

File tree

.changeset/fee-payer-url.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mppx": patch
3+
---
4+
5+
Added support for `feePayer` as a URL string on `tempo` method.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,14 @@
3737
"@vitest/coverage-v8": "^4.0.17",
3838
"browserslist": "^4.28.1",
3939
"elysia": "^1.4.27",
40-
"file-type": "^21.3.2",
4140
"eslint": "^9.28.0",
4241
"eslint-plugin-compat": "^7.0.1",
4342
"express": "^5.2.1",
43+
"file-type": "^21.3.2",
4444
"hono": "^4.11.9",
4545
"playwright": "^1.58.2",
4646
"prool": "^0.2.2",
47+
"tempo.ts": "^0.14.2",
4748
"testcontainers": "^11.11.0",
4849
"tsx": "^4.21.0",
4950
"typescript": "~5.9.3",

pnpm-lock.yaml

Lines changed: 39 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tempo/internal/account.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import type { Account, Address } from 'viem'
66
* Accepts either `account` or `recipient` as the parameter name. When the value
77
* is an `Account`, its address is extracted. If `feePayer` is `true`, the
88
* account also acts as the fee payer. Alternatively, a separate `Account`
9-
* can be provided as the fee payer.
9+
* can be provided as the fee payer, or a URL string pointing to a fee payer
10+
* relay service (used with `withFeePayer` transport wrapping).
1011
*
11-
* @returns An object with `account`, `feePayer`, and `recipient`.
12+
* @returns An object with `account`, `feePayer`, `feePayerUrl`, and `recipient`.
1213
*/
1314
export function resolve(parameters: resolve.Parameters) {
1415
const account = (() => {
@@ -20,13 +21,15 @@ export function resolve(parameters: resolve.Parameters) {
2021
if (typeof parameters.account === 'object') return parameters.account.address
2122
return parameters.account
2223
})()
24+
const feePayerUrl = typeof parameters.feePayer === 'string' ? parameters.feePayer : undefined
2325
const feePayer = (() => {
26+
if (typeof parameters.feePayer === 'string') return undefined
2427
if (typeof parameters.account === 'object' && parameters.feePayer === true)
2528
return parameters.account
2629
if (typeof parameters.feePayer === 'object') return parameters.feePayer
2730
return undefined
2831
})()
29-
return { account, feePayer, recipient: recipient as Address | undefined }
32+
return { account, feePayer, feePayerUrl, recipient: recipient as Address | undefined }
3033
}
3134

3235
export declare namespace resolve {
@@ -40,8 +43,8 @@ export declare namespace resolve {
4043
| {
4144
/** Address that receives payment. */
4245
account?: Address | undefined
43-
/** Optional fee payer account for covering transaction fees. */
44-
feePayer?: Account | undefined
46+
/** Optional fee payer account or fee payer URL for covering transaction fees. */
47+
feePayer?: Account | string | undefined
4548
}
4649
)
4750
}

src/tempo/server/Charge.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { Challenge, Credential, Receipt } from 'mppx'
22
import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
33
import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
44
import type { Hex } from 'ox'
5+
import { Handler } from 'tempo.ts/server'
56
import { encodeFunctionData, parseUnits } from 'viem'
6-
import { prepareTransactionRequest, signTransaction } from 'viem/actions'
7+
import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions'
78
import { Abis, Actions, Addresses, Tick } from 'viem/tempo'
89
import { beforeAll, describe, expect, test } from 'vitest'
910
import * as Http from '~test/Http.js'
@@ -487,6 +488,64 @@ describe('tempo', () => {
487488
httpServer.close()
488489
})
489490

491+
test('behavior: fee payer URL (withFeePayer transport)', async () => {
492+
const feePayerHandler = Handler.feePayer({
493+
account: accounts[0],
494+
client,
495+
})
496+
const feePayerServer = await Http.createServer(feePayerHandler.listener)
497+
498+
const serverWithFeePayer = Mppx_server.create({
499+
methods: [
500+
tempo_server.charge({
501+
feePayer: feePayerServer.url,
502+
getClient: () => client,
503+
currency: asset,
504+
}),
505+
],
506+
realm,
507+
secretKey,
508+
})
509+
510+
const mppx = Mppx_client.create({
511+
polyfill: false,
512+
methods: [
513+
tempo_client({
514+
account: accounts[1],
515+
getClient: () => client,
516+
}),
517+
],
518+
})
519+
520+
const httpServer = await Http.createServer(async (req, res) => {
521+
const result = await Mppx_server.toNodeListener(
522+
serverWithFeePayer.charge({
523+
amount: '1',
524+
currency: asset,
525+
recipient: accounts[0].address,
526+
}),
527+
)(req, res)
528+
if (result.status === 402) return
529+
res.end('OK')
530+
})
531+
532+
const response = await mppx.fetch(httpServer.url)
533+
expect(response.status).toBe(200)
534+
535+
const receipt = Receipt.fromResponse(response)
536+
expect(receipt.status).toBe('success')
537+
538+
const txReceipt = await getTransactionReceipt(client, {
539+
hash: receipt.reference as Hex.Hex,
540+
})
541+
expect((txReceipt as any).feePayer).toBe(
542+
accounts[0].address.toLowerCase(),
543+
)
544+
545+
httpServer.close()
546+
feePayerServer.close()
547+
})
548+
490549
test('error: rejects fee-payer transaction with unauthorized calls', async () => {
491550
const httpServer = await Http.createServer(async (req, res) => {
492551
const result = await Mppx_server.toNodeListener(

src/tempo/server/Charge.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ export function charge<const parameters extends charge.Parameters>(
4242
waitForConfirmation = true,
4343
} = parameters
4444

45-
const { recipient, feePayer } = Account.resolve(parameters)
45+
const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters)
4646

4747
const getClient = Client.getResolver({
4848
chain: { ...tempo_chain, experimental_preconfirmationTime: 500 },
49+
feePayerUrl,
4950
getClient: parameters.getClient,
5051
rpcUrl: defaults.rpcUrl,
5152
})
@@ -82,7 +83,7 @@ export function charge<const parameters extends charge.Parameters>(
8283

8384
const resolvedFeePayer = (() => {
8485
const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer
85-
const requested = request.feePayer !== false && (account ?? feePayer)
86+
const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl)
8687
if (credential) return account
8788
if (requested) return true
8889
return undefined
@@ -235,7 +236,7 @@ export function charge<const parameters extends charge.Parameters>(
235236
recipient,
236237
})
237238

238-
if (feePayer && methodDetails?.feePayer !== false)
239+
if ((feePayer || feePayerUrl) && methodDetails?.feePayer !== false)
239240
FeePayer.validateCalls(calls, { amount, currency, recipient })
240241

241242
const resolvedFeeToken =

src/tempo/server/Session.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ export function session<const parameters extends session.Parameters>(p?: paramet
9898

9999
const store = ChannelStore.fromStore(rawStore)
100100

101+
const { account, recipient, feePayer, feePayerUrl } = Account.resolve(parameters)
102+
101103
const getClient = Client.getResolver({
102104
chain: tempo_chain,
105+
feePayerUrl,
103106
getClient: parameters.getClient,
104107
rpcUrl: defaults.rpcUrl,
105108
})
106-
const { account, recipient, feePayer } = Account.resolve(parameters)
107109

108110
type Transport = parameters['sse'] extends false | undefined ? undefined : Transport.Sse
109111
const transport = parameters.sse
@@ -154,7 +156,7 @@ export function session<const parameters extends session.Parameters>(p?: paramet
154156
// Extract feePayer.
155157
const resolvedFeePayer = (() => {
156158
const account = typeof request.feePayer === 'object' ? request.feePayer : feePayer
157-
const requested = request.feePayer !== false && (account ?? feePayer)
159+
const requested = request.feePayer !== false && (account ?? feePayer ?? feePayerUrl)
158160
if (credential) return account
159161
if (requested) return true
160162
return undefined

src/viem/Client.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,41 @@
11
import { type Chain, type Client, createClient, http } from 'viem'
2+
import { withFeePayer } from 'viem/tempo'
23
import type { MaybePromise } from '../internal/types.js'
34

45
export function getResolver(
56
parameters: getResolver.Parameters & {
67
/** Default chain to use if not provided. */
78
chain?: Chain | undefined
9+
/** Fee payer relay URL. When set, the transport is wrapped with `withFeePayer`. */
10+
feePayerUrl?: string | undefined
811
/** RPC URLs keyed by chain ID. */
912
rpcUrl?: ({ [chainId: number]: string } & object) | undefined
1013
},
1114
): (parameters: { chainId?: number | undefined }) => MaybePromise<Client> {
12-
const { chain, getClient, rpcUrl } = parameters
15+
const { chain, feePayerUrl, getClient, rpcUrl } = parameters
1316

1417
if (getClient) {
1518
// When a default chain with serializers is provided (e.g. Tempo chain config),
1619
// ensure user-provided clients inherit those serializers. Without this, clients
1720
// created without the Tempo chain config will use the default viem serializer,
1821
// causing errors like "maxFeePerGas is not a valid Legacy Transaction attribute".
19-
if (!chain?.serializers) return getClient
22+
if (!chain?.serializers && !feePayerUrl) return getClient
2023
return async (params) => {
2124
const client = await getClient(params)
22-
if (client.chain?.serializers?.transaction) return client
25+
26+
// Wrap the client's transport with `withFeePayer` when a fee payer URL is provided.
27+
if (feePayerUrl && client.transport.key !== 'feePayer') {
28+
const url = (client.transport as { url?: string }).url
29+
if (url) {
30+
const wrapped = createClient({
31+
chain: client.chain,
32+
transport: withFeePayer(http(url), http(feePayerUrl)),
33+
})
34+
Object.assign(client, { transport: wrapped.transport, request: wrapped.request })
35+
}
36+
}
37+
38+
if (!chain?.serializers || client.chain?.serializers?.transaction) return client
2339
return Object.assign({}, client, {
2440
chain: {
2541
...chain,
@@ -40,9 +56,10 @@ export function getResolver(
4056
const resolvedChainId = chainId || Number(Object.keys(rpcUrl)[0])!
4157
const url = rpcUrl[resolvedChainId as keyof typeof rpcUrl]
4258
if (!url) throw new Error(`No \`rpcUrl\` configured for \`chainId\` (${resolvedChainId}).`)
59+
const transport = feePayerUrl ? withFeePayer(http(url), http(feePayerUrl)) : http(url)
4360
return createClient({
4461
chain: { ...chain, id: resolvedChainId } as never,
45-
transport: http(url),
62+
transport,
4663
})
4764
}
4865
}

0 commit comments

Comments
 (0)