mppx 0.8.0 → 0.8.2
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 +18 -0
- package/dist/Errors.d.ts +13 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +12 -0
- package/dist/Errors.js.map +1 -1
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +35 -4
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/internal.d.ts +1 -0
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js +1 -15
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts +15 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +124 -14
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +2 -1
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/Transport.d.ts.map +1 -1
- package/dist/client/Transport.js +11 -12
- package/dist/client/Transport.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +55 -50
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/protocols/Mcp.d.ts +6 -0
- package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
- package/dist/client/internal/protocols/Mcp.js +28 -10
- package/dist/client/internal/protocols/Mcp.js.map +1 -1
- package/dist/client/internal/protocols/X402.d.ts.map +1 -1
- package/dist/client/internal/protocols/X402.js +18 -12
- package/dist/client/internal/protocols/X402.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +17 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +105 -25
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +4 -0
- package/dist/tempo/server/Charge.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/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +47 -5
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.d.ts +6 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.js +13 -0
- package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
- package/dist/x402/Header.d.ts +10 -1
- package/dist/x402/Header.d.ts.map +1 -1
- package/dist/x402/Header.js +27 -1
- package/dist/x402/Header.js.map +1 -1
- package/package.json +3 -3
- package/src/Errors.test.ts +28 -0
- package/src/Errors.ts +19 -0
- package/src/cli/cli.test.ts +194 -0
- package/src/cli/cli.ts +43 -7
- package/src/cli/config.test.ts +13 -7
- package/src/cli/internal.ts +1 -16
- package/src/cli/plugins/tempo.ts +160 -17
- package/src/client/Mppx.test-d.ts +9 -0
- package/src/client/Mppx.test.ts +41 -0
- package/src/client/Mppx.ts +4 -0
- package/src/client/Transport.test.ts +124 -0
- package/src/client/Transport.ts +11 -9
- package/src/client/internal/Fetch.test-d.ts +9 -0
- package/src/client/internal/Fetch.test.ts +186 -4
- package/src/client/internal/Fetch.ts +85 -76
- package/src/client/internal/protocols/Mcp.ts +39 -15
- package/src/client/internal/protocols/X402.ts +9 -5
- package/src/server/Mppx.test.ts +3 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/internal/fee-payer.test.ts +130 -0
- package/src/tempo/internal/fee-payer.ts +157 -36
- package/src/tempo/server/Charge.ts +4 -0
- package/src/tempo/server/internal/html/package.json +2 -2
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/SessionManager.test.ts +57 -4
- package/src/tempo/session/client/SessionManager.ts +50 -6
- package/src/tempo/session/server/CredentialVerification.test.ts +36 -0
- package/src/tempo/session/server/CredentialVerification.ts +18 -0
- package/src/x402/Header.ts +45 -0
package/src/cli/cli.test.ts
CHANGED
|
@@ -31,6 +31,7 @@ import cli from './cli.js'
|
|
|
31
31
|
const testPrivateKey = generatePrivateKey()
|
|
32
32
|
const testAccount = privateKeyToAccount(testPrivateKey)
|
|
33
33
|
const cliTestXdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-cli-xdg-'))
|
|
34
|
+
const unfundedToken = '0x20c0000000000000000000000000000000000002' as Address
|
|
34
35
|
const cliSessionFeePayerPolicy = {
|
|
35
36
|
maxGas: 2_250_000n,
|
|
36
37
|
maxTotalFee: 60_000_000_000_000_000n,
|
|
@@ -468,6 +469,199 @@ describe('basic charge (examples/basic)', () => {
|
|
|
468
469
|
}
|
|
469
470
|
})
|
|
470
471
|
|
|
472
|
+
test(
|
|
473
|
+
'selects a later Tempo charge challenge when wallet has that token balance',
|
|
474
|
+
{ timeout: 120_000 },
|
|
475
|
+
async () => {
|
|
476
|
+
const { Actions } = await import('viem/tempo')
|
|
477
|
+
const payerPrivateKey = generatePrivateKey()
|
|
478
|
+
const payer = privateKeyToAccount(payerPrivateKey)
|
|
479
|
+
await Actions.token.transferSync(client, {
|
|
480
|
+
account: accounts[0],
|
|
481
|
+
chain: client.chain,
|
|
482
|
+
token: asset,
|
|
483
|
+
to: payer.address,
|
|
484
|
+
amount: parseUnits('100', 6),
|
|
485
|
+
})
|
|
486
|
+
await Actions.token.transferSync(client, {
|
|
487
|
+
account: accounts[0],
|
|
488
|
+
chain: client.chain,
|
|
489
|
+
token: Addresses.pathUsd,
|
|
490
|
+
to: payer.address,
|
|
491
|
+
amount: parseUnits('100', 6),
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
const tempoMethod = tempo.charge({ getClient: () => client })
|
|
495
|
+
const server = Mppx_server.create({
|
|
496
|
+
methods: [tempoMethod],
|
|
497
|
+
realm: 'cli-test-balance-aware-offers',
|
|
498
|
+
secretKey: 'cli-test-secret-cli-test-secret-32',
|
|
499
|
+
})
|
|
500
|
+
let authorization: string | undefined
|
|
501
|
+
|
|
502
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
503
|
+
if (req.headers.authorization) {
|
|
504
|
+
authorization = req.headers.authorization
|
|
505
|
+
res.end('paid-from-funded-token')
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const result = await toNodeListener(
|
|
510
|
+
server.compose(
|
|
511
|
+
[
|
|
512
|
+
tempoMethod,
|
|
513
|
+
{
|
|
514
|
+
amount: '1',
|
|
515
|
+
currency: unfundedToken,
|
|
516
|
+
decimals: 6,
|
|
517
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
518
|
+
recipient: accounts[0].address,
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
[
|
|
522
|
+
tempoMethod,
|
|
523
|
+
{
|
|
524
|
+
amount: '1',
|
|
525
|
+
currency: asset,
|
|
526
|
+
decimals: 6,
|
|
527
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
528
|
+
recipient: accounts[0].address,
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
),
|
|
532
|
+
)(req, res)
|
|
533
|
+
if (result.status === 402) return
|
|
534
|
+
res.end('unexpected')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const { output, exitCode } = await serve([httpServer.url, '--rpc-url', rpcUrl, '-s'], {
|
|
539
|
+
env: { MPPX_PRIVATE_KEY: payerPrivateKey },
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
expect(exitCode, output).toBeUndefined()
|
|
543
|
+
expect(output).toContain('paid-from-funded-token')
|
|
544
|
+
expect(Credential.deserialize(authorization!).challenge.request.currency).toBe(asset)
|
|
545
|
+
} finally {
|
|
546
|
+
httpServer.close()
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
test('currency option selects a matching offered challenge', { timeout: 120_000 }, async () => {
|
|
552
|
+
const { Actions } = await import('viem/tempo')
|
|
553
|
+
const payerPrivateKey = generatePrivateKey()
|
|
554
|
+
const payer = privateKeyToAccount(payerPrivateKey)
|
|
555
|
+
await Actions.token.transferSync(client, {
|
|
556
|
+
account: accounts[0],
|
|
557
|
+
chain: client.chain,
|
|
558
|
+
token: asset,
|
|
559
|
+
to: payer.address,
|
|
560
|
+
amount: parseUnits('100', 6),
|
|
561
|
+
})
|
|
562
|
+
await Actions.token.transferSync(client, {
|
|
563
|
+
account: accounts[0],
|
|
564
|
+
chain: client.chain,
|
|
565
|
+
token: Addresses.pathUsd,
|
|
566
|
+
to: payer.address,
|
|
567
|
+
amount: parseUnits('100', 6),
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const tempoMethod = tempo.charge({ getClient: () => client })
|
|
571
|
+
const server = Mppx_server.create({
|
|
572
|
+
methods: [tempoMethod],
|
|
573
|
+
realm: 'cli-test-currency-option',
|
|
574
|
+
secretKey: 'cli-test-secret-cli-test-secret-32',
|
|
575
|
+
})
|
|
576
|
+
let authorization: string | undefined
|
|
577
|
+
|
|
578
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
579
|
+
if (req.headers.authorization) {
|
|
580
|
+
authorization = req.headers.authorization
|
|
581
|
+
res.end('paid-from-currency-option')
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const result = await toNodeListener(
|
|
586
|
+
server.compose(
|
|
587
|
+
[
|
|
588
|
+
tempoMethod,
|
|
589
|
+
{
|
|
590
|
+
amount: '1',
|
|
591
|
+
currency: unfundedToken,
|
|
592
|
+
decimals: 6,
|
|
593
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
594
|
+
recipient: accounts[0].address,
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
[
|
|
598
|
+
tempoMethod,
|
|
599
|
+
{
|
|
600
|
+
amount: '1',
|
|
601
|
+
currency: asset,
|
|
602
|
+
decimals: 6,
|
|
603
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
604
|
+
recipient: accounts[0].address,
|
|
605
|
+
},
|
|
606
|
+
],
|
|
607
|
+
),
|
|
608
|
+
)(req, res)
|
|
609
|
+
if (result.status === 402) return
|
|
610
|
+
res.end('unexpected')
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
const { output, exitCode } = await serve(
|
|
615
|
+
[httpServer.url, '--rpc-url', rpcUrl, '--currency', asset, '-s'],
|
|
616
|
+
{ env: { MPPX_PRIVATE_KEY: payerPrivateKey } },
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
expect(exitCode, output).toBeUndefined()
|
|
620
|
+
expect(output).toContain('paid-from-currency-option')
|
|
621
|
+
expect(Credential.deserialize(authorization!).challenge.request.currency).toBe(asset)
|
|
622
|
+
} finally {
|
|
623
|
+
httpServer.close()
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('currency option rejects currencies the server did not offer', async () => {
|
|
628
|
+
const tempoMethod = tempo.charge({ getClient: () => client })
|
|
629
|
+
const server = Mppx_server.create({
|
|
630
|
+
methods: [tempoMethod],
|
|
631
|
+
realm: 'cli-test-currency-unavailable',
|
|
632
|
+
secretKey: 'cli-test-secret-cli-test-secret-32',
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
636
|
+
const result = await toNodeListener(
|
|
637
|
+
server.charge({
|
|
638
|
+
amount: '1',
|
|
639
|
+
currency: asset,
|
|
640
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
641
|
+
recipient: accounts[0].address,
|
|
642
|
+
}),
|
|
643
|
+
)(req, res)
|
|
644
|
+
if (result.status === 402) return
|
|
645
|
+
res.end('unexpected')
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
const { output, exitCode } = await serve([
|
|
650
|
+
httpServer.url,
|
|
651
|
+
'--currency',
|
|
652
|
+
Addresses.pathUsd,
|
|
653
|
+
'-s',
|
|
654
|
+
])
|
|
655
|
+
|
|
656
|
+
expect(exitCode).toBe(2)
|
|
657
|
+
expect(output).toContain('UNSUPPORTED_CURRENCY')
|
|
658
|
+
expect(output).toContain(Addresses.pathUsd)
|
|
659
|
+
expect(output).toContain(asset)
|
|
660
|
+
} finally {
|
|
661
|
+
httpServer.close()
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
|
|
471
665
|
test('selects a later supported challenge when the first offer is unsupported', async () => {
|
|
472
666
|
const unsupportedMethod = Method.toServer(createMockChargeMethod('unknown'), {
|
|
473
667
|
async verify() {
|
package/src/cli/cli.ts
CHANGED
|
@@ -16,7 +16,11 @@ import { validate as validateDiscovery } from '../discovery/Validate.js'
|
|
|
16
16
|
import { createDefaultStore, createKeychain, resolveAccountName } from './account.js'
|
|
17
17
|
import { loadConfig, resolveAcceptPayment, selectChallenge } from './internal.js'
|
|
18
18
|
import type { Plugin } from './plugins/plugin.js'
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
orderTempoChargeChallengesByBalance,
|
|
21
|
+
readTempoKeystore,
|
|
22
|
+
resolveTempoAccount,
|
|
23
|
+
} from './plugins/tempo.js'
|
|
20
24
|
import {
|
|
21
25
|
chainName,
|
|
22
26
|
confirm,
|
|
@@ -151,6 +155,18 @@ function formatPayment(payment: unknown): string {
|
|
|
151
155
|
.join(' ')
|
|
152
156
|
}
|
|
153
157
|
|
|
158
|
+
function filterChallengesByCurrency(
|
|
159
|
+
challenges: readonly Challenge.Challenge[],
|
|
160
|
+
currency: string | undefined,
|
|
161
|
+
): Challenge.Challenge[] {
|
|
162
|
+
if (!currency) return [...challenges]
|
|
163
|
+
const normalized = currency.toLowerCase()
|
|
164
|
+
return challenges.filter((challenge) => {
|
|
165
|
+
const offered = challenge.request.currency
|
|
166
|
+
return typeof offered === 'string' && offered.toLowerCase() === normalized
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
async function fetchServicesRegistry(): Promise<ServiceRegistryService[]> {
|
|
155
171
|
const url = process.env.MPPX_SERVICES_URL ?? servicesRegistryUrl
|
|
156
172
|
const response = await globalThis.fetch(url)
|
|
@@ -198,6 +214,7 @@ const cli = Cli.create('mppx', {
|
|
|
198
214
|
autoSwap: z.boolean().optional().describe('Auto-swap source tokens into payment currency'),
|
|
199
215
|
config: z.string().optional().describe('Path to config file'),
|
|
200
216
|
confirm: z.boolean().optional().default(false).describe('Show confirmation prompts'),
|
|
217
|
+
currency: z.string().optional().describe('Payment currency/token address to select'),
|
|
201
218
|
data: z.string().optional().describe('Send request body (implies POST unless -X is set)'),
|
|
202
219
|
fail: z.boolean().optional().describe('Fail silently on HTTP errors (exit 22)'),
|
|
203
220
|
header: z.array(z.string()).optional().describe('Add header (repeatable)'),
|
|
@@ -357,12 +374,31 @@ const cli = Cli.create('mppx', {
|
|
|
357
374
|
return
|
|
358
375
|
}
|
|
359
376
|
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
)
|
|
377
|
+
const methodOpts = parseMethodOpts(c.options.methodOpt)
|
|
378
|
+
const offeredChallenges = Challenge.fromResponseList(challengeResponse)
|
|
379
|
+
const currencyChallenges = filterChallengesByCurrency(offeredChallenges, c.options.currency)
|
|
380
|
+
if (c.options.currency && currencyChallenges.length === 0) {
|
|
381
|
+
const offers = offeredChallenges
|
|
382
|
+
.map((challenge) => challenge.request.currency)
|
|
383
|
+
.filter((currency): currency is string => typeof currency === 'string')
|
|
384
|
+
.join(', ')
|
|
385
|
+
return c.error({
|
|
386
|
+
code: 'UNSUPPORTED_CURRENCY',
|
|
387
|
+
message: `Server did not offer payment currency ${c.options.currency}.${offers ? ` Offered currencies: ${offers}.` : ''}`,
|
|
388
|
+
exitCode: 2,
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
const challenges = await orderTempoChargeChallengesByBalance(currencyChallenges, {
|
|
392
|
+
options: {
|
|
393
|
+
account: c.options.account,
|
|
394
|
+
network: c.options.network,
|
|
395
|
+
rpcUrl: c.options.rpcUrl,
|
|
396
|
+
},
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const selected = selectChallenge(challenges, loaded?.config)
|
|
364
400
|
if (!selected) {
|
|
365
|
-
const offers =
|
|
401
|
+
const offers = challenges
|
|
366
402
|
.map((challenge) => `${challenge.method}/${challenge.intent}`)
|
|
367
403
|
.join(', ')
|
|
368
404
|
return c.error({
|
|
@@ -393,7 +429,7 @@ const cli = Cli.create('mppx', {
|
|
|
393
429
|
rpcUrl: c.options.rpcUrl,
|
|
394
430
|
slippage: c.options.slippage,
|
|
395
431
|
},
|
|
396
|
-
methodOpts
|
|
432
|
+
methodOpts,
|
|
397
433
|
})
|
|
398
434
|
tokenSymbol = pluginResult.tokenSymbol
|
|
399
435
|
tokenDecimals = pluginResult.tokenDecimals
|
package/src/cli/config.test.ts
CHANGED
|
@@ -41,35 +41,41 @@ describe('loadConfig', () => {
|
|
|
41
41
|
expect(result?.path).toBe(configPath)
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test('loads config from explicit config file', async () => {
|
|
45
|
+
const configPath = path.join(tmpDir, 'custom.mjs')
|
|
46
|
+
fs.writeFileSync(configPath, 'export default { methods: [] }')
|
|
47
|
+
const result = await loadConfig(configPath)
|
|
48
|
+
expect(result?.config).toEqual({ methods: [] })
|
|
49
|
+
expect(result?.path).toBe(configPath)
|
|
50
|
+
})
|
|
51
|
+
|
|
44
52
|
test('returns undefined when MPPX_CONFIG points to nonexistent file', async () => {
|
|
45
53
|
vi.stubEnv('MPPX_CONFIG', path.join(tmpDir, 'nonexistent.ts'))
|
|
46
54
|
const config = await loadConfig()
|
|
47
55
|
expect(config).toBeUndefined()
|
|
48
56
|
})
|
|
49
57
|
|
|
50
|
-
test('
|
|
58
|
+
test('ignores mppx.config.mjs from cwd without explicit selection', async () => {
|
|
51
59
|
const configPath = path.join(tmpDir, 'mppx.config.mjs')
|
|
52
60
|
fs.writeFileSync(configPath, 'export default { methods: [] }')
|
|
53
61
|
vi.spyOn(process, 'cwd').mockReturnValue(tmpDir)
|
|
54
62
|
const result = await loadConfig()
|
|
55
|
-
expect(result
|
|
56
|
-
expect(result?.path).toBe(configPath)
|
|
63
|
+
expect(result).toBeUndefined()
|
|
57
64
|
vi.mocked(process.cwd).mockRestore()
|
|
58
65
|
})
|
|
59
66
|
|
|
60
|
-
test('
|
|
67
|
+
test('does not walk up from cwd to find config', async () => {
|
|
61
68
|
const nested = path.join(tmpDir, 'a', 'b', 'c')
|
|
62
69
|
fs.mkdirSync(nested, { recursive: true })
|
|
63
70
|
const configPath = path.join(tmpDir, 'mppx.config.mjs')
|
|
64
71
|
fs.writeFileSync(configPath, 'export default { methods: [] }')
|
|
65
72
|
vi.spyOn(process, 'cwd').mockReturnValue(nested)
|
|
66
73
|
const result = await loadConfig()
|
|
67
|
-
expect(result
|
|
68
|
-
expect(result?.path).toBe(configPath)
|
|
74
|
+
expect(result).toBeUndefined()
|
|
69
75
|
vi.mocked(process.cwd).mockRestore()
|
|
70
76
|
})
|
|
71
77
|
|
|
72
|
-
test('MPPX_CONFIG
|
|
78
|
+
test('MPPX_CONFIG loads even when cwd config exists', async () => {
|
|
73
79
|
fs.writeFileSync(path.join(tmpDir, 'mppx.config.mjs'), 'export default { methods: ["cwd"] }')
|
|
74
80
|
const envConfig = path.join(tmpDir, 'env.mjs')
|
|
75
81
|
fs.writeFileSync(envConfig, 'export default { methods: ["env"] }')
|
package/src/cli/internal.ts
CHANGED
|
@@ -84,8 +84,7 @@ function supportsPlugin(plugin: Plugin, challenge: Challenge.Challenge): boolean
|
|
|
84
84
|
return plugin.supports ? plugin.supports(challenge) : plugin.method === challenge.method
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
/** Loads CLI config only from explicit, user-selected paths. */
|
|
89
88
|
export async function loadConfig(
|
|
90
89
|
configFile?: string | undefined,
|
|
91
90
|
): Promise<{ config: Config; path: string } | undefined> {
|
|
@@ -111,19 +110,5 @@ function resolveConfigPath(configFile?: string | undefined): string | undefined
|
|
|
111
110
|
return undefined
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
// 2. Walk up from cwd, stopping at project root
|
|
115
|
-
let dir = process.cwd()
|
|
116
|
-
while (true) {
|
|
117
|
-
for (const name of CONFIG_NAMES) {
|
|
118
|
-
const candidate = path.join(dir, name)
|
|
119
|
-
if (fs.existsSync(candidate)) return candidate
|
|
120
|
-
}
|
|
121
|
-
const isProjectRoot =
|
|
122
|
-
fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))
|
|
123
|
-
const parent = path.dirname(dir)
|
|
124
|
-
if (isProjectRoot || parent === dir) break
|
|
125
|
-
dir = parent
|
|
126
|
-
}
|
|
127
|
-
|
|
128
113
|
return undefined
|
|
129
114
|
}
|
package/src/cli/plugins/tempo.ts
CHANGED
|
@@ -9,7 +9,9 @@ import { Base64 } from 'ox'
|
|
|
9
9
|
import type { Address } from 'viem'
|
|
10
10
|
import { createClient, http } from 'viem'
|
|
11
11
|
import { privateKeyToAccount } from 'viem/accounts'
|
|
12
|
+
import { Actions } from 'viem/tempo'
|
|
12
13
|
|
|
14
|
+
import type * as Challenge from '../../Challenge.js'
|
|
13
15
|
import { normalizeHeaders } from '../../client/internal/Fetch.js'
|
|
14
16
|
import * as Constants from '../../Constants.js'
|
|
15
17
|
import * as Credential from '../../Credential.js'
|
|
@@ -30,10 +32,88 @@ import {
|
|
|
30
32
|
pc,
|
|
31
33
|
resolveChain,
|
|
32
34
|
resolveRpcUrl,
|
|
35
|
+
type Network,
|
|
33
36
|
} from '../utils.js'
|
|
34
37
|
import { createPlugin, type Plugin } from './plugin.js'
|
|
35
38
|
|
|
36
39
|
const packageJson = createRequire(import.meta.url)('../../../package.json') as { name: string }
|
|
40
|
+
const booleanOption = z.union([
|
|
41
|
+
z.boolean(),
|
|
42
|
+
z.literal('true').transform(() => true),
|
|
43
|
+
z.literal('false').transform(() => false),
|
|
44
|
+
])
|
|
45
|
+
const tempoOptionSchema = z.object({
|
|
46
|
+
autoSwap: z.optional(booleanOption),
|
|
47
|
+
channel: z.optional(z.coerce.string()),
|
|
48
|
+
deposit: z.optional(z.union([z.string(), z.number()])),
|
|
49
|
+
payWith: z.optional(z.string()),
|
|
50
|
+
slippage: z.optional(z.coerce.number()),
|
|
51
|
+
tokenIn: z.optional(z.string()),
|
|
52
|
+
})
|
|
53
|
+
const tempoOptionKeys = [
|
|
54
|
+
'autoSwap',
|
|
55
|
+
'channel',
|
|
56
|
+
'deposit',
|
|
57
|
+
'payWith',
|
|
58
|
+
'slippage',
|
|
59
|
+
'tokenIn',
|
|
60
|
+
] as const
|
|
61
|
+
const tempoChargeChallengeScoreOrder = [
|
|
62
|
+
'payable',
|
|
63
|
+
'unknownBalance',
|
|
64
|
+
'insufficientBalance',
|
|
65
|
+
'unsupported',
|
|
66
|
+
] as const
|
|
67
|
+
|
|
68
|
+
type TempoChargeChallengeScore = (typeof tempoChargeChallengeScoreOrder)[number]
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Orders Tempo charge challenges by whether the configured wallet can satisfy them.
|
|
72
|
+
*
|
|
73
|
+
* Direct balances are preferred first. Unsupported or unpayable challenges keep
|
|
74
|
+
* their original relative order.
|
|
75
|
+
*/
|
|
76
|
+
export async function orderTempoChargeChallengesByBalance(
|
|
77
|
+
challenges: readonly Challenge.Challenge[],
|
|
78
|
+
ctx: {
|
|
79
|
+
options: {
|
|
80
|
+
account?: string | undefined
|
|
81
|
+
network?: Network | undefined
|
|
82
|
+
rpcUrl?: string | undefined
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
): Promise<Challenge.Challenge[]> {
|
|
86
|
+
const tempoPositions: number[] = []
|
|
87
|
+
const tempoChallenges: Challenge.Challenge[] = []
|
|
88
|
+
for (const [index, challenge] of challenges.entries()) {
|
|
89
|
+
if (!isTempoChargeChallenge(challenge)) continue
|
|
90
|
+
tempoPositions.push(index)
|
|
91
|
+
tempoChallenges.push(challenge)
|
|
92
|
+
}
|
|
93
|
+
if (tempoChallenges.length < 2) return [...challenges]
|
|
94
|
+
|
|
95
|
+
const probe = await resolveBalanceProbe(ctx).catch(() => undefined)
|
|
96
|
+
if (!probe) return [...challenges]
|
|
97
|
+
|
|
98
|
+
const scored = await Promise.all(
|
|
99
|
+
tempoChallenges.map(async (challenge, index) => ({
|
|
100
|
+
challenge,
|
|
101
|
+
index,
|
|
102
|
+
score: await scoreTempoChargeChallenge(challenge, probe),
|
|
103
|
+
})),
|
|
104
|
+
)
|
|
105
|
+
scored.sort(
|
|
106
|
+
(left, right) =>
|
|
107
|
+
tempoChargeChallengeScoreOrder.indexOf(left.score) -
|
|
108
|
+
tempoChargeChallengeScoreOrder.indexOf(right.score) || left.index - right.index,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const ordered = [...challenges]
|
|
112
|
+
for (const [index, position] of tempoPositions.entries()) {
|
|
113
|
+
ordered[position] = scored[index]!.challenge
|
|
114
|
+
}
|
|
115
|
+
return ordered
|
|
116
|
+
}
|
|
37
117
|
|
|
38
118
|
export function tempo() {
|
|
39
119
|
let _session:
|
|
@@ -60,23 +140,7 @@ export function tempo() {
|
|
|
60
140
|
const accountName = resolveAccountName(options.account)
|
|
61
141
|
const challengeRequest = challenge.request as Record<string, unknown>
|
|
62
142
|
const currency = challengeRequest.currency as string | undefined
|
|
63
|
-
const
|
|
64
|
-
z.boolean(),
|
|
65
|
-
z.literal('true').transform(() => true),
|
|
66
|
-
z.literal('false').transform(() => false),
|
|
67
|
-
])
|
|
68
|
-
const tempoOpts = parseOptions(
|
|
69
|
-
z.object({
|
|
70
|
-
autoSwap: z.optional(booleanOption),
|
|
71
|
-
channel: z.optional(z.coerce.string()),
|
|
72
|
-
deposit: z.optional(z.union([z.string(), z.number()])),
|
|
73
|
-
payWith: z.optional(z.string()),
|
|
74
|
-
slippage: z.optional(z.coerce.number()),
|
|
75
|
-
tokenIn: z.optional(z.string()),
|
|
76
|
-
}),
|
|
77
|
-
methodOpts,
|
|
78
|
-
['autoSwap', 'channel', 'deposit', 'payWith', 'slippage', 'tokenIn'],
|
|
79
|
-
)
|
|
143
|
+
const tempoOpts = parseTempoOptions(methodOpts)
|
|
80
144
|
const autoSwap = resolveAutoSwap({
|
|
81
145
|
autoSwap: tempoOpts.autoSwap ?? options.autoSwap,
|
|
82
146
|
payWith: tempoOpts.payWith ?? options.payWith,
|
|
@@ -746,6 +810,85 @@ function detectTerminalBg(
|
|
|
746
810
|
|
|
747
811
|
// --- Account helpers ---
|
|
748
812
|
|
|
813
|
+
type BalanceProbe = {
|
|
814
|
+
account: Address
|
|
815
|
+
client: ReturnType<typeof createClient>
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function parseTempoOptions(rawOptions: Record<string, string>) {
|
|
819
|
+
return parseOptions(tempoOptionSchema, rawOptions, tempoOptionKeys)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function isTempoChargeChallenge(challenge: Challenge.Challenge): boolean {
|
|
823
|
+
return challenge.method === 'tempo' && challenge.intent === 'charge'
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function resolveBalanceProbe(ctx: {
|
|
827
|
+
options: {
|
|
828
|
+
account?: string | undefined
|
|
829
|
+
network?: Network | undefined
|
|
830
|
+
rpcUrl?: string | undefined
|
|
831
|
+
}
|
|
832
|
+
}): Promise<BalanceProbe | undefined> {
|
|
833
|
+
const accountName = resolveAccountName(ctx.options.account)
|
|
834
|
+
const privateKey =
|
|
835
|
+
process.env.MPPX_PRIVATE_KEY?.trim() ||
|
|
836
|
+
(isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
|
|
837
|
+
|
|
838
|
+
let account: Address | undefined
|
|
839
|
+
if (privateKey) {
|
|
840
|
+
account = privateKeyToAccount(privateKey as `0x${string}`).address
|
|
841
|
+
} else if (isTempoAccount(accountName) && hasTempoCliSync()) {
|
|
842
|
+
account = resolveTempoAccount(accountName)?.wallet_address as Address | undefined
|
|
843
|
+
} else {
|
|
844
|
+
const fallback = fallbackFromTempo()
|
|
845
|
+
if (fallback) {
|
|
846
|
+
const fallbackKey = await createKeychain(fallback).get()
|
|
847
|
+
if (fallbackKey) account = privateKeyToAccount(fallbackKey as `0x${string}`).address
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (!account) return undefined
|
|
851
|
+
|
|
852
|
+
const rpcUrl = resolveRpcUrl(ctx.options.rpcUrl, { network: ctx.options.network })
|
|
853
|
+
const client = createClient({
|
|
854
|
+
chain: await resolveChain({ network: ctx.options.network, rpcUrl }),
|
|
855
|
+
transport: http(rpcUrl),
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
account,
|
|
860
|
+
client,
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function scoreTempoChargeChallenge(
|
|
865
|
+
challenge: Challenge.Challenge,
|
|
866
|
+
probe: BalanceProbe,
|
|
867
|
+
): Promise<TempoChargeChallengeScore> {
|
|
868
|
+
const request = challenge.request as Record<string, unknown>
|
|
869
|
+
const currency = request.currency
|
|
870
|
+
const amount = request.amount
|
|
871
|
+
if (typeof currency !== 'string' || typeof amount !== 'string') return 'unsupported'
|
|
872
|
+
|
|
873
|
+
const requiredChainId = (request.methodDetails as { chainId?: number } | undefined)?.chainId
|
|
874
|
+
if (requiredChainId && probe.client.chain?.id && requiredChainId !== probe.client.chain.id) {
|
|
875
|
+
return 'unsupported'
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const amountRaw = BigInt(amount)
|
|
879
|
+
if (amountRaw === 0n) return 'payable'
|
|
880
|
+
|
|
881
|
+
const balance = await Actions.token
|
|
882
|
+
.getBalance(probe.client as never, {
|
|
883
|
+
account: probe.account,
|
|
884
|
+
token: currency as Address,
|
|
885
|
+
})
|
|
886
|
+
.catch(() => undefined)
|
|
887
|
+
if (balance === undefined) return 'unknownBalance'
|
|
888
|
+
if (balance >= amountRaw) return 'payable'
|
|
889
|
+
return 'insufficientBalance'
|
|
890
|
+
}
|
|
891
|
+
|
|
749
892
|
function parseOptions<const schema extends z.ZodType>(
|
|
750
893
|
schema: schema,
|
|
751
894
|
rawOptions: unknown,
|
|
@@ -62,6 +62,15 @@ describe('create.Config', () => {
|
|
|
62
62
|
expectTypeOf<Config>().toHaveProperty('methods')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
+
test('accepts payment retry cap', () => {
|
|
66
|
+
const mppx = Mppx.create({
|
|
67
|
+
maxPaymentRetries: 1,
|
|
68
|
+
methods: [charge()],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expectTypeOf(mppx.fetch).toBeFunction()
|
|
72
|
+
})
|
|
73
|
+
|
|
65
74
|
test('paymentPreferences callback exposes typed method keys', () => {
|
|
66
75
|
const mppx = Mppx.create({
|
|
67
76
|
methods: [tempo({ account: {} as Account })],
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -41,6 +41,47 @@ describe('Mppx.create', () => {
|
|
|
41
41
|
expect(mppx.transport.name).toBe('mcp')
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
test('behavior: passes maxPaymentRetries to fetch wrapper', async () => {
|
|
45
|
+
const testMethod = Method.toClient(
|
|
46
|
+
Method.from({
|
|
47
|
+
name: 'test',
|
|
48
|
+
intent: 'test',
|
|
49
|
+
schema: Methods.charge.schema,
|
|
50
|
+
}),
|
|
51
|
+
{
|
|
52
|
+
async createCredential() {
|
|
53
|
+
return 'credential'
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
const challenge = Challenge.from({
|
|
58
|
+
id: 'retry-cap',
|
|
59
|
+
realm,
|
|
60
|
+
method: 'test',
|
|
61
|
+
intent: 'test',
|
|
62
|
+
request: { amount: '1' },
|
|
63
|
+
})
|
|
64
|
+
let callCount = 0
|
|
65
|
+
const fetch = vi.fn(async () => {
|
|
66
|
+
callCount++
|
|
67
|
+
return new Response(null, {
|
|
68
|
+
status: 402,
|
|
69
|
+
headers: { 'WWW-Authenticate': Challenge.serialize(challenge) },
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
const mppx = Mppx.create({
|
|
73
|
+
fetch: fetch as typeof globalThis.fetch,
|
|
74
|
+
maxPaymentRetries: 1,
|
|
75
|
+
methods: [testMethod],
|
|
76
|
+
polyfill: false,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const response = await mppx.fetch('https://example.com/api')
|
|
80
|
+
|
|
81
|
+
expect(response.status).toBe(402)
|
|
82
|
+
expect(callCount).toBe(2)
|
|
83
|
+
})
|
|
84
|
+
|
|
44
85
|
test('behavior: with multiple methods', () => {
|
|
45
86
|
const stripeCharge = Method.from({
|
|
46
87
|
name: 'stripe',
|
package/src/client/Mppx.ts
CHANGED
|
@@ -100,6 +100,7 @@ export function create<
|
|
|
100
100
|
>,
|
|
101
101
|
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
|
|
102
102
|
const {
|
|
103
|
+
maxPaymentRetries,
|
|
103
104
|
onChallenge,
|
|
104
105
|
orderChallenges,
|
|
105
106
|
polyfill = true,
|
|
@@ -122,6 +123,7 @@ export function create<
|
|
|
122
123
|
acceptPaymentPolicy,
|
|
123
124
|
...(config.fetch && { fetch: config.fetch }),
|
|
124
125
|
eventDispatcher: events,
|
|
126
|
+
...(maxPaymentRetries !== undefined && { maxPaymentRetries }),
|
|
125
127
|
...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
|
|
126
128
|
...(orderChallenges && { orderChallenges }),
|
|
127
129
|
methods,
|
|
@@ -298,6 +300,8 @@ export declare namespace create {
|
|
|
298
300
|
},
|
|
299
301
|
) => Promise<string | undefined>)
|
|
300
302
|
| undefined
|
|
303
|
+
/** Maximum number of payment challenge retries after the initial response. @default 3 */
|
|
304
|
+
maxPaymentRetries?: Fetch.from.Config['maxPaymentRetries'] | undefined
|
|
301
305
|
/** Filters and sorts supported challenges before credential creation. */
|
|
302
306
|
orderChallenges?: AcceptPayment.OrderChallenges<FlattenMethods<methods>> | undefined
|
|
303
307
|
/** Client-declared supported payment methods, keyed by typed `method/intent` strings. */
|