Skip to content

Commit 33df58b

Browse files
authored
fix: resolve session settlement fee token (#419)
1 parent 39b3226 commit 33df58b

6 files changed

Lines changed: 253 additions & 44 deletions

File tree

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+
Fixed Tempo session close and settle transactions to resolve funded Tempo fee tokens before signing.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { Account } from 'viem'
2+
import { createClient } from 'viem'
3+
import { Actions, Addresses } from 'viem/tempo'
4+
import { describe, expect, test } from 'vp/test'
5+
import { nodeEnv } from '~test/config.js'
6+
import { accounts, asset, chain, fundAccount, http } from '~test/tempo/viem.js'
7+
8+
import { resolveFeeToken } from './fee-token.js'
9+
10+
const isLocalnet = nodeEnv === 'localnet'
11+
12+
function clientFor(account: Account) {
13+
return createClient({
14+
account,
15+
chain,
16+
transport: http(),
17+
})
18+
}
19+
20+
function expectAddress(actual: string | undefined, expected: string) {
21+
expect(actual?.toLowerCase()).toBe(expected.toLowerCase())
22+
}
23+
24+
describe.runIf(isLocalnet)('resolveFeeToken', () => {
25+
test('uses the funded account fee preference first', async () => {
26+
const account = accounts[11]
27+
const client = clientFor(account)
28+
await fundAccount({ address: account.address, token: asset })
29+
await Actions.fee.setUserTokenSync(client, {
30+
feeToken: asset,
31+
token: asset,
32+
} as never)
33+
34+
const feeToken = await resolveFeeToken({
35+
account: account.address,
36+
candidateTokens: [Addresses.pathUsd],
37+
client,
38+
})
39+
40+
expectAddress(feeToken, asset)
41+
})
42+
43+
test('falls through to the first funded candidate token', async () => {
44+
const account = accounts[12]
45+
const client = clientFor(account)
46+
await fundAccount({ address: account.address, token: asset })
47+
48+
const feeToken = await resolveFeeToken({
49+
account: account.address,
50+
candidateTokens: [Addresses.pathUsd, asset],
51+
client,
52+
})
53+
54+
expectAddress(feeToken, asset)
55+
})
56+
57+
test('falls through from an unfunded account fee preference', async () => {
58+
const account = accounts[13]
59+
const client = clientFor(account)
60+
await fundAccount({ address: account.address, token: asset })
61+
await Actions.fee.setUserTokenSync(client, {
62+
feeToken: asset,
63+
token: Addresses.pathUsd,
64+
} as never)
65+
66+
const feeToken = await resolveFeeToken({
67+
account: account.address,
68+
candidateTokens: [asset],
69+
client,
70+
})
71+
72+
expectAddress(feeToken, asset)
73+
})
74+
75+
test('uses a funded chain fee token when configured', async () => {
76+
const account = accounts[14]
77+
const client = createClient({
78+
account,
79+
chain: { ...chain, feeToken: asset },
80+
transport: http(),
81+
})
82+
await fundAccount({ address: account.address, token: asset })
83+
84+
const feeToken = await resolveFeeToken({
85+
account: account.address,
86+
candidateTokens: [Addresses.pathUsd],
87+
client,
88+
})
89+
90+
expectAddress(feeToken, asset)
91+
})
92+
93+
test('falls through from an unfunded chain fee token', async () => {
94+
const account = accounts[15]
95+
const client = createClient({
96+
account,
97+
chain: { ...chain, feeToken: Addresses.pathUsd },
98+
transport: http(),
99+
})
100+
await fundAccount({ address: account.address, token: asset })
101+
102+
const feeToken = await resolveFeeToken({
103+
account: account.address,
104+
candidateTokens: [asset],
105+
client,
106+
})
107+
108+
expectAddress(feeToken, asset)
109+
})
110+
111+
test('falls back to the first known token when none are funded', async () => {
112+
const account = accounts[16]
113+
const client = clientFor(account)
114+
115+
const feeToken = await resolveFeeToken({
116+
account: account.address,
117+
candidateTokens: [asset, Addresses.pathUsd],
118+
client,
119+
})
120+
121+
expectAddress(feeToken, asset)
122+
})
123+
})

src/tempo/internal/fee-token.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Address, Client } from 'viem'
2+
import { Actions, TokenId } from 'viem/tempo'
3+
4+
import * as TempoAddress from './address.js'
5+
import * as defaults from './defaults.js'
6+
7+
function pushUnique(tokens: Address[], token: Address | undefined) {
8+
if (!token) return
9+
if (tokens.some((t) => TempoAddress.isEqual(t, token))) return
10+
tokens.push(token)
11+
}
12+
13+
async function hasBalance(client: Client, account: Address, token: Address): Promise<boolean> {
14+
try {
15+
return (await Actions.token.getBalance(client as never, { account, token })) > 0n
16+
} catch {
17+
return false
18+
}
19+
}
20+
21+
function getChainFeeToken(client: Client): Address | undefined {
22+
const feeToken = (client.chain as { feeToken?: Address | bigint | undefined } | undefined)
23+
?.feeToken
24+
if (feeToken) return TokenId.toAddress(feeToken)
25+
26+
const chainId = client.chain?.id
27+
return chainId ? defaults.currency[chainId as keyof typeof defaults.currency] : undefined
28+
}
29+
30+
export async function resolveFeeToken(parameters: {
31+
account: Address
32+
candidateTokens?: readonly Address[] | undefined
33+
client: Client
34+
}): Promise<Address | undefined> {
35+
const { account, candidateTokens, client } = parameters
36+
const tokens: Address[] = []
37+
38+
const userToken = await Actions.fee
39+
.getUserToken(client as never, { account })
40+
.then((token) => token?.address as Address | undefined)
41+
.catch(() => undefined)
42+
pushUnique(tokens, userToken)
43+
pushUnique(tokens, getChainFeeToken(client))
44+
for (const token of candidateTokens ?? []) pushUnique(tokens, token)
45+
46+
for (const token of tokens) {
47+
if (await hasBalance(client, account, token)) return token
48+
}
49+
50+
return tokens[0]
51+
}

