mppx 0.8.9 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/cli/validate/challenge.d.ts +2 -0
- package/dist/cli/validate/challenge.d.ts.map +1 -1
- package/dist/cli/validate/challenge.js +21 -9
- package/dist/cli/validate/challenge.js.map +1 -1
- package/dist/cli/validate/discovery.d.ts.map +1 -1
- package/dist/cli/validate/discovery.js +50 -19
- package/dist/cli/validate/discovery.js.map +1 -1
- package/dist/cli/validate/helpers.d.ts +1 -0
- package/dist/cli/validate/helpers.d.ts.map +1 -1
- package/dist/cli/validate/helpers.js +11 -0
- package/dist/cli/validate/helpers.js.map +1 -1
- package/dist/cli/validate/index.d.ts +1 -0
- package/dist/cli/validate/index.d.ts.map +1 -1
- package/dist/cli/validate/index.js +4 -0
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/cli/validate/payment.d.ts +1 -0
- package/dist/cli/validate/payment.d.ts.map +1 -1
- package/dist/cli/validate/payment.js +2 -2
- package/dist/cli/validate/payment.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +35 -6
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodResponse.d.ts +23 -0
- package/dist/client/internal/MethodResponse.d.ts.map +1 -0
- package/dist/client/internal/MethodResponse.js +15 -0
- package/dist/client/internal/MethodResponse.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts +39 -2
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +45 -1
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/internal/fee-token.d.ts +8 -0
- package/dist/tempo/internal/fee-token.d.ts.map +1 -1
- package/dist/tempo/internal/fee-token.js +19 -6
- package/dist/tempo/internal/fee-token.js.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.js +4 -0
- package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
- package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
- package/dist/tempo/legacy/client/Session.js +7 -0
- package/dist/tempo/legacy/client/Session.js.map +1 -1
- package/dist/tempo/legacy/session/Chain.d.ts.map +1 -1
- package/dist/tempo/legacy/session/Chain.js +53 -50
- package/dist/tempo/legacy/session/Chain.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +8 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +49 -45
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +7 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts.map +1 -1
- package/dist/tempo/server/Subscription.js +24 -16
- package/dist/tempo/server/Subscription.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +53 -1
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +3 -0
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +3 -0
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +30 -0
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +118 -19
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +25 -19
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/server/index.d.ts +2 -0
- package/dist/tempo/session/server/index.d.ts.map +1 -1
- package/dist/tempo/session/server/index.js +2 -0
- package/dist/tempo/session/server/index.js.map +1 -1
- package/dist/validation/core.d.ts +1 -0
- package/dist/validation/core.d.ts.map +1 -1
- package/dist/validation/core.js +5 -0
- package/dist/validation/core.js.map +1 -1
- package/dist/x402/Header.js +1 -1
- package/dist/x402/Header.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/validate/challenge.ts +20 -8
- package/src/cli/validate/discovery.test.ts +128 -0
- package/src/cli/validate/discovery.ts +50 -15
- package/src/cli/validate/helpers.ts +10 -0
- package/src/cli/validate/index.ts +4 -0
- package/src/cli/validate/payment.ts +4 -1
- package/src/cli/validate.test.ts +23 -2
- package/src/client/Mppx.test-d.ts +3 -1
- package/src/client/internal/Fetch.test.ts +29 -0
- package/src/client/internal/Fetch.ts +51 -6
- package/src/client/internal/MethodResponse.ts +39 -0
- package/src/tempo/PublicExports.test-d.ts +5 -0
- package/src/tempo/internal/fee-payer.test.ts +93 -0
- package/src/tempo/internal/fee-payer.ts +64 -2
- package/src/tempo/internal/fee-token.test.ts +37 -0
- package/src/tempo/internal/fee-token.ts +26 -5
- package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
- package/src/tempo/legacy/client/ChannelOps.ts +7 -0
- package/src/tempo/legacy/client/Session.test.ts +102 -1
- package/src/tempo/legacy/client/Session.ts +10 -0
- package/src/tempo/legacy/session/Chain.test.ts +78 -2
- package/src/tempo/legacy/session/Chain.ts +56 -52
- package/src/tempo/server/Charge.test.ts +121 -6
- package/src/tempo/server/Charge.ts +61 -48
- package/src/tempo/server/Methods.ts +6 -0
- package/src/tempo/server/Subscription.test.ts +79 -4
- package/src/tempo/server/Subscription.ts +24 -16
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/Session.test.ts +83 -1
- package/src/tempo/session/client/Session.ts +73 -1
- package/src/tempo/session/client/SessionManager.test.ts +12 -1
- package/src/tempo/session/client/SessionManager.ts +5 -0
- package/src/tempo/session/client/Transports.test.ts +109 -0
- package/src/tempo/session/client/Transports.ts +153 -29
- package/src/tempo/session/precompile/Chain.test.ts +9 -0
- package/src/tempo/session/precompile/Chain.ts +28 -20
- package/src/tempo/session/server/Session.test.ts +23 -3
- package/src/tempo/session/server/index.ts +2 -0
- package/src/validation/core.ts +6 -0
- package/src/x402/Header.ts +1 -1
|
@@ -11,8 +11,10 @@ import {
|
|
|
11
11
|
defaultAllowedFeeTokens,
|
|
12
12
|
FeePayerValidationError,
|
|
13
13
|
fillHostedFeePayerTransaction,
|
|
14
|
+
preflightSponsorship,
|
|
14
15
|
prepareSponsoredTransaction,
|
|
15
16
|
simulationTransaction,
|
|
17
|
+
sponsoredSimulationTransaction,
|
|
16
18
|
validateCalls,
|
|
17
19
|
} from './fee-payer.js'
|
|
18
20
|
import * as Selectors from './selectors.js'
|
|
@@ -768,6 +770,97 @@ describe('simulationTransaction', () => {
|
|
|
768
770
|
feePayerSignature: undefined,
|
|
769
771
|
})
|
|
770
772
|
})
|
|
773
|
+
|
|
774
|
+
test('builds the final sponsored envelope without either signature', () => {
|
|
775
|
+
const transaction = {
|
|
776
|
+
calls: [{ to: bogus }],
|
|
777
|
+
feePayerSignature,
|
|
778
|
+
feeToken: swapTokenIn,
|
|
779
|
+
from: bogus,
|
|
780
|
+
signature: { r: 1n, s: 2n, yParity: 0 },
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
expect(
|
|
784
|
+
sponsoredSimulationTransaction(transaction as any, {
|
|
785
|
+
feePayer: swapTokenOut,
|
|
786
|
+
feeToken: bogus,
|
|
787
|
+
}),
|
|
788
|
+
).toEqual({
|
|
789
|
+
...transaction,
|
|
790
|
+
account: bogus,
|
|
791
|
+
calls: transaction.calls,
|
|
792
|
+
feePayer: swapTokenOut,
|
|
793
|
+
feePayerSignature: undefined,
|
|
794
|
+
feeToken: bogus,
|
|
795
|
+
signature: undefined,
|
|
796
|
+
})
|
|
797
|
+
})
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
describe('preflightSponsorship', () => {
|
|
801
|
+
const transaction = {
|
|
802
|
+
calls: [{ to: bogus }],
|
|
803
|
+
feeToken: swapTokenIn,
|
|
804
|
+
from: bogus,
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
test('simulates the sender before completion and the completed envelope after', async () => {
|
|
808
|
+
const simulations: Record<string, unknown>[] = []
|
|
809
|
+
let completions = 0
|
|
810
|
+
|
|
811
|
+
const result = await preflightSponsorship({
|
|
812
|
+
transaction: transaction as any,
|
|
813
|
+
async simulate(request) {
|
|
814
|
+
simulations.push(request)
|
|
815
|
+
},
|
|
816
|
+
async complete() {
|
|
817
|
+
completions += 1
|
|
818
|
+
return { feePayer: swapTokenOut, transaction: transaction as any }
|
|
819
|
+
},
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
expect(completions).toBe(1)
|
|
823
|
+
expect(result.feePayer).toBe(swapTokenOut)
|
|
824
|
+
expect(simulations).toEqual([
|
|
825
|
+
{ account: bogus, calls: transaction.calls },
|
|
826
|
+
{
|
|
827
|
+
...transaction,
|
|
828
|
+
account: bogus,
|
|
829
|
+
calls: transaction.calls,
|
|
830
|
+
feePayer: swapTokenOut,
|
|
831
|
+
feePayerSignature: undefined,
|
|
832
|
+
signature: undefined,
|
|
833
|
+
},
|
|
834
|
+
])
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test.each([
|
|
838
|
+
{ completeCalls: 0, failingSimulation: 1, simulationCalls: 1 },
|
|
839
|
+
{ completeCalls: 1, failingSimulation: 2, simulationCalls: 2 },
|
|
840
|
+
])(
|
|
841
|
+
'stops at simulation $failingSimulation',
|
|
842
|
+
async ({ completeCalls, failingSimulation, simulationCalls }) => {
|
|
843
|
+
let completions = 0
|
|
844
|
+
let simulations = 0
|
|
845
|
+
|
|
846
|
+
await expect(
|
|
847
|
+
preflightSponsorship({
|
|
848
|
+
transaction: transaction as any,
|
|
849
|
+
async simulate() {
|
|
850
|
+
simulations += 1
|
|
851
|
+
if (simulations === failingSimulation) throw new Error('simulation failed')
|
|
852
|
+
},
|
|
853
|
+
async complete() {
|
|
854
|
+
completions += 1
|
|
855
|
+
return { feePayer: swapTokenOut, transaction: transaction as any }
|
|
856
|
+
},
|
|
857
|
+
}),
|
|
858
|
+
).rejects.toThrow('simulation failed')
|
|
859
|
+
|
|
860
|
+
expect(completions).toBe(completeCalls)
|
|
861
|
+
expect(simulations).toBe(simulationCalls)
|
|
862
|
+
},
|
|
863
|
+
)
|
|
771
864
|
})
|
|
772
865
|
|
|
773
866
|
describe('prepareSponsoredTransaction', () => {
|
|
@@ -228,7 +228,7 @@ export async function fillHostedFeePayerTransaction(parameters: {
|
|
|
228
228
|
...(filled.feePayerSignature as Record<string, unknown>),
|
|
229
229
|
yParity: Number((filled.feePayerSignature as { yParity?: unknown }).yParity),
|
|
230
230
|
}
|
|
231
|
-
const feeToken = filled.feeToken
|
|
231
|
+
const feeToken = filled.feeToken as TempoAddress.Address
|
|
232
232
|
|
|
233
233
|
// Recover the concrete sponsor address so the simulation can use a concrete
|
|
234
234
|
// `feePayer` (the node rejects `eth_call` with `feePayer: true`).
|
|
@@ -266,7 +266,14 @@ export async function fillHostedFeePayerTransaction(parameters: {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
/**
|
|
269
|
+
/**
|
|
270
|
+
* Returns a transaction shape suitable for pre-broadcast simulation.
|
|
271
|
+
*
|
|
272
|
+
* Sponsored transactions are first simulated as calls from the sender with no
|
|
273
|
+
* fee fields or signatures. This checks call execution without requiring the
|
|
274
|
+
* sender to hold the fee token; transferred value and call-level balances are
|
|
275
|
+
* still checked by the RPC.
|
|
276
|
+
*/
|
|
270
277
|
export function simulationTransaction(
|
|
271
278
|
transaction: SponsoredTransaction,
|
|
272
279
|
options: { feePayer: boolean },
|
|
@@ -284,6 +291,61 @@ export function simulationTransaction(
|
|
|
284
291
|
}
|
|
285
292
|
}
|
|
286
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Returns the final fee-sponsored transaction shape for pre-broadcast
|
|
296
|
+
* simulation. RPC `eth_call` does not carry either transaction signature.
|
|
297
|
+
*/
|
|
298
|
+
export function sponsoredSimulationTransaction(
|
|
299
|
+
transaction: SponsoredTransaction,
|
|
300
|
+
options: { feePayer: TempoAddress.Address; feeToken?: TempoAddress.Address | undefined },
|
|
301
|
+
) {
|
|
302
|
+
const { feePayer, feeToken } = options
|
|
303
|
+
return {
|
|
304
|
+
...transaction,
|
|
305
|
+
account: transaction.from,
|
|
306
|
+
calls: transaction.calls,
|
|
307
|
+
feePayer,
|
|
308
|
+
feePayerSignature: undefined,
|
|
309
|
+
...(feeToken !== undefined ? { feeToken } : {}),
|
|
310
|
+
signature: undefined,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** A completed sponsorship envelope that can be simulated before signing or broadcast. */
|
|
315
|
+
export type PreflightSponsorship = {
|
|
316
|
+
feePayer: TempoAddress.Address
|
|
317
|
+
feeToken?: TempoAddress.Address | undefined
|
|
318
|
+
transaction: SponsoredTransaction
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Runs execution-only sender and final-envelope simulations around sponsorship.
|
|
323
|
+
*
|
|
324
|
+
* First, it simulates the calls with the sender as `from`, omitting fee fields
|
|
325
|
+
* and signatures so the sender's fee balance is irrelevant. Next, `complete`
|
|
326
|
+
* resolves the sponsor and produces the co-signed transaction. Finally, it
|
|
327
|
+
* simulates that transaction with its concrete fee payer and fee token.
|
|
328
|
+
*
|
|
329
|
+
* `complete` runs only after sender-context execution succeeds, so a reverting
|
|
330
|
+
* transaction never reaches a local signer or hosted fee-payer.
|
|
331
|
+
*/
|
|
332
|
+
export async function preflightSponsorship<sponsorship extends PreflightSponsorship>(parameters: {
|
|
333
|
+
complete: () => Promise<sponsorship>
|
|
334
|
+
simulate: (request: Record<string, unknown>) => Promise<unknown>
|
|
335
|
+
transaction: SponsoredTransaction
|
|
336
|
+
}): Promise<sponsorship> {
|
|
337
|
+
const { complete, simulate, transaction } = parameters
|
|
338
|
+
await simulate(simulationTransaction(transaction, { feePayer: true }))
|
|
339
|
+
const completed = await complete()
|
|
340
|
+
await simulate(
|
|
341
|
+
sponsoredSimulationTransaction(completed.transaction, {
|
|
342
|
+
feePayer: completed.feePayer,
|
|
343
|
+
feeToken: completed.feeToken,
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
return completed
|
|
347
|
+
}
|
|
348
|
+
|
|
287
349
|
/**
|
|
288
350
|
* maxTotalFee must be high enough to cover `transferWithMemo` and
|
|
289
351
|
* swap transactions at peak gas prices. Bumped from 0.01 ETH in #327.
|
|
@@ -77,6 +77,43 @@ describe.runIf(isLocalnet)('resolveFeeToken', () => {
|
|
|
77
77
|
expectAddress(feeToken, asset)
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
test('prioritizes funded allowed candidates', async () => {
|
|
81
|
+
const account = testAccount()
|
|
82
|
+
const client = clientFor(account)
|
|
83
|
+
await fundAccount({ address: account.address, token: asset })
|
|
84
|
+
await fundAccount({ address: account.address, token: Addresses.pathUsd })
|
|
85
|
+
await Actions.fee.setUserTokenSync(client, {
|
|
86
|
+
feeToken: asset,
|
|
87
|
+
token: asset,
|
|
88
|
+
} as never)
|
|
89
|
+
|
|
90
|
+
const feeToken = await resolveFeeToken({
|
|
91
|
+
account: account.address,
|
|
92
|
+
allowedTokens: [Addresses.pathUsd, asset],
|
|
93
|
+
candidateTokens: [Addresses.pathUsd, asset],
|
|
94
|
+
client,
|
|
95
|
+
prioritizeCandidates: true,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expectAddress(feeToken, Addresses.pathUsd)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('falls through prioritized candidates when pathUSD is unfunded', async () => {
|
|
102
|
+
const account = testAccount()
|
|
103
|
+
const client = clientFor(account)
|
|
104
|
+
await fundAccount({ address: account.address, token: asset })
|
|
105
|
+
|
|
106
|
+
const feeToken = await resolveFeeToken({
|
|
107
|
+
account: account.address,
|
|
108
|
+
allowedTokens: [Addresses.pathUsd, asset],
|
|
109
|
+
candidateTokens: [Addresses.pathUsd, asset],
|
|
110
|
+
client,
|
|
111
|
+
prioritizeCandidates: true,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expectAddress(feeToken, asset)
|
|
115
|
+
})
|
|
116
|
+
|
|
80
117
|
test('uses a funded chain fee token when configured', async () => {
|
|
81
118
|
const account = testAccount()
|
|
82
119
|
const client = createClient({
|
|
@@ -4,8 +4,17 @@ import { Actions, TokenId } from 'viem/tempo'
|
|
|
4
4
|
import * as TempoAddress from './address.js'
|
|
5
5
|
import * as defaults from './defaults.js'
|
|
6
6
|
|
|
7
|
-
function pushUnique(
|
|
7
|
+
function pushUnique(
|
|
8
|
+
tokens: Address[],
|
|
9
|
+
token: Address | undefined,
|
|
10
|
+
allowedTokens?: readonly Address[] | undefined,
|
|
11
|
+
) {
|
|
8
12
|
if (!token) return
|
|
13
|
+
if (
|
|
14
|
+
allowedTokens &&
|
|
15
|
+
!allowedTokens.some((allowedToken) => TempoAddress.isEqual(allowedToken, token))
|
|
16
|
+
)
|
|
17
|
+
return
|
|
9
18
|
if (tokens.some((t) => TempoAddress.isEqual(t, token))) return
|
|
10
19
|
tokens.push(token)
|
|
11
20
|
}
|
|
@@ -27,21 +36,33 @@ function getChainFeeToken(client: Client): Address | undefined {
|
|
|
27
36
|
return chainId ? defaults.currency[chainId as keyof typeof defaults.currency] : undefined
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a funded fee token from account, chain, and caller-supplied preferences.
|
|
41
|
+
*
|
|
42
|
+
* `prioritizeCandidates` checks candidate tokens before account and chain
|
|
43
|
+
* preferences. `allowedTokens` limits every preference to the caller's policy.
|
|
44
|
+
*/
|
|
30
45
|
export async function resolveFeeToken(parameters: {
|
|
31
46
|
account: Address
|
|
47
|
+
allowedTokens?: readonly Address[] | undefined
|
|
32
48
|
candidateTokens?: readonly Address[] | undefined
|
|
33
49
|
client: Client
|
|
50
|
+
prioritizeCandidates?: boolean | undefined
|
|
34
51
|
}): Promise<Address | undefined> {
|
|
35
|
-
const { account, candidateTokens, client } = parameters
|
|
52
|
+
const { account, allowedTokens, candidateTokens, client, prioritizeCandidates } = parameters
|
|
36
53
|
const tokens: Address[] = []
|
|
37
54
|
|
|
55
|
+
if (prioritizeCandidates)
|
|
56
|
+
for (const token of candidateTokens ?? []) pushUnique(tokens, token, allowedTokens)
|
|
57
|
+
|
|
38
58
|
const userToken = await Actions.fee
|
|
39
59
|
.getUserToken(client as never, { account })
|
|
40
60
|
.then((token) => token?.address as Address | undefined)
|
|
41
61
|
.catch(() => undefined)
|
|
42
|
-
pushUnique(tokens, userToken)
|
|
43
|
-
pushUnique(tokens, getChainFeeToken(client))
|
|
44
|
-
|
|
62
|
+
pushUnique(tokens, userToken, allowedTokens)
|
|
63
|
+
pushUnique(tokens, getChainFeeToken(client), allowedTokens)
|
|
64
|
+
if (!prioritizeCandidates)
|
|
65
|
+
for (const token of candidateTokens ?? []) pushUnique(tokens, token, allowedTokens)
|
|
45
66
|
|
|
46
67
|
for (const token of tokens) {
|
|
47
68
|
if (await hasBalance(client, account, token)) return token
|
|
@@ -340,6 +340,27 @@ describe.runIf(isLocalnet)('tryRecoverChannel', () => {
|
|
|
340
340
|
expect(result!.opened).toBe(true)
|
|
341
341
|
expect(result!.escrowContract).toBe(escrow)
|
|
342
342
|
expect(result!.chainId).toBe(chain.id)
|
|
343
|
+
// Channel opened with a zero authorizedSigner: the escrow verifies
|
|
344
|
+
// vouchers against the payer, so the recovered authority is the payer.
|
|
345
|
+
expect(result!.authorizedSigner.toLowerCase()).toBe(payer.address.toLowerCase())
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('recovers the on-chain authorizedSigner when set', async () => {
|
|
349
|
+
const authorizedSigner = accounts[4].address
|
|
350
|
+
const { channelId: signerChannelId } = await openChannel({
|
|
351
|
+
escrow,
|
|
352
|
+
payer,
|
|
353
|
+
payee,
|
|
354
|
+
token: currency,
|
|
355
|
+
deposit: 10_000_000n,
|
|
356
|
+
salt: Hex.random(32) as `0x${string}`,
|
|
357
|
+
authorizedSigner,
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const result = await tryRecoverChannel(client, escrow, signerChannelId, chain.id)
|
|
361
|
+
|
|
362
|
+
expect(result).not.toBeUndefined()
|
|
363
|
+
expect(result!.authorizedSigner.toLowerCase()).toBe(authorizedSigner.toLowerCase())
|
|
343
364
|
})
|
|
344
365
|
|
|
345
366
|
test('returns undefined for non-existent channel', async () => {
|
|
@@ -27,6 +27,8 @@ import { signVoucher } from '../session/Voucher.js'
|
|
|
27
27
|
|
|
28
28
|
/** Cached channel metadata used by the legacy auto-driving session client. */
|
|
29
29
|
export type ChannelEntry = {
|
|
30
|
+
/** Voucher authority the channel was opened with. */
|
|
31
|
+
authorizedSigner: Address
|
|
30
32
|
/** Legacy contract-backed channel ID. */
|
|
31
33
|
channelId: Hex.Hex
|
|
32
34
|
/** Salt used to derive the channel ID. */
|
|
@@ -209,6 +211,7 @@ export async function createOpenPayload(
|
|
|
209
211
|
|
|
210
212
|
return {
|
|
211
213
|
entry: {
|
|
214
|
+
authorizedSigner,
|
|
212
215
|
channelId,
|
|
213
216
|
salt,
|
|
214
217
|
cumulativeAmount: initialAmount,
|
|
@@ -249,6 +252,10 @@ export async function tryRecoverChannel(
|
|
|
249
252
|
|
|
250
253
|
if (onChain.deposit > 0n && !onChain.finalized) {
|
|
251
254
|
return {
|
|
255
|
+
// A zero on-chain authorizedSigner means the escrow verifies vouchers
|
|
256
|
+
// against the payer, so record the payer as the voucher authority.
|
|
257
|
+
authorizedSigner:
|
|
258
|
+
BigInt(onChain.authorizedSigner) === 0n ? onChain.payer : onChain.authorizedSigner,
|
|
252
259
|
channelId,
|
|
253
260
|
salt: '0x' as Hex.Hex,
|
|
254
261
|
cumulativeAmount: onChain.settled,
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { SignatureEnvelope } from 'ox/tempo'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type Address,
|
|
4
|
+
createClient,
|
|
5
|
+
custom,
|
|
6
|
+
decodeFunctionData,
|
|
7
|
+
erc20Abi,
|
|
8
|
+
type Hex,
|
|
9
|
+
http,
|
|
10
|
+
} from 'viem'
|
|
3
11
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
4
12
|
import { Account as TempoAccount, Addresses, Transaction, WebCryptoP256 } from 'viem/tempo'
|
|
13
|
+
import { tempoModerato } from 'viem/tempo/chains'
|
|
5
14
|
import { beforeAll, describe, expect, test } from 'vp/test'
|
|
6
15
|
import { tempoNetwork } from '~test/config.js'
|
|
7
16
|
import { deployEscrow, openChannel } from '~test/tempo/legacy/session.js'
|
|
@@ -374,6 +383,98 @@ describe('session (pure)', () => {
|
|
|
374
383
|
expect(cred.source).toBe(`did:pkh:eip155:42431:${pureAccount.address}`)
|
|
375
384
|
})
|
|
376
385
|
})
|
|
386
|
+
|
|
387
|
+
describe('auto-manage channel reuse', () => {
|
|
388
|
+
const payerAccount = privateKeyToAccount(
|
|
389
|
+
'0x59c6995e998f97a5a0044966f09453863d462d2b3f1446a99f0a3d7b5d0f5a0d',
|
|
390
|
+
)
|
|
391
|
+
const signerA = TempoAccount.fromSecp256k1(
|
|
392
|
+
'0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a',
|
|
393
|
+
{ access: payerAccount },
|
|
394
|
+
)
|
|
395
|
+
const signerB = TempoAccount.fromSecp256k1(
|
|
396
|
+
'0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6',
|
|
397
|
+
{ access: payerAccount },
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
function makeAutoClient(account: typeof signerA) {
|
|
401
|
+
return createClient({
|
|
402
|
+
account,
|
|
403
|
+
chain: tempoModerato,
|
|
404
|
+
transport: custom({
|
|
405
|
+
async request({ method }: { method: string }) {
|
|
406
|
+
if (method === 'eth_chainId') return `0x${tempoModerato.id.toString(16)}`
|
|
407
|
+
if (method === 'eth_fillTransaction')
|
|
408
|
+
return {
|
|
409
|
+
raw: '0x',
|
|
410
|
+
tx: {
|
|
411
|
+
chainId: `0x${tempoModerato.id.toString(16)}`,
|
|
412
|
+
from: payerAccount.address,
|
|
413
|
+
gas: '0x5208',
|
|
414
|
+
input: '0x',
|
|
415
|
+
maxFeePerGas: '0x1',
|
|
416
|
+
maxPriorityFeePerGas: '0x1',
|
|
417
|
+
nonce: '0x0',
|
|
418
|
+
to: escrowAddress,
|
|
419
|
+
type: '0x2',
|
|
420
|
+
value: '0x0',
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
throw new Error(`unexpected rpc request: ${method}`)
|
|
424
|
+
},
|
|
425
|
+
}),
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
test('reuses the cached channel while the voucher signer is unchanged', async () => {
|
|
430
|
+
const method = session({
|
|
431
|
+
getClient: () => makeAutoClient(signerA),
|
|
432
|
+
maxDeposit: '10',
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const first = deserializePayload(
|
|
436
|
+
await method.createCredential({ challenge: makeChallenge(), context: {} }),
|
|
437
|
+
)
|
|
438
|
+
const second = deserializePayload(
|
|
439
|
+
await method.createCredential({ challenge: makeChallenge(), context: {} }),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
expect(first.payload.action).toBe('open')
|
|
443
|
+
expect(second.payload.action).toBe('voucher')
|
|
444
|
+
expect(second.payload.channelId).toBe(first.payload.channelId)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('opens a new channel instead of reusing when the authorizedSigner changes', async () => {
|
|
448
|
+
let account = signerA
|
|
449
|
+
const method = session({
|
|
450
|
+
getClient: () => makeAutoClient(account),
|
|
451
|
+
maxDeposit: '10',
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const first = deserializePayload(
|
|
455
|
+
await method.createCredential({ challenge: makeChallenge(), context: {} }),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
account = signerB
|
|
459
|
+
|
|
460
|
+
const second = deserializePayload(
|
|
461
|
+
await method.createCredential({ challenge: makeChallenge(), context: {} }),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
expect(first.payload.action).toBe('open')
|
|
465
|
+
if (first.payload.action !== 'open') throw new Error('expected open payload')
|
|
466
|
+
expect(first.payload.authorizedSigner?.toLowerCase()).toBe(
|
|
467
|
+
signerA.accessKeyAddress.toLowerCase(),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
expect(second.payload.action).toBe('open')
|
|
471
|
+
if (second.payload.action !== 'open') throw new Error('expected open payload')
|
|
472
|
+
expect(second.payload.authorizedSigner?.toLowerCase()).toBe(
|
|
473
|
+
signerB.accessKeyAddress.toLowerCase(),
|
|
474
|
+
)
|
|
475
|
+
expect(second.payload.channelId).not.toBe(first.payload.channelId)
|
|
476
|
+
})
|
|
477
|
+
})
|
|
377
478
|
})
|
|
378
479
|
|
|
379
480
|
describe.runIf(isLocalnet)('session (on-chain)', () => {
|
|
@@ -175,6 +175,16 @@ export function session(parameters: session.Parameters = {}) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// A channel only admits vouchers from the authorizedSigner it was opened
|
|
179
|
+
// with, so a cached or recovered entry stops being reusable once the
|
|
180
|
+
// resolved signer changes. Fall through to open a fresh channel instead
|
|
181
|
+
// of emitting a voucher the escrow would reject.
|
|
182
|
+
if (
|
|
183
|
+
entry?.opened &&
|
|
184
|
+
entry.authorizedSigner.toLowerCase() !== getAccountSignerAddress(voucherSigner).toLowerCase()
|
|
185
|
+
)
|
|
186
|
+
entry = undefined
|
|
187
|
+
|
|
178
188
|
let payload: LegacySessionCredentialPayload
|
|
179
189
|
|
|
180
190
|
if (entry?.opened) {
|
|
@@ -457,7 +457,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
457
457
|
).rejects.toThrow('gas exceeds sponsor policy')
|
|
458
458
|
})
|
|
459
459
|
|
|
460
|
-
test('fee-payer: simulates open before broadcasting', async () => {
|
|
460
|
+
test('fee-payer: simulates open before broadcasting and recovers duplicate opens', async () => {
|
|
461
461
|
const rpcMethods: string[] = []
|
|
462
462
|
const interceptingClient = createClient({
|
|
463
463
|
account: accounts[0],
|
|
@@ -521,6 +521,22 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
521
521
|
expect(broadcastIndex).toBeGreaterThan(-1)
|
|
522
522
|
expect(simulationIndex).toBeGreaterThan(-1)
|
|
523
523
|
expect(simulationIndex).toBeLessThan(broadcastIndex)
|
|
524
|
+
expect(
|
|
525
|
+
rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'),
|
|
526
|
+
).toHaveLength(2)
|
|
527
|
+
|
|
528
|
+
const recovered = await broadcastOpenTransaction({
|
|
529
|
+
client: interceptingClient,
|
|
530
|
+
serializedTransaction,
|
|
531
|
+
escrowContract,
|
|
532
|
+
channelId,
|
|
533
|
+
recipient,
|
|
534
|
+
currency,
|
|
535
|
+
feePayer: accounts[0],
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
expect(recovered.txHash).toBeUndefined()
|
|
539
|
+
expect(recovered.onChain.deposit).toBe(deposit)
|
|
524
540
|
})
|
|
525
541
|
|
|
526
542
|
test('fee-payer: rejects smuggled second open call', async () => {
|
|
@@ -620,6 +636,17 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
620
636
|
})
|
|
621
637
|
|
|
622
638
|
test('waitForConfirmation: false returns derived on-chain state', async () => {
|
|
639
|
+
const rpcMethods: string[] = []
|
|
640
|
+
const interceptingClient = createClient({
|
|
641
|
+
account: accounts[0],
|
|
642
|
+
chain: client.chain,
|
|
643
|
+
transport: custom({
|
|
644
|
+
async request(args: any) {
|
|
645
|
+
rpcMethods.push(args.method)
|
|
646
|
+
return client.transport.request(args)
|
|
647
|
+
},
|
|
648
|
+
}),
|
|
649
|
+
})
|
|
623
650
|
const salt = nextSalt()
|
|
624
651
|
const deposit = 10_000_000n
|
|
625
652
|
|
|
@@ -633,7 +660,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
633
660
|
})
|
|
634
661
|
|
|
635
662
|
const result = await broadcastOpenTransaction({
|
|
636
|
-
client,
|
|
663
|
+
client: interceptingClient,
|
|
637
664
|
serializedTransaction,
|
|
638
665
|
escrowContract,
|
|
639
666
|
channelId,
|
|
@@ -649,6 +676,52 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
649
676
|
expect(result.onChain.deposit).toBe(deposit)
|
|
650
677
|
expect(result.onChain.settled).toBe(0n)
|
|
651
678
|
expect(result.onChain.finalized).toBe(false)
|
|
679
|
+
const simulationIndex = rpcMethods.indexOf('eth_call')
|
|
680
|
+
const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransaction')
|
|
681
|
+
expect(simulationIndex).toBeGreaterThan(-1)
|
|
682
|
+
expect(broadcastIndex).toBeGreaterThan(-1)
|
|
683
|
+
expect(simulationIndex).toBeLessThan(broadcastIndex)
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
test('fee-payer relay: optimistic open simulates before broadcasting', async () => {
|
|
687
|
+
const rpcMethods: string[] = []
|
|
688
|
+
const interceptingClient = createClient({
|
|
689
|
+
account: accounts[0],
|
|
690
|
+
chain: client.chain,
|
|
691
|
+
transport: custom({
|
|
692
|
+
async request(args: any) {
|
|
693
|
+
rpcMethods.push(args.method)
|
|
694
|
+
return client.transport.request(args)
|
|
695
|
+
},
|
|
696
|
+
}),
|
|
697
|
+
})
|
|
698
|
+
const salt = nextSalt()
|
|
699
|
+
const deposit = 10_000_000n
|
|
700
|
+
const { channelId, serializedTransaction } = await signOpenChannel({
|
|
701
|
+
escrow: escrowContract,
|
|
702
|
+
payer,
|
|
703
|
+
payee: recipient,
|
|
704
|
+
token: currency,
|
|
705
|
+
deposit,
|
|
706
|
+
salt,
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
await broadcastOpenTransaction({
|
|
710
|
+
client: interceptingClient,
|
|
711
|
+
serializedTransaction,
|
|
712
|
+
escrowContract,
|
|
713
|
+
channelId,
|
|
714
|
+
recipient,
|
|
715
|
+
currency,
|
|
716
|
+
isSponsored: true,
|
|
717
|
+
waitForConfirmation: false,
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const simulationIndex = rpcMethods.indexOf('eth_call')
|
|
721
|
+
const broadcastIndex = rpcMethods.indexOf('eth_sendRawTransaction')
|
|
722
|
+
expect(simulationIndex).toBeGreaterThan(-1)
|
|
723
|
+
expect(broadcastIndex).toBeGreaterThan(-1)
|
|
724
|
+
expect(simulationIndex).toBeLessThan(broadcastIndex)
|
|
652
725
|
})
|
|
653
726
|
})
|
|
654
727
|
|
|
@@ -1034,6 +1107,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
|
|
|
1034
1107
|
expect(broadcastIndex).toBeGreaterThan(-1)
|
|
1035
1108
|
expect(simulationIndex).toBeGreaterThan(-1)
|
|
1036
1109
|
expect(simulationIndex).toBeLessThan(broadcastIndex)
|
|
1110
|
+
expect(
|
|
1111
|
+
rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'),
|
|
1112
|
+
).toHaveLength(2)
|
|
1037
1113
|
})
|
|
1038
1114
|
|
|
1039
1115
|
test('fee-payer: rejects smuggled second topUp call', async () => {
|