@promus/cli 0.24.17

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.
Files changed (96) hide show
  1. package/README.md +18 -0
  2. package/bin/promus +33 -0
  3. package/package.json +51 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_inft-ref.ts +43 -0
  6. package/src/commands/_unlock.ts +74 -0
  7. package/src/commands/admin-autotopup-tick.ts +73 -0
  8. package/src/commands/admin.test.ts +34 -0
  9. package/src/commands/admin.ts +32 -0
  10. package/src/commands/balance.test.ts +10 -0
  11. package/src/commands/balance.ts +112 -0
  12. package/src/commands/chat-sandbox.tsx +520 -0
  13. package/src/commands/chat-telegram.ts +398 -0
  14. package/src/commands/chat.tsx +1916 -0
  15. package/src/commands/deploy.ts +204 -0
  16. package/src/commands/drain.ts +90 -0
  17. package/src/commands/gateway-logs.ts +47 -0
  18. package/src/commands/gateway-run.ts +54 -0
  19. package/src/commands/gateway-start.ts +218 -0
  20. package/src/commands/gateway-status.ts +88 -0
  21. package/src/commands/gateway-stop.ts +133 -0
  22. package/src/commands/gateway.ts +101 -0
  23. package/src/commands/init/cost.test.ts +169 -0
  24. package/src/commands/init/cost.ts +154 -0
  25. package/src/commands/init/funding-gate.ts +67 -0
  26. package/src/commands/init/model-picker.ts +81 -0
  27. package/src/commands/init/operator-picker.ts +263 -0
  28. package/src/commands/init/resume.ts +136 -0
  29. package/src/commands/init/sandbox-provision.test.ts +497 -0
  30. package/src/commands/init/sandbox-provision.ts +1177 -0
  31. package/src/commands/init/telegram-step.ts +229 -0
  32. package/src/commands/init/wizard-state.ts +95 -0
  33. package/src/commands/init.ts +612 -0
  34. package/src/commands/inspect.ts +529 -0
  35. package/src/commands/ledger.ts +176 -0
  36. package/src/commands/logs.ts +86 -0
  37. package/src/commands/migrate-keystore.ts +155 -0
  38. package/src/commands/model.ts +48 -0
  39. package/src/commands/pairing-approve.ts +114 -0
  40. package/src/commands/pairing-clear.ts +42 -0
  41. package/src/commands/pairing-list.ts +58 -0
  42. package/src/commands/pairing-revoke.ts +52 -0
  43. package/src/commands/pairing.test.ts +88 -0
  44. package/src/commands/pairing.ts +81 -0
  45. package/src/commands/pause.ts +99 -0
  46. package/src/commands/profile.ts +184 -0
  47. package/src/commands/restore.ts +221 -0
  48. package/src/commands/resume.ts +181 -0
  49. package/src/commands/status.ts +119 -0
  50. package/src/commands/sync.ts +147 -0
  51. package/src/commands/telegram-remove.ts +65 -0
  52. package/src/commands/telegram-setup.ts +74 -0
  53. package/src/commands/telegram-status.ts +89 -0
  54. package/src/commands/telegram.test.ts +50 -0
  55. package/src/commands/telegram.ts +44 -0
  56. package/src/commands/topup.ts +303 -0
  57. package/src/commands/transfer.test.ts +111 -0
  58. package/src/commands/transfer.ts +520 -0
  59. package/src/commands/upgrade.test.ts +137 -0
  60. package/src/commands/upgrade.ts +690 -0
  61. package/src/config/load.ts +35 -0
  62. package/src/config/render.test.ts +96 -0
  63. package/src/config/render.ts +110 -0
  64. package/src/index.ts +378 -0
  65. package/src/sandbox/client.test.ts +251 -0
  66. package/src/sandbox/client.ts +424 -0
  67. package/src/ui/app.tsx +677 -0
  68. package/src/ui/approval-summary.test.ts +154 -0
  69. package/src/ui/approval-summary.ts +34 -0
  70. package/src/ui/markdown-parse.ts +219 -0
  71. package/src/ui/markdown.test.ts +146 -0
  72. package/src/ui/markdown.tsx +37 -0
  73. package/src/ui/state.test.ts +74 -0
  74. package/src/ui/state.ts +198 -0
  75. package/src/util/bootstrap-mode.test.ts +40 -0
  76. package/src/util/bootstrap-mode.ts +25 -0
  77. package/src/util/bootstrap-progress-box.test.ts +190 -0
  78. package/src/util/bootstrap-progress-box.ts +378 -0
  79. package/src/util/brain-secrets.ts +96 -0
  80. package/src/util/cli-version.ts +28 -0
  81. package/src/util/format.test.ts +16 -0
  82. package/src/util/format.ts +11 -0
  83. package/src/util/gateway-spawn.test.ts +86 -0
  84. package/src/util/gateway-spawn.ts +128 -0
  85. package/src/util/gateway-version.test.ts +113 -0
  86. package/src/util/gateway-version.ts +154 -0
  87. package/src/util/github-releases.test.ts +116 -0
  88. package/src/util/github-releases.ts +79 -0
  89. package/src/util/profile-key.test.ts +60 -0
  90. package/src/util/profile-key.ts +25 -0
  91. package/src/util/ref-resolver.test.ts +77 -0
  92. package/src/util/ref-resolver.ts +55 -0
  93. package/src/util/silence-console.test.ts +53 -0
  94. package/src/util/silence-console.ts +40 -0
  95. package/src/util/telegram-secrets.test.ts +227 -0
  96. package/src/util/telegram-secrets.ts +223 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `promus gateway status` — show PID, uptime, socket path, lock state,