src/tempo/server/Charge.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4268,9 +4268,17 @@ describe('tempo', () => {
42684268
],
42694269
})
42704270

4271+
let challengeNonce = 0
42714272
const httpServer = await Http.createServer(async (req, res) => {
4273+
// This replay check requires distinct challenge IDs. Default expires can collide
4274+
// when the paired 402s are issued in the same millisecond.
42724275
const result = await Mppx_server.toNodeListener(
4273-
chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
4276+
chargeServer.charge({
4277+
amount: '1',
4278+
currency: asset,
4279+
expires: new Date(Date.now() + 300_000 + challengeNonce++).toISOString(),
4280+
recipient: accounts[0].address,
4281+
}),
42744282
)(req, res)
42754283
if (result.status === 402) return
42764284
res.end('OK')
@@ -4339,9 +4347,17 @@ describe('tempo', () => {
43394347
],
43404348
})
43414349

4350+
let challengeNonce = 0
43424351
const httpServer = await Http.createServer(async (req, res) => {
4352+
// This replay check requires distinct challenge IDs. Default expires can collide
4353+
// when the paired 402s are issued in the same millisecond.
43434354
const result = await Mppx_server.toNodeListener(
4344-
chargeServer.charge({ amount: '1', currency: asset, recipient: accounts[0].address }),
4355+
chargeServer.charge({
4356+
amount: '1',
4357+
currency: asset,
4358+
expires: new Date(Date.now() + 300_000 + challengeNonce++).toISOString(),
4359+
recipient: accounts[0].address,
4360+
}),
43454361
)(req, res)
43464362
if (result.status === 402) return
43474363
res.end('OK')

src/tempo/server/Session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ export async function settle(
432432
...(options?.feePayer && options?.account
433433
? { feePayer: options.feePayer, account: options.account }
434434
: { account: options?.account }),
435+
candidateFeeTokens: [channel.token],
435436
})
436437

437438
await store.updateChannel(channelId, (current) => {
@@ -965,6 +966,7 @@ async function handleClose(
965966

966967
txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, {
967968
...(feePayer && account ? { feePayer, account } : { account }),
969+
candidateFeeTokens: [channel.token],
968970
})
969971
} catch (error) {
970972
if (pendingCloseMarked) {

0 commit comments

Comments
 (0)