Skip to content

Commit 9f660c2

Browse files
authored
feat: add --network option (#425)
1 parent 849134c commit 9f660c2

6 files changed

Lines changed: 97 additions & 21 deletions

File tree

.changeset/cli-network-chain.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 Tempo CLI network shortcuts and challenge chain mismatch checks.

src/cli/cli.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const cli = Cli.create('mppx', {
108108
.array(z.string())
109109
.optional()
110110
.describe('Method-specific option (key=value, repeatable)'),
111+
network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
111112
rpcUrl: z
112113
.string()
113114
.optional()
@@ -274,7 +275,11 @@ const cli = Cli.create('mppx', {
274275
if (plugin) {
275276
pluginResult = await plugin.setup({
276277
challenge,
277-
options: { account: c.options.account, rpcUrl: c.options.rpcUrl },
278+
options: {
279+
account: c.options.account,
280+
network: c.options.network,
281+
rpcUrl: c.options.rpcUrl,
282+
},
278283
methodOpts: parseMethodOpts(c.options.methodOpt),
279284
})
280285
tokenSymbol = pluginResult.tokenSymbol
@@ -542,6 +547,7 @@ const account = Cli.create('account', {
542547
description: 'Create new account',
543548
options: z.object({
544549
account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
550+
network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
545551
rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
546552
}),
547553
output: z.object({ address: z.string(), name: z.string() }),
@@ -587,8 +593,8 @@ const account = Cli.create('account', {
587593
const addrDisplay = explorerUrl
588594
? link(`${explorerUrl}/address/${acct.address}`, acct.address)
589595
: acct.address
590-
const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
591-
resolveChain({ rpcUrl })
596+
const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
597+
resolveChain({ network: c.options.network, rpcUrl })
592598
.then((chain) => createClient({ chain, transport: http(rpcUrl) }))
593599
.then((client) =>
594600
import('viem/tempo').then(({ Actions }) =>
@@ -702,6 +708,7 @@ const account = Cli.create('account', {
702708
description: 'Fund account with testnet tokens',
703709
options: z.object({
704710
account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
711+
network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
705712
rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
706713
}),
707714
output: z.object({ account: z.string(), chain: z.string(), transactions: z.array(z.string()) }),
@@ -722,8 +729,8 @@ const account = Cli.create('account', {
722729
return c.error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
723730
}
724731
const acct = privateKeyToAccount(key as `0x${string}`)
725-
const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
726-
const chain = await resolveChain({ rpcUrl })
732+
const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
733+
const chain = await resolveChain({ network: c.options.network, rpcUrl })
727734
const client = createClient({ chain, transport: http(rpcUrl) })
728735
if (!structured) console.log(`Funding "${accountName}" on ${chainName(chain)}`)
729736
try {
@@ -852,6 +859,7 @@ const account = Cli.create('account', {
852859
description: 'View account address',
853860
options: z.object({
854861
account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
862+
network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
855863
rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
856864
}),
857865
output: accountViewSchema,
@@ -869,8 +877,8 @@ const account = Cli.create('account', {
869877
})
870878
}
871879
const address = tempoEntry.wallet_address as Address
872-
const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
873-
const chain = await resolveChain({ rpcUrl })
880+
const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
881+
const chain = await resolveChain({ network: c.options.network, rpcUrl })
874882
const explorerUrl = chain.blockExplorers?.default?.url
875883
const addrDisplay = explorerUrl
876884
? link(`${explorerUrl}/address/${address}`, address)
@@ -913,8 +921,8 @@ const account = Cli.create('account', {
913921
return c.error({ code: 'ACCOUNT_NOT_FOUND', message: 'No account found.', exitCode: 69 })
914922
}
915923
const acct = privateKeyToAccount(key as `0x${string}`)
916-
const rpcUrl = resolveRpcUrl(c.options.rpcUrl)
917-
const chain = await resolveChain({ rpcUrl })
924+
const rpcUrl = resolveRpcUrl(c.options.rpcUrl, { network: c.options.network })
925+
const chain = await resolveChain({ network: c.options.network, rpcUrl })
918926
const explorerUrl = chain.blockExplorers?.default?.url
919927
const addrDisplay = explorerUrl
920928
? link(`${explorerUrl}/address/${acct.address}`, acct.address)
@@ -952,6 +960,7 @@ const sign = Cli.create('sign', {
952960
.array(z.string())
953961
.optional()
954962
.describe('Method-specific option (key=value, repeatable)'),
963+
network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
955964
rpcUrl: z
956965
.string()
957966
.optional()
@@ -1029,7 +1038,11 @@ const sign = Cli.create('sign', {
10291038
if (plugin) {
10301039
const result = await plugin.setup({
10311040
challenge,
1032-
options: { account: c.options.account, rpcUrl: c.options.rpcUrl },
1041+
options: {
1042+
account: c.options.account,
1043+
network: c.options.network,
1044+
rpcUrl: c.options.rpcUrl,
1045+
},
10331046
methodOpts,
10341047
})
10351048
if (result.createCredential) {

src/cli/plugins/plugin.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type * as Challenge from '../../Challenge.js'
22
import type * as Method from '../../Method.js'
3+
import type { Network } from '../utils.js'
34

45
export function createPlugin(plugin: Plugin): Plugin {
56
return plugin
@@ -18,7 +19,11 @@ export interface Plugin {
1819
*/
1920
setup(ctx: {
2021
challenge: Challenge.Challenge
21-
options: { account?: string | undefined; rpcUrl?: string | undefined }
22+
options: {
23+
account?: string | undefined
24+
network?: Network | undefined
25+
rpcUrl?: string | undefined
26+
}
2227
methodOpts: Record<string, string>
2328
}): Promise<{
2429
/** Token symbol for display (e.g., 'PathUSD', 'USD') */

src/cli/plugins/tempo.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,12 @@ export function tempo() {
7171
useTempoCliSign = true
7272
const tempoEntry = resolveTempoAccount(accountName)
7373
if (tempoEntry) {
74-
const rpcUrl = resolveRpcUrl(options.rpcUrl)
74+
const rpcUrl = resolveRpcUrl(options.rpcUrl, { network: options.network })
7575
client = createClient({
76-
chain: await resolveChain({ rpcUrl }),
76+
chain: await resolveChain({ network: options.network, rpcUrl }),
7777
transport: http(rpcUrl),
7878
})
79+
assertChallengeChain({ challenge, clientChainId: client.chain?.id })
7980
explorerUrl = client.chain?.blockExplorers?.default?.url
8081
const tokenInfo = currency
8182
? await fetchTokenInfo(
@@ -111,11 +112,12 @@ export function tempo() {
111112
} else account = privateKeyToAccount(privateKey as `0x${string}`)
112113

113114
if (!useTempoCliSign && account) {
114-
const rpcUrl = resolveRpcUrl(options.rpcUrl)
115+
const rpcUrl = resolveRpcUrl(options.rpcUrl, { network: options.network })
115116
client = createClient({
116-
chain: await resolveChain({ rpcUrl }),
117+
chain: await resolveChain({ network: options.network, rpcUrl }),
117118
transport: http(rpcUrl),
118119
})
120+
assertChallengeChain({ challenge, clientChainId: client.chain?.id })
119121
explorerUrl = client.chain?.blockExplorers?.default?.url
120122
const tokenInfo = currency
121123
? await fetchTokenInfo(client, currency as Address, account.address).catch(
@@ -730,6 +732,28 @@ function parseOptions<const schema extends z.ZodType>(
730732
throw new Error(`Invalid CLI options (${summary})`)
731733
}
732734

735+
function assertChallengeChain(opts: {
736+
challenge: { request: Record<string, unknown> }
737+
clientChainId?: number | undefined
738+
}) {
739+
const methodDetails = opts.challenge.request.methodDetails as
740+
| { chainId?: number | undefined }
741+
| undefined
742+
const requiredChainId = methodDetails?.chainId
743+
if (!requiredChainId || !opts.clientChainId || requiredChainId === opts.clientChainId) return
744+
const hint =
745+
requiredChainId === 4217
746+
? ' Use --network mainnet or --rpc-url https://rpc.tempo.xyz.'
747+
: requiredChainId === 42431
748+
? ' Use --network testnet or --rpc-url https://rpc.moderato.tempo.xyz.'
749+
: ''
750+
throw new Errors.IncurError({
751+
code: 'CHAIN_MISMATCH',
752+
message: `Challenge requires chainId ${requiredChainId}, but RPC is chainId ${opts.clientChainId}.${hint}`,
753+
exitCode: 2,
754+
})
755+
}
756+
733757
function channelStateDir() {
734758
return path.join(
735759
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),

src/cli/utils.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { tempo as tempoMainnet, tempoModerato } from 'viem/chains'
22
import { afterEach, describe, expect, test } from 'vp/test'
33

4-
import { resolveChain, resolveRpcUrl } from './utils.js'
4+
import { networkRpcUrls, resolveChain, resolveRpcUrl } from './utils.js'
55

66
describe('resolveRpcUrl', () => {
77
afterEach(() => {
@@ -14,6 +14,17 @@ describe('resolveRpcUrl', () => {
1414
expect(resolveRpcUrl('https://explicit.example.com')).toBe('https://explicit.example.com')
1515
})
1616

17+
test('uses network default before env vars', () => {
18+
process.env.MPPX_RPC_URL = 'https://env.example.com'
19+
expect(resolveRpcUrl(undefined, { network: 'testnet' })).toBe(networkRpcUrls.testnet)
20+
})
21+
22+
test('prefers explicit rpc url over network default', () => {
23+
expect(resolveRpcUrl('https://explicit.example.com', { network: 'mainnet' })).toBe(
24+
'https://explicit.example.com',
25+
)
26+
})
27+
1728
test('falls back to MPPX_RPC_URL env var', () => {
1829
process.env.MPPX_RPC_URL = 'https://mppx.example.com'
1930
process.env.RPC_URL = 'https://rpc.example.com'

src/cli/utils.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { Chain } from 'viem'
44
import { type Address, createClient, http } from 'viem'
55
import { tempo as tempoMainnet, tempoModerato } from 'viem/chains'
66

7+
import * as defaults from '../tempo/internal/defaults.js'
8+
79
// Inlined from https://github.com/alexeyraspopov/picocolors (ISC License)
810
export const pc = (() => {
911
const p = process || ({} as NodeJS.Process)
@@ -221,13 +223,29 @@ export function fmtBalance(
221223
return `${dec ? `${formatted}.${dec}` : formatted} ${sym}`
222224
}
223225

224-
/** Resolve RPC URL from explicit option, then MPPX_RPC_URL, then RPC_URL env vars. */
225-
export function resolveRpcUrl(explicit?: string | undefined): string | undefined {
226-
return explicit ?? (process.env.MPPX_RPC_URL?.trim() || process.env.RPC_URL?.trim() || undefined)
226+
export type Network = 'mainnet' | 'testnet'
227+
228+
export const networkRpcUrls = {
229+
mainnet: defaults.rpcUrl[defaults.chainId.mainnet],
230+
testnet: defaults.rpcUrl[defaults.chainId.testnet],
231+
} as const satisfies Record<Network, string>
232+
233+
/** Resolve RPC URL from explicit option, network option, then MPPX_RPC_URL/RPC_URL env vars. */
234+
export function resolveRpcUrl(
235+
explicit?: string | undefined,
236+
options: { network?: Network | undefined } = {},
237+
): string | undefined {
238+
return (
239+
explicit ??
240+
(options.network ? networkRpcUrls[options.network] : undefined) ??
241+
(process.env.MPPX_RPC_URL?.trim() || process.env.RPC_URL?.trim() || undefined)
242+
)
227243
}
228244

229-
export async function resolveChain(opts: { rpcUrl?: string | undefined } = {}): Promise<Chain> {
230-
const rpcUrl = resolveRpcUrl(opts.rpcUrl)
245+
export async function resolveChain(
246+
opts: { network?: Network | undefined; rpcUrl?: string | undefined } = {},
247+
): Promise<Chain> {
248+
const rpcUrl = resolveRpcUrl(opts.rpcUrl, { network: opts.network })
231249
if (!rpcUrl) return tempoMainnet
232250
const { getChainId } = await import('viem/actions')
233251
const chainId = await getChainId(createClient({ transport: http(rpcUrl) }))

0 commit comments

Comments
 (0)