3
+ * operator-session freshness.
4
+ */
5
+
6
+ import { createHash } from 'node:crypto'
7
+ import { existsSync, readFileSync, statSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import {
11
+ agentPaths,
12
+ iNFTAgentId,
13
+ isOperatorSessionFresh,
14
+ readOperatorSession,
15
+ } from '@promus/core'
16
+ import { type Address, getAddress } from 'viem'
17
+ import { findAndLoadConfig } from '../config/load'
18
+
19
+ export interface GatewayStatusOpts {
20
+ agentId?: string
21
+ }
22
+
23
+ function fmtAge(ms: number): string {
24
+ const s = Math.floor(ms / 1000)
25
+ if (s < 60) return `${s}s`
26
+ const m = Math.floor(s / 60)
27
+ if (m < 60) return `${m}m`
28
+ const h = Math.floor(m / 60)
29
+ return `${h}h${m % 60}m`
30
+ }
31
+
32
+ export async function runGatewayStatus(opts: GatewayStatusOpts): Promise<void> {
33
+ let agentId = opts.agentId
34
+ if (!agentId) {
35
+ const found = await findAndLoadConfig()
36
+ if (!found?.config) {
37
+ console.error('promus gateway status: no promus.config.ts and no --agent provided')
38
+ process.exit(1)
39
+ }
40
+ const contractAddress = getAddress(found.config.identity.iNFT!.contract as Address)
41
+ const tokenId = BigInt(found.config.identity.iNFT!.tokenId)
42
+ agentId = iNFTAgentId({ contractAddress, tokenId })
43
+ }
44
+ const paths = agentPaths.agent(agentId)
45
+ const socketPath = join(paths.dir, 'gateway.sock')
46
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
47
+ const lockFile = join(homedir(), '.promus', 'locks', `@promus/gateway-${identityHash}.lock`)
48
+
49
+ console.log(`agent: ${agentId}`)
50
+ console.log(`socket: ${socketPath} ${existsSync(socketPath) ? '(present)' : '(absent)'}`)
51
+ console.log(`lock: ${lockFile} ${existsSync(lockFile) ? '(present)' : '(absent)'}`)
52
+
53
+ // PID + uptime via lock file.
54
+ if (existsSync(lockFile)) {
55
+ try {
56
+ const parsed = JSON.parse(readFileSync(lockFile, 'utf8')) as { pid?: number }
57
+ if (typeof parsed.pid === 'number') {
58
+ let alive = false
59
+ try {
60
+ process.kill(parsed.pid, 0)
61
+ alive = true
62
+ } catch {
63
+ /* dead */
64
+ }
65
+ const stat = statSync(lockFile)
66
+ const ageMs = Date.now() - stat.mtimeMs
67
+ console.log(`pid: ${parsed.pid} ${alive ? '(alive)' : '(dead — stale lock)'}`)
68
+ console.log(`lock-age: ${fmtAge(ageMs)}`)
69
+ }
70
+ } catch {
71
+ console.log('pid: (lock file unreadable)')
72
+ }
73
+ } else {
74
+ console.log('pid: (not running)')
75
+ }
76
+
77
+ // Operator-session freshness.
78
+ const fresh = isOperatorSessionFresh(agentId)
79
+ console.log(`session: ${fresh ? 'fresh' : 'absent or expired'}`)
80
+ if (fresh) {
81
+ const sess = readOperatorSession(agentId)
82
+ if (sess) {
83
+ const remaining = sess.expiresAt - Date.now()
84
+ const scopes = Object.keys(sess.keys).filter(k => sess.keys[k as keyof typeof sess.keys])
85
+ console.log(`session-ttl: ${fmtAge(remaining)} remaining (scopes: ${scopes.join(', ')})`)
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `promus gateway stop` — SIGTERM the running gateway daemon via the lock
3
+ * file's PID. Falls through to SIGKILL after a 5s grace period.
4
+ */
5
+
6
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+ import { agentPaths, iNFTAgentId } from '@promus/core'
10
+ import { type Address, getAddress } from 'viem'
11
+ import { findAndLoadConfig } from '../config/load'
12
+
13
+ export interface GatewayStopOpts {
14
+ agentId?: string
15
+ }
16
+
17
+ function lockPath(_agentId: string): string {
18
+ // Mirror packages/core/src/locks.ts — `~/.promus/locks/<scope>-<sha256(identity).slice(0,16)>.lock`
19
+ // For '@promus/gateway' scope. We compute the same hash as the lock module.
20
+ // Easiest: read all lock files and find one matching the agent.
21
+ return join(homedir(), '.promus', 'locks')
22
+ }
23
+
24
+ function findGatewayLock(agentId: string): string | null {
25
+ // The lock filename embeds sha256(agentId).slice(0, 16). Compute it.
26
+ const { createHash } = require('node:crypto')
27
+ const identityHash = createHash('sha256').update(agentId).digest('hex').slice(0, 16)
28
+ const lockFile = join(lockPath(agentId), `@promus/gateway-${identityHash}.lock`)
29
+ return existsSync(lockFile) ? lockFile : null
30
+ }
31
+
32
+ export async function runGatewayStop(opts: GatewayStopOpts): Promise<void> {
33
+ let agentId = opts.agentId
34
+ if (!agentId) {
35
+ const found = await findAndLoadConfig()
36
+ if (!found?.config) {
37
+ console.error('promus gateway stop: no promus.config.ts and no --agent provided')
38
+ process.exit(1)
39
+ }
40
+ const contractAddress = getAddress(found.config.identity.iNFT!.contract as Address)
41
+ const tokenId = BigInt(found.config.identity.iNFT!.tokenId)
42
+ agentId = iNFTAgentId({ contractAddress, tokenId })
43
+ const subname = found.config.subname ?? null
44
+ const agentEoa = (found.config.identity?.agent as string | undefined) ?? null
45
+ const label = subname ? `${subname}.promus.0g` : `agent ${agentId.slice(0, 8)}…`
46
+ const eoaLabel = agentEoa ? ` (EOA ${agentEoa.slice(0, 6)}…${agentEoa.slice(-4)})` : ''
47
+ const configPath = found.path ?? '<unknown>'
48
+ console.log(`promus gateway stop → ${label}${eoaLabel}`)
49
+ console.log(` config: ${configPath}`)
50
+ console.log(
51
+ ' if this is not the agent you meant, set PROMUS_ROOT or pass --agent <id> before re-running.',
52
+ )
53
+ }
54
+ const lockFile = findGatewayLock(agentId)
55
+ if (!lockFile) {
56
+ console.log(`gateway not running (no lock at ${lockPath(agentId)})`)
57
+ return
58
+ }
59
+ let pid: number
60
+ try {
61
+ const raw = readFileSync(lockFile, 'utf8').trim()
62
+ // Lock files are JSON with shape `{pid, scope, identityHash, expiresAt}`.
63
+ const parsed = JSON.parse(raw) as { pid?: number }
64
+ if (typeof parsed.pid !== 'number') {
65
+ console.error('promus gateway stop: lock file has no pid field')
66
+ process.exit(1)
67
+ }
68
+ pid = parsed.pid
69
+ } catch (e) {
70
+ console.error(`promus gateway stop: lock file unreadable — ${(e as Error).message}`)
71
+ process.exit(1)
72
+ }
73
+
74
+ // Verify the PID is alive.
75
+ try {
76
+ process.kill(pid, 0)
77
+ } catch {
78
+ console.log(`gateway not running (stale lock pid=${pid}); cleaning up`)
79
+ try {
80
+ unlinkSync(lockFile)
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ return
85
+ }
86
+
87
+ // Send SIGTERM, wait up to 5s, then SIGKILL.
88
+ console.log(`stopping gateway pid=${pid} ...`)
89
+ try {
90
+ process.kill(pid, 'SIGTERM')
91
+ } catch (e) {
92
+ console.error(`promus gateway stop: SIGTERM failed — ${(e as Error).message}`)
93
+ process.exit(1)
94
+ }
95
+
96
+ const start = Date.now()
97
+ while (Date.now() - start < 5_000) {
98
+ try {
99
+ process.kill(pid, 0)
100
+ } catch {
101
+ console.log(`gateway stopped pid=${pid}`)
102
+ // Lock file is auto-removed by daemon's shutdown handler. Belt + suspenders:
103
+ try {
104
+ if (existsSync(lockFile)) unlinkSync(lockFile)
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ // Also clean up the socket file in case the daemon didn't.
109
+ const socketPath = join(agentPaths.agent(agentId).dir, 'gateway.sock')
110
+ try {
111
+ if (existsSync(socketPath)) unlinkSync(socketPath)
112
+ } catch {
113
+ /* ignore */
114
+ }
115
+ return
116
+ }
117
+ await new Promise(r => setTimeout(r, 200))
118
+ }
119
+
120
+ console.log('gateway did not exit in 5s; sending SIGKILL')
121
+ try {
122
+ process.kill(pid, 'SIGKILL')
123
+ } catch (e) {
124
+ console.error(`promus gateway stop: SIGKILL failed — ${(e as Error).message}`)
125
+ process.exit(1)
126
+ }
127
+ try {
128
+ if (existsSync(lockFile)) unlinkSync(lockFile)
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ console.log(`gateway force-killed pid=${pid}`)
133
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `promus gateway <sub>` argv dispatcher.
3
+ *
4
+ * Subs:
5
+ * run — foreground daemon (blocks; Ctrl+C to stop). Uses operator-session.
6
+ * start — interactive: prompt Touch ID, write operator-session, fork daemon.
7
+ * stop — SIGTERM the running daemon via the lock file's PID.
8
+ * restart — stop + start.
9
+ * status — show PID, uptime, socket path, lock state.
10
+ * logs — tail the gateway log (--tail N, --follow).
11
+ *
12
+ * v0.19.x scope: run + start + stop + status. restart/logs/install/setup ship
13
+ * in v0.19.3 alongside launchd plist generator.
14
+ */
15
+
16
+ export type GatewaySub = 'run' | 'start' | 'stop' | 'restart' | 'status' | 'logs'
17
+
18
+ export interface ParsedGatewayArgs {
19
+ sub: GatewaySub
20
+ /** Optional --agent <id> override; defaults to config-derived. */
21
+ agentId?: string
22
+ /** --tail N for logs; default 100. */
23
+ tail?: number
24
+ /** --follow / -f for logs. */
25
+ follow?: boolean
26
+ }
27
+
28
+ export type ParseResult = ParsedGatewayArgs | { error: string }
29
+
30
+ export function parseGatewayArgs(argv: string[]): ParseResult {
31
+ const sub = argv[0]
32
+ if (!sub) {
33
+ return { error: 'usage: promus gateway <run | start | stop | restart | status | logs>' }
34
+ }
35
+ if (!['run', 'start', 'stop', 'restart', 'status', 'logs'].includes(sub)) {
36
+ return { error: `unknown gateway sub: ${sub}` }
37
+ }
38
+ let agentId: string | undefined
39
+ let tail: number | undefined
40
+ let follow = false
41
+ for (let i = 1; i < argv.length; i++) {
42
+ const a = argv[i]
43
+ if (a === '--agent') {
44
+ const v = argv[++i]
45
+ if (!v) return { error: '--agent requires a value' }
46
+ agentId = v
47
+ } else if (a === '--tail') {
48
+ const v = argv[++i]
49
+ if (!v) return { error: '--tail requires a value' }
50
+ const n = Number.parseInt(v, 10)
51
+ if (!Number.isFinite(n) || n < 0) return { error: '--tail must be a positive integer' }
52
+ tail = n
53
+ } else if (a === '--follow' || a === '-f') {
54
+ follow = true
55
+ } else {
56
+ return { error: `unknown flag: ${a}` }
57
+ }
58
+ }
59
+ return { sub: sub as GatewaySub, agentId, tail, follow }
60
+ }
61
+
62
+ export async function runGateway(parsed: ParsedGatewayArgs): Promise<void> {
63
+ switch (parsed.sub) {
64
+ case 'run': {
65
+ const { runGatewayForeground } = await import('./gateway-run')
66
+ await runGatewayForeground({ agentId: parsed.agentId })
67
+ return
68
+ }
69
+ case 'start': {
70
+ const { runGatewayStart } = await import('./gateway-start')
71
+ await runGatewayStart({ agentId: parsed.agentId })
72
+ return
73
+ }
74
+ case 'stop': {
75
+ const { runGatewayStop } = await import('./gateway-stop')
76
+ await runGatewayStop({ agentId: parsed.agentId })
77
+ return
78
+ }
79
+ case 'restart': {
80
+ const { runGatewayStop } = await import('./gateway-stop')
81
+ const { runGatewayStart } = await import('./gateway-start')
82
+ await runGatewayStop({ agentId: parsed.agentId })
83
+ await runGatewayStart({ agentId: parsed.agentId })
84
+ return
85
+ }
86
+ case 'status': {
87
+ const { runGatewayStatus } = await import('./gateway-status')
88
+ await runGatewayStatus({ agentId: parsed.agentId })
89
+ return
90
+ }
91
+ case 'logs': {
92
+ const { runGatewayLogs } = await import('./gateway-logs')
93
+ await runGatewayLogs({
94
+ agentId: parsed.agentId,
95
+ tail: parsed.tail ?? 100,
96
+ follow: parsed.follow ?? false,
97
+ })
98
+ return
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { parseEther } from 'viem'
3
+ import {
4
+ SANDBOX_BURN_RATE_OG_PER_HOUR,
5
+ SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG,
6
+ estimateCosts,
7
+ renderCostSummary,
8
+ } from './cost'
9
+
10
+ describe('estimateCosts (0G stack)', () => {
11
+ // NOTE: the 0G network path still exists in @promus/core but the display
12
+ // strings have been migrated to ETH / Arbitrum branding.
13
+ it('local target: zero sandbox fields', () => {
14
+ const c = estimateCosts({
15
+ ledgerSizeOg: 3,
16
+ withSubname: true,
17
+ deployTarget: 'local',
18
+ network: '0g-mainnet',
19
+ })
20
+ expect(c.sandboxInitialDepositTestnet).toBe(0n)
21
+ expect(c.sandboxBurnRatePerHourTestnet).toBe(0n)
22
+ expect(c.deployTarget).toBe('local')
23
+ expect(c.totalOperator).toBe(parseEther('3.115'))
24
+ expect(c.currency).toBe('0G')
25
+ expect(c.lean).toBe(false)
26
+ })
27
+
28
+ it('sandbox target: populates testnet fields', () => {
29
+ const c = estimateCosts({
30
+ ledgerSizeOg: 3,
31
+ withSubname: true,
32
+ deployTarget: 'sandbox',
33
+ network: '0g-mainnet',
34
+ })
35
+ expect(c.sandboxInitialDepositTestnet).toBe(
36
+ parseEther(String(SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG)),
37
+ )
38
+ expect(c.sandboxBurnRatePerHourTestnet).toBe(parseEther(String(SANDBOX_BURN_RATE_OG_PER_HOUR)))
39
+ expect(c.deployTarget).toBe('sandbox')
40
+ // mainnet totalOperator UNCHANGED by deploy target (testnet is a separate pool)
41
+ expect(c.totalOperator).toBe(parseEther('3.115'))
42
+ })
43
+
44
+ it('burn rate equals topup.ts canonical 0.09 0G/hour', () => {
45
+ expect(SANDBOX_BURN_RATE_OG_PER_HOUR).toBe(0.09)
46
+ const c = estimateCosts({
47
+ ledgerSizeOg: 3,
48
+ withSubname: false,
49
+ deployTarget: 'sandbox',
50
+ network: '0g-mainnet',
51
+ })
52
+ expect(c.sandboxBurnRatePerHourTestnet).toBe(parseEther('0.09'))
53
+ })
54
+ })
55
+
56
+ describe('estimateCosts (lean stack: Arbitrum/Claude/IPFS)', () => {
57
+ it('only real L2 gas: mint + small ETH agent float, no ledger or subname', () => {
58
+ const c = estimateCosts({
59
+ ledgerSizeOg: 0,
60
+ withSubname: false,
61
+ deployTarget: 'local',
62
+ network: 'arbitrum-sepolia',
63
+ })
64
+ expect(c.lean).toBe(true)
65
+ expect(c.currency).toBe('ETH')
66
+ expect(c.agentFloat).toBeGreaterThan(0n) // small ETH float for the agent EOA
67
+ expect(c.computeLedgerDeposit).toBe(0n)
68
+ expect(c.subnameAndRecords).toBe(0n)
69
+ expect(c.storageUploadGas).toBe(0n) // anchor paid from the agent float
70
+ expect(c.sandboxInitialDepositTestnet).toBe(0n)
71
+ // mint + agent float only — well under a faucet-funded testnet wallet
72
+ expect(c.totalOperator).toBe(c.mintAndApproveGas + c.agentFloat)
73
+ expect(c.totalOperator).toBeLessThan(parseEther('0.002'))
74
+ })
75
+
76
+ it('Robinhood Orbit L2 is also lean / ETH', () => {
77
+ const c = estimateCosts({
78
+ ledgerSizeOg: 10,
79
+ withSubname: true,
80
+ deployTarget: 'local',
81
+ network: 'robinhood-testnet',
82
+ })
83
+ expect(c.lean).toBe(true)
84
+ expect(c.currency).toBe('ETH')
85
+ // ledger size + subname requests are ignored on the lean stack
86
+ expect(c.computeLedgerDeposit).toBe(0n)
87
+ expect(c.subnameAndRecords).toBe(0n)
88
+ })
89
+ })
90
+
91
+ describe('renderCostSummary (0G stack)', () => {
92
+ it('local target: omits sandbox section', () => {
93
+ const c = estimateCosts({
94
+ ledgerSizeOg: 3,
95
+ withSubname: true,
96
+ deployTarget: 'local',
97
+ network: '0g-mainnet',
98
+ })
99
+ const out = renderCostSummary(c)
100
+ expect(out).toContain('operator spend (Arbitrum mainnet)')
101
+ expect(out).toContain('mint + setApprovalForAll')
102
+ expect(out).toContain('compute ledger deposit')
103
+ expect(out).not.toContain('sandbox spend')
104
+ expect(out).not.toContain('Galileo testnet')
105
+ expect(out).not.toContain('faucet')
106
+ })
107
+
108
+ it('sandbox target: includes Galileo testnet section with runway + faucet', () => {
109
+ const c = estimateCosts({
110
+ ledgerSizeOg: 3,
111
+ withSubname: true,
112
+ deployTarget: 'sandbox',
113
+ network: '0g-mainnet',
114
+ })
115
+ const out = renderCostSummary(c)
116
+ expect(out).toContain('sandbox spend (Arbitrum testnet, free via faucet):')
117
+ expect(out).toContain('initial provider deposit')
118
+ expect(out).toContain('runtime burn')
119
+ expect(out).toContain('1 ETH')
120
+ expect(out).toContain('0.09 ETH/h')
121
+ expect(out).toContain('auto-topup')
122
+ expect(out).toContain('runway')
123
+ })
124
+
125
+ it('sandbox target: runway expressed in hours for ~1 0G default', () => {
126
+ const c = estimateCosts({
127
+ ledgerSizeOg: 3,
128
+ withSubname: false,
129
+ deployTarget: 'sandbox',
130
+ network: '0g-mainnet',
131
+ })
132
+ const out = renderCostSummary(c)
133
+ // 1 ETH / 0.09 ETH/h = 11.11h
134
+ expect(out).toMatch(/~11\.[0-9]h runway/)
135
+ })
136
+
137
+ it('still shows USD $0.00 for testnet line', () => {
138
+ const c = estimateCosts({
139
+ ledgerSizeOg: 3,
140
+ withSubname: false,
141
+ deployTarget: 'sandbox',
142
+ network: '0g-mainnet',
143
+ })
144
+ const out = renderCostSummary(c)
145
+ // Testnet ETH is free — USD col should read ($0.00)
146
+ expect(out).toMatch(/initial provider deposit\s+1 ETH\s+\(\$0\.00\)/)
147
+ expect(out).not.toMatch(/initial provider deposit\s+1 ETH\s+\(\$0\.50\)/)
148
+ })
149
+ })
150
+
151
+ describe('renderCostSummary (lean stack)', () => {
152
+ it('ETH gas only: no economy lines', () => {
153
+ const c = estimateCosts({
154
+ ledgerSizeOg: 0,
155
+ withSubname: false,
156
+ deployTarget: 'local',
157
+ network: 'arbitrum-sepolia',
158
+ })
159
+ const out = renderCostSummary(c)
160
+ expect(out).toContain('operator spend (ETH L2 gas)')
161
+ expect(out).toContain('mint + setApprovalForAll')
162
+ expect(out).toContain('agent gas float')
163
+ expect(out).toContain('total operator spend')
164
+ expect(out).not.toContain('0G')
165
+ expect(out).not.toContain('agent infra float')
166
+ expect(out).not.toContain('compute ledger deposit')
167
+ expect(out).not.toContain('sandbox spend')
168
+ })
169
+ })
@@ -0,0 +1,154 @@
1
+ import {
2
+ type PromusNetwork,
3
+ NETWORK_CURRENCY,
4
+ SANDBOX_BURN_RATE_OG_PER_HOUR,
5
+ SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG,
6
+ isOgNetwork,
7
+ } from '@promus/core'
8
+ import { formatEther } from 'viem'
9
+
10
+ export { SANDBOX_BURN_RATE_OG_PER_HOUR, SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG }
11
+
12
+ /** ETH spot price used for USD estimates. Not authoritative, just a hint. */
13
+ const ETH_USD = 3500
14
+
15
+ export type DeployTarget = 'local' | 'sandbox'
16
+
17
+ export interface CostBreakdown {
18
+ mintAndApproveGas: bigint
19
+ agentFloat: bigint
20
+ computeLedgerDeposit: bigint
21
+ storageUploadGas: bigint
22
+ subnameAndRecords: bigint
23
+ totalOperator: bigint
24
+ /** Galileo testnet — present only when deployTarget === 'sandbox'. */
25
+ sandboxInitialDepositTestnet: bigint
26
+ /** Galileo testnet burn rate per hour, in wei. */
27
+ sandboxBurnRatePerHourTestnet: bigint
28
+ deployTarget: DeployTarget
29
+ /** Native gas-token symbol for the chosen network ('ETH'). */
30
+ currency: string
31
+ /** Lean stack = Claude brain + IPFS memory + local runtime. */
32
+ lean: boolean
33
+ }
34
+
35
+ export function estimateCosts(opts: {
36
+ ledgerSizeOg: number
37
+ withSubname: boolean
38
+ deployTarget: DeployTarget
39
+ network: PromusNetwork
40
+ }): CostBreakdown {
41
+ const currency = NETWORK_CURRENCY[opts.network]
42
+ const lean = !isOgNetwork(opts.network)
43
+
44
+ if (lean) {
45
+ // Arbitrum-family: only real L2 gas, in ETH. Memory is IPFS (off-chain) and
46
+ // the brain is Claude (off-chain API key), so there is no compute ledger or
47
+ // storage cost, and no subname. The operator pays the mint, then seeds a
48
+ // small ETH float to the agent EOA — the agent spends it on its own
49
+ // keystore-CID anchor and the per-turn memory-sync anchors. Both numbers
50
+ // are generous L2 buffers that stay well under a faucet-funded testnet wallet.
51
+ const mintAndApproveGas = 200_000_000_000_000n // ~0.0002 ETH (mint + setApprovalForAll; ~6x the real L2 cost)
52
+ const agentFloat = 300_000_000_000_000n // ~0.0003 ETH — agent EOA gas for anchors + ~60 memory syncs
53
+ const totalOperator = mintAndApproveGas + agentFloat
54
+ return {
55
+ mintAndApproveGas,
56
+ agentFloat,
57
+ computeLedgerDeposit: 0n,
58
+ storageUploadGas: 0n,
59
+ subnameAndRecords: 0n,
60
+ totalOperator,
61
+ sandboxInitialDepositTestnet: 0n,
62
+ sandboxBurnRatePerHourTestnet: 0n,
63
+ deployTarget: opts.deployTarget,
64
+ currency,
65
+ lean,
66
+ }
67
+ }
68
+
69
+ const mintAndApproveGas = 10_000_000_000_000_000n // ~0.01 ETH (mint + setApprovalForAll bundle)
70
+ const agentFloat = 100_000_000_000_000_000n // 0.1 ETH — infra float for the agent
71
+ const computeLedgerDeposit = BigInt(Math.round(opts.ledgerSizeOg * 1e18))
72
+ const storageUploadGas = 5_000_000_000_000_000n // ~0.005 ETH (storage anchor tx)
73
+ const subnameAndRecords = opts.withSubname
74
+ ? 30_000_000_000_000_000n // ~0.03 ETH (claim + 2 text records, paid from agent float)
75
+ : 0n
76
+ const totalOperator = mintAndApproveGas + agentFloat + computeLedgerDeposit + storageUploadGas
77
+ const sandboxInitialDepositTestnet =
78
+ opts.deployTarget === 'sandbox'
79
+ ? BigInt(Math.round(SANDBOX_DEFAULT_INITIAL_DEPOSIT_OG * 1e18))
80
+ : 0n
81
+ const sandboxBurnRatePerHourTestnet =
82
+ opts.deployTarget === 'sandbox' ? BigInt(Math.round(SANDBOX_BURN_RATE_OG_PER_HOUR * 1e18)) : 0n
83
+ return {
84
+ mintAndApproveGas,
85
+ agentFloat,
86
+ computeLedgerDeposit,
87
+ storageUploadGas,
88
+ subnameAndRecords,
89
+ totalOperator,
90
+ sandboxInitialDepositTestnet,
91
+ sandboxBurnRatePerHourTestnet,
92
+ deployTarget: opts.deployTarget,
93
+ currency,
94
+ lean,
95
+ }
96
+ }
97
+
98
+ export function formatUsd(valueWei: bigint): string {
99
+ const eth = Number(formatEther(valueWei))
100
+ return `$${(eth * ETH_USD).toFixed(2)}`
101
+ }
102
+
103
+ function formatRunway(depositWei: bigint, burnPerHourWei: bigint): string {
104
+ if (burnPerHourWei === 0n) return ''
105
+ const hours = Number(depositWei) / Number(burnPerHourWei)
106
+ if (hours < 1) return `${Math.round(hours * 60)} min runway`
107
+ if (hours < 48) return `~${hours.toFixed(1)}h runway`
108
+ const days = hours / 24
109
+ return `~${days.toFixed(1)}d runway`
110
+ }
111
+
112
+ export function renderCostSummary(c: CostBreakdown): string {
113
+ // On the lean stack the gas token is testnet ETH (no market price) — show $0.00.
114
+ const usd = (wei: bigint): string => (c.lean ? '$0.00' : formatUsd(wei))
115
+ const line = (label: string, wei: bigint): string =>
116
+ ` ${label.padEnd(32)}${formatEther(wei).padStart(8)} ${c.currency} (${usd(wei)})`
117
+
118
+ if (c.lean) {
119
+ // Claude + IPFS + local: operator pays the mint, then seeds the agent EOA
120
+ // float that covers the keystore-CID anchor + per-turn memory-sync anchors.
121
+ return [
122
+ ` operator spend (${c.currency} L2 gas):`,
123
+ line('mint + setApprovalForAll', c.mintAndApproveGas),
124
+ line('agent gas float', c.agentFloat),
125
+ ` ${'─'.repeat(32)}${'─'.repeat(18)}`,
126
+ line('total operator spend', c.totalOperator),
127
+ ].join('\n')
128
+ }
129
+
130
+ const lines = [
131
+ ' operator spend (Arbitrum mainnet):',
132
+ line('mint + setApprovalForAll', c.mintAndApproveGas),
133
+ line('storage upload (keystore)', c.storageUploadGas),
134
+ line('agent infra float', c.agentFloat),
135
+ line('compute ledger deposit', c.computeLedgerDeposit),
136
+ ` ${'─'.repeat(32)}${'─'.repeat(18)}`,
137
+ line('total operator spend', c.totalOperator),
138
+ '',
139
+ ' agent spend (from the float):',
140
+ line('subname + text records', c.subnameAndRecords),
141
+ ]
142
+ if (c.deployTarget === 'sandbox') {
143
+ const runway = formatRunway(c.sandboxInitialDepositTestnet, c.sandboxBurnRatePerHourTestnet)
144
+ lines.push(
145
+ '',
146
+ ' sandbox spend (Arbitrum testnet, free via faucet):',
147
+ ` ${'initial provider deposit'.padEnd(32)}${formatEther(c.sandboxInitialDepositTestnet).padStart(8)} ETH ($0.00)`,
148
+ ` ${'runtime burn'.padEnd(32)}${formatEther(c.sandboxBurnRatePerHourTestnet).padStart(8)} ETH/h (${runway})`,
149
+ ' fund via faucet → paste operator address',
150
+ ' auto-topup agent EOA refills sandbox billing from compute ledger',
151
+ )
152
+ }
153
+ return lines.join('\n')
154
+ }