@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.
- package/README.md +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- package/src/util/telegram-secrets.ts +223 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { rm } from 'node:fs/promises'
|
|
3
|
+
import { cancel, confirm, intro, isCancel, note, outro, password, spinner } from '@clack/prompts'
|
|
4
|
+
import {
|
|
5
|
+
AGENT_NFT_ABI,
|
|
6
|
+
PromusAgentNFTReader,
|
|
7
|
+
NETWORK_CHAIN_ID,
|
|
8
|
+
type OperatorSigner,
|
|
9
|
+
RawPrivkeyOperatorSigner,
|
|
10
|
+
agentPaths,
|
|
11
|
+
buildTransferHashes,
|
|
12
|
+
explorerTokenUrl,
|
|
13
|
+
fetchAndDecryptKeystore,
|
|
14
|
+
iNFTAgentId,
|
|
15
|
+
reEncryptKeystoreForRecipient,
|
|
16
|
+
signTransferProof,
|
|
17
|
+
slotIndex,
|
|
18
|
+
waitForReceiptResilient,
|
|
19
|
+
} from '@promus/core'
|
|
20
|
+
import { type Address, type Hex, isAddress, toHex } from 'viem'
|
|
21
|
+
import { privateKeyToAccount } from 'viem/accounts'
|
|
22
|
+
import { type ParsedINFTRef, parseINFTRef } from './_inft-ref'
|
|
23
|
+
import { pickOperatorSigner } from './init/operator-picker'
|
|
24
|
+
|
|
25
|
+
export interface TransferOpts {
|
|
26
|
+
/** iNFT ref string e.g. `eip155:16661:0x9e71...:7` */
|
|
27
|
+
ref: string
|
|
28
|
+
/** Recipient operator address (the new owner). */
|
|
29
|
+
to: Address
|
|
30
|
+
/** Recipient's privkey for re-encryption sig. Falls back to PROMUS_RECIPIENT_PRIVKEY env, then interactive picker. */
|
|
31
|
+
recipientKey?: Hex
|
|
32
|
+
/** Oracle privkey for signing the transfer proof when sender does not equal teeOracle. Falls back to PROMUS_ORACLE_PRIVKEY env. */
|
|
33
|
+
oracleKey?: Hex
|
|
34
|
+
/** Re-encrypt + round-trip verify locally; do not write to chain. */
|
|
35
|
+
dryRun?: boolean
|
|
36
|
+
/** Skip the confirmation prompt. */
|
|
37
|
+
yes?: boolean
|
|
38
|
+
/** Keep profile slot unchanged (default: purge to bootstrap). */
|
|
39
|
+
noPurge?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ParseTransferResult = TransferOpts | { error: string }
|
|
43
|
+
|
|
44
|
+
function parsePrivkeyFlag(name: string, args: string[]): { value: Hex } | { error: string } {
|
|
45
|
+
const v = args.shift()
|
|
46
|
+
if (!v) return { error: `${name} requires a hex value` }
|
|
47
|
+
const norm = v.startsWith('0x') ? v : `0x${v}`
|
|
48
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(norm)) {
|
|
49
|
+
return { error: `${name} must be a 32-byte hex string` }
|
|
50
|
+
}
|
|
51
|
+
return { value: norm as Hex }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `promus transfer <ref> --to <addr> [--recipient-key 0x...] [--oracle-key 0x...] [--dry-run] [--yes] [--no-purge]`
|
|
56
|
+
*
|
|
57
|
+
* Positional `<ref>` is the iNFT identifier (`eip155:<chainId>:<contract>:<tokenId>`
|
|
58
|
+
* or shorthand). All other args are flags.
|
|
59
|
+
*/
|
|
60
|
+
export function parseTransferArgs(argv: readonly string[]): ParseTransferResult {
|
|
61
|
+
const args = [...argv]
|
|
62
|
+
const out: Partial<TransferOpts> = {}
|
|
63
|
+
while (args.length > 0) {
|
|
64
|
+
const head = args.shift()!
|
|
65
|
+
if (head === '--to') {
|
|
66
|
+
const v = args.shift()
|
|
67
|
+
if (!v) return { error: '--to requires an address' }
|
|
68
|
+
if (!isAddress(v)) return { error: `--to value '${v}' is not a valid address` }
|
|
69
|
+
out.to = v as Address
|
|
70
|
+
} else if (head === '--recipient-key') {
|
|
71
|
+
const r = parsePrivkeyFlag('--recipient-key', args)
|
|
72
|
+
if ('error' in r) return r
|
|
73
|
+
out.recipientKey = r.value
|
|
74
|
+
} else if (head === '--oracle-key') {
|
|
75
|
+
const r = parsePrivkeyFlag('--oracle-key', args)
|
|
76
|
+
if ('error' in r) return r
|
|
77
|
+
out.oracleKey = r.value
|
|
78
|
+
} else if (head === '--dry-run') {
|
|
79
|
+
out.dryRun = true
|
|
80
|
+
} else if (head === '--yes' || head === '-y') {
|
|
81
|
+
out.yes = true
|
|
82
|
+
} else if (head === '--no-purge') {
|
|
83
|
+
out.noPurge = true
|
|
84
|
+
} else if (head.startsWith('-')) {
|
|
85
|
+
return { error: `unknown flag: ${head}` }
|
|
86
|
+
} else if (!out.ref) {
|
|
87
|
+
out.ref = head
|
|
88
|
+
} else {
|
|
89
|
+
return { error: `unexpected positional argument: ${head}` }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!out.ref) return { error: 'iNFT ref is required (positional)' }
|
|
93
|
+
if (!out.to) return { error: '--to <recipient-address> is required' }
|
|
94
|
+
return out as TransferOpts
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function runTransfer(opts: TransferOpts): Promise<void> {
|
|
98
|
+
intro(opts.dryRun ? 'promus transfer (dry run)' : 'promus transfer')
|
|
99
|
+
|
|
100
|
+
let parsed: ParsedINFTRef
|
|
101
|
+
try {
|
|
102
|
+
parsed = parseINFTRef(opts.ref)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
cancel((e as Error).message)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (parsed.contract.toLowerCase() === opts.to.toLowerCase()) {
|
|
108
|
+
cancel('refusing transfer: --to address equals iNFT contract address')
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
// Step 1: read current state (owner + slot hashes + oracle).
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
const sFetch = spinner()
|
|
116
|
+
sFetch.start(`Fetching iNFT #${parsed.tokenId} state on ${parsed.network}`)
|
|
117
|
+
const reader = new PromusAgentNFTReader({
|
|
118
|
+
network: parsed.network,
|
|
119
|
+
contractAddress: parsed.contract,
|
|
120
|
+
})
|
|
121
|
+
let currentOwner: Address
|
|
122
|
+
let currentSlots: { dataHash: Hex }[]
|
|
123
|
+
try {
|
|
124
|
+
const [owner, slots] = await Promise.all([
|
|
125
|
+
reader.ownerOf(parsed.tokenId),
|
|
126
|
+
reader.getIntelligentData(parsed.tokenId),
|
|
127
|
+
])
|
|
128
|
+
currentOwner = owner
|
|
129
|
+
currentSlots = slots
|
|
130
|
+
sFetch.stop(`owner=${owner} slots=${slots.length}`)
|
|
131
|
+
} catch (e) {
|
|
132
|
+
sFetch.stop(`fetch failed: ${(e as Error).message.slice(0, 200)}`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (opts.to.toLowerCase() === currentOwner.toLowerCase()) {
|
|
136
|
+
cancel('refusing transfer: --to address equals current owner')
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
// Step 2: pick sender (operator A). Must equal current owner.
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
const senderPicked = await pickOperatorSigner({ network: parsed.network })
|
|
144
|
+
if (!senderPicked) {
|
|
145
|
+
cancel('aborted: no sender wallet')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const sender = senderPicked.signer
|
|
149
|
+
const senderAddr = await sender.address()
|
|
150
|
+
if (senderAddr.toLowerCase() !== currentOwner.toLowerCase()) {
|
|
151
|
+
await sender.close?.()
|
|
152
|
+
cancel(
|
|
153
|
+
[
|
|
154
|
+
'Sender wallet does not own this iNFT.',
|
|
155
|
+
` current owner: ${currentOwner}`,
|
|
156
|
+
` you connected: ${senderAddr}`,
|
|
157
|
+
].join('\n'),
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
// Step 3: prompt for agent EOA, decrypt keystore via sender wallet.
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
const agentAddrInput = (await password({
|
|
166
|
+
message: 'Agent EOA address (0x...) — find via your config or the iNFT subname',
|
|
167
|
+
validate: v => {
|
|
168
|
+
if (!v) return 'Required.'
|
|
169
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(v)) return 'Must be a 20-byte hex address.'
|
|
170
|
+
return undefined
|
|
171
|
+
},
|
|
172
|
+
})) as string | symbol
|
|
173
|
+
if (isCancel(agentAddrInput)) {
|
|
174
|
+
cancel('aborted')
|
|
175
|
+
await sender.close?.()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const agentAddress = agentAddrInput as Address
|
|
179
|
+
const agentId = iNFTAgentId({ contractAddress: parsed.contract, tokenId: parsed.tokenId })
|
|
180
|
+
const paths = agentPaths.agent(agentId)
|
|
181
|
+
|
|
182
|
+
const sUnlock = spinner()
|
|
183
|
+
sUnlock.start('Decrypting agent keystore via sender wallet')
|
|
184
|
+
let agentPrivkey: Hex
|
|
185
|
+
try {
|
|
186
|
+
const decrypted = await fetchAndDecryptKeystore({
|
|
187
|
+
network: parsed.network,
|
|
188
|
+
contractAddress: parsed.contract,
|
|
189
|
+
tokenId: parsed.tokenId,
|
|
190
|
+
signer: sender,
|
|
191
|
+
agentAddress,
|
|
192
|
+
cachePath: paths.keystore,
|
|
193
|
+
})
|
|
194
|
+
agentPrivkey = decrypted.privkeyHex
|
|
195
|
+
const derived = privateKeyToAccount(agentPrivkey).address
|
|
196
|
+
if (derived.toLowerCase() !== agentAddress.toLowerCase()) {
|
|
197
|
+
sUnlock.stop('agent address mismatch')
|
|
198
|
+
cancel(`Decrypted privkey points to ${derived} but you said ${agentAddress}.`)
|
|
199
|
+
await sender.close?.()
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
sUnlock.stop(`unlocked agent EOA ${agentAddress}`)
|
|
203
|
+
} catch (e) {
|
|
204
|
+
sUnlock.stop(`decrypt failed: ${(e as Error).message.slice(0, 200)}`)
|
|
205
|
+
await sender.close?.()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// -------------------------------------------------------------------------
|
|
210
|
+
// Step 4: resolve recipient signer.
|
|
211
|
+
// precedence: --recipient-key > PROMUS_RECIPIENT_PRIVKEY env > picker.
|
|
212
|
+
// -------------------------------------------------------------------------
|
|
213
|
+
const recipientKey = opts.recipientKey ?? (process.env.PROMUS_RECIPIENT_PRIVKEY as Hex | undefined)
|
|
214
|
+
let recipient: OperatorSigner
|
|
215
|
+
if (recipientKey) {
|
|
216
|
+
recipient = new RawPrivkeyOperatorSigner({
|
|
217
|
+
privkey: recipientKey,
|
|
218
|
+
sourceLabel: opts.recipientKey ? 'flag' : 'env:PROMUS_RECIPIENT_PRIVKEY',
|
|
219
|
+
})
|
|
220
|
+
} else {
|
|
221
|
+
note('Recipient signer not provided via flag/env. Pick one interactively.', 'recipient')
|
|
222
|
+
const picked = await pickOperatorSigner({ network: parsed.network })
|
|
223
|
+
if (!picked) {
|
|
224
|
+
cancel('aborted: no recipient signer')
|
|
225
|
+
await sender.close?.()
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
recipient = picked.signer
|
|
229
|
+
}
|
|
230
|
+
const recipientAddr = await recipient.address()
|
|
231
|
+
if (recipientAddr.toLowerCase() !== opts.to.toLowerCase()) {
|
|
232
|
+
await sender.close?.()
|
|
233
|
+
await recipient.close?.()
|
|
234
|
+
cancel(
|
|
235
|
+
[
|
|
236
|
+
'Recipient signer address does not match --to.',
|
|
237
|
+
` --to: ${opts.to}`,
|
|
238
|
+
` signer addr: ${recipientAddr}`,
|
|
239
|
+
].join('\n'),
|
|
240
|
+
)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// -------------------------------------------------------------------------
|
|
245
|
+
// Step 5: re-encrypt keystore with recipient's signer + upload to 0G Storage.
|
|
246
|
+
// -------------------------------------------------------------------------
|
|
247
|
+
const sReEnc = spinner()
|
|
248
|
+
sReEnc.start('Re-encrypting keystore for recipient + uploading to 0G Storage')
|
|
249
|
+
let newKeystoreHash: Hex
|
|
250
|
+
try {
|
|
251
|
+
const currentKeystoreHash = currentSlots[slotIndex('keystore')]?.dataHash
|
|
252
|
+
if (!currentKeystoreHash) {
|
|
253
|
+
throw new Error('keystore slot missing from current iNFT state')
|
|
254
|
+
}
|
|
255
|
+
newKeystoreHash = await reEncryptKeystoreForRecipient({
|
|
256
|
+
oldOpSigner: sender,
|
|
257
|
+
newOpSigner: recipient,
|
|
258
|
+
agentAddress,
|
|
259
|
+
currentRootHash: currentKeystoreHash,
|
|
260
|
+
network: parsed.network,
|
|
261
|
+
agentPrivkey,
|
|
262
|
+
})
|
|
263
|
+
sReEnc.stop(`uploaded new keystore blob: ${newKeystoreHash.slice(0, 18)}...`)
|
|
264
|
+
} catch (e) {
|
|
265
|
+
sReEnc.stop(`re-encrypt failed: ${(e as Error).message.slice(0, 200)}`)
|
|
266
|
+
await sender.close?.()
|
|
267
|
+
await recipient.close?.()
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
// Step 6: build newHashes[6], sign oracle proof.
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
const newHashes = buildTransferHashes({
|
|
275
|
+
currentHashes: currentSlots.map(s => s.dataHash),
|
|
276
|
+
newKeystoreHash,
|
|
277
|
+
purgeProfile: !opts.noPurge,
|
|
278
|
+
})
|
|
279
|
+
const proofNonce = toHex(randomBytes(32))
|
|
280
|
+
const chainId = NETWORK_CHAIN_ID[parsed.network]
|
|
281
|
+
|
|
282
|
+
// Determine oracle. Read on-chain teeOracle. If sender == oracle (MVP path
|
|
283
|
+
// where operator is also the oracle), reuse the sender signer. Otherwise
|
|
284
|
+
// resolve a separate oracle signer from --oracle-key flag or
|
|
285
|
+
// PROMUS_ORACLE_PRIVKEY env. This unblocks back-transfers and any flow where
|
|
286
|
+
// the iNFT owner differs from the contract's TEE oracle.
|
|
287
|
+
const oracleAddr = (await reader.publicClient.readContract({
|
|
288
|
+
address: parsed.contract,
|
|
289
|
+
abi: AGENT_NFT_ABI,
|
|
290
|
+
functionName: 'teeOracle',
|
|
291
|
+
})) as Address
|
|
292
|
+
let oracleSigner: OperatorSigner = sender
|
|
293
|
+
if (oracleAddr.toLowerCase() !== senderAddr.toLowerCase()) {
|
|
294
|
+
const oracleKey = opts.oracleKey ?? (process.env.PROMUS_ORACLE_PRIVKEY as Hex | undefined)
|
|
295
|
+
if (!oracleKey) {
|
|
296
|
+
await sender.close?.()
|
|
297
|
+
await recipient.close?.()
|
|
298
|
+
cancel(
|
|
299
|
+
[
|
|
300
|
+
'Oracle signer required (sender does not match teeOracle).',
|
|
301
|
+
` teeOracle: ${oracleAddr}`,
|
|
302
|
+
` sender: ${senderAddr}`,
|
|
303
|
+
'',
|
|
304
|
+
'Provide an oracle privkey via:',
|
|
305
|
+
' --oracle-key 0x<hex>',
|
|
306
|
+
' PROMUS_ORACLE_PRIVKEY=0x<hex>',
|
|
307
|
+
].join('\n'),
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
const candidate: OperatorSigner = new RawPrivkeyOperatorSigner({
|
|
312
|
+
privkey: oracleKey,
|
|
313
|
+
sourceLabel: opts.oracleKey ? 'flag' : 'env:PROMUS_ORACLE_PRIVKEY',
|
|
314
|
+
})
|
|
315
|
+
const candidateAddr = await candidate.address()
|
|
316
|
+
if (candidateAddr.toLowerCase() !== oracleAddr.toLowerCase()) {
|
|
317
|
+
await candidate.close?.()
|
|
318
|
+
await sender.close?.()
|
|
319
|
+
await recipient.close?.()
|
|
320
|
+
cancel(
|
|
321
|
+
[
|
|
322
|
+
'Oracle signer address does not match teeOracle.',
|
|
323
|
+
` teeOracle: ${oracleAddr}`,
|
|
324
|
+
` signer addr: ${candidateAddr}`,
|
|
325
|
+
].join('\n'),
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
oracleSigner = candidate
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const closeSigners = async (): Promise<void> => {
|
|
333
|
+
if (oracleSigner !== sender) await oracleSigner.close?.()
|
|
334
|
+
await recipient.close?.()
|
|
335
|
+
await sender.close?.()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const sOracle = spinner()
|
|
339
|
+
sOracle.start('Signing transfer proof with oracle')
|
|
340
|
+
let oracleSignature: Hex
|
|
341
|
+
try {
|
|
342
|
+
oracleSignature = await signTransferProof(
|
|
343
|
+
{
|
|
344
|
+
tokenId: parsed.tokenId,
|
|
345
|
+
from: senderAddr,
|
|
346
|
+
to: opts.to,
|
|
347
|
+
newHashes,
|
|
348
|
+
chainId,
|
|
349
|
+
proofNonce,
|
|
350
|
+
contractAddress: parsed.contract,
|
|
351
|
+
},
|
|
352
|
+
oracleSigner,
|
|
353
|
+
)
|
|
354
|
+
sOracle.stop('proof signed')
|
|
355
|
+
} catch (e) {
|
|
356
|
+
sOracle.stop(`oracle sign failed: ${(e as Error).message.slice(0, 200)}`)
|
|
357
|
+
await closeSigners()
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// -------------------------------------------------------------------------
|
|
362
|
+
// Step 7: dry-run gate or confirm + iTransferFrom.
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
const purgeLabel = !opts.noPurge ? 'YES (slot 3 -> bootstrap)' : 'NO'
|
|
365
|
+
const slotChanges = currentSlots
|
|
366
|
+
.map((s, i) => {
|
|
367
|
+
const before = s.dataHash
|
|
368
|
+
const after = newHashes[i]
|
|
369
|
+
return after === before
|
|
370
|
+
? null
|
|
371
|
+
: ` slot ${i}: ${before.slice(0, 12)}... -> ${after?.slice(0, 12)}...`
|
|
372
|
+
})
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
note(
|
|
375
|
+
[
|
|
376
|
+
`iNFT: #${parsed.tokenId} at ${parsed.contract}`,
|
|
377
|
+
`from: ${senderAddr}`,
|
|
378
|
+
`to: ${opts.to}`,
|
|
379
|
+
`agent EOA: ${agentAddress} (unchanged)`,
|
|
380
|
+
`oracle: ${oracleAddr}`,
|
|
381
|
+
`purge profile: ${purgeLabel}`,
|
|
382
|
+
`nonce: ${proofNonce}`,
|
|
383
|
+
'',
|
|
384
|
+
'slot changes:',
|
|
385
|
+
...(slotChanges as string[]),
|
|
386
|
+
].join('\n'),
|
|
387
|
+
'transfer plan',
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if (opts.dryRun) {
|
|
391
|
+
await closeSigners()
|
|
392
|
+
outro(
|
|
393
|
+
[
|
|
394
|
+
'',
|
|
395
|
+
' dry-run complete.',
|
|
396
|
+
` new keystore root: ${newKeystoreHash}`,
|
|
397
|
+
' re-encryption round-trip succeeded; chain unchanged.',
|
|
398
|
+
' re-run without --dry-run to commit.',
|
|
399
|
+
'',
|
|
400
|
+
].join('\n'),
|
|
401
|
+
)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!opts.yes) {
|
|
406
|
+
const ok = await confirm({
|
|
407
|
+
message: `Commit iTransferFrom on ${parsed.network}? Sender wallet pays gas.`,
|
|
408
|
+
initialValue: false,
|
|
409
|
+
})
|
|
410
|
+
if (isCancel(ok) || !ok) {
|
|
411
|
+
cancel('aborted')
|
|
412
|
+
await closeSigners()
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Submit iTransferFrom via the operator's WalletClient directly. Avoids
|
|
418
|
+
// needing to extract the privkey from privkey-based signers, and keeps the
|
|
419
|
+
// door open for WalletConnect senders later (their walletClient signs via
|
|
420
|
+
// the WC relay).
|
|
421
|
+
const senderWallet = await sender.walletClient(parsed.network)
|
|
422
|
+
const senderAccount = await sender.account()
|
|
423
|
+
const sTx = spinner()
|
|
424
|
+
sTx.start('Submitting iTransferFrom on chain')
|
|
425
|
+
let txHash: Hex
|
|
426
|
+
try {
|
|
427
|
+
txHash = (await senderWallet.writeContract({
|
|
428
|
+
address: parsed.contract,
|
|
429
|
+
abi: AGENT_NFT_ABI,
|
|
430
|
+
functionName: 'iTransferFrom',
|
|
431
|
+
args: [
|
|
432
|
+
senderAddr,
|
|
433
|
+
opts.to,
|
|
434
|
+
parsed.tokenId,
|
|
435
|
+
[...newHashes] as Hex[],
|
|
436
|
+
proofNonce,
|
|
437
|
+
oracleSignature,
|
|
438
|
+
],
|
|
439
|
+
chain: sender.chain(parsed.network),
|
|
440
|
+
account: senderAccount,
|
|
441
|
+
gas: BigInt(newHashes.length) * 60_000n + 200_000n,
|
|
442
|
+
})) as Hex
|
|
443
|
+
} catch (e) {
|
|
444
|
+
sTx.stop(`submit failed: ${(e as Error).message.slice(0, 240)}`)
|
|
445
|
+
await closeSigners()
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 0G mainnet block time is variable; viem's default receipt timeout is too
|
|
450
|
+
// short and false-fails on transactions that actually succeed. Use the core
|
|
451
|
+
// resilient poll helper (75 tries x 4s = 5 min budget) so we don't bail on
|
|
452
|
+
// a slow block.
|
|
453
|
+
let receiptStatus: 'success' | 'reverted' | 'unknown' = 'unknown'
|
|
454
|
+
try {
|
|
455
|
+
const receipt = await waitForReceiptResilient(reader.publicClient, txHash, {
|
|
456
|
+
tries: 75,
|
|
457
|
+
delayMs: 4000,
|
|
458
|
+
})
|
|
459
|
+
receiptStatus = receipt.status
|
|
460
|
+
} catch {
|
|
461
|
+
receiptStatus = 'unknown'
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (receiptStatus === 'reverted') {
|
|
465
|
+
sTx.stop(`tx reverted: ${txHash}`)
|
|
466
|
+
await closeSigners()
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
if (receiptStatus === 'unknown') {
|
|
470
|
+
sTx.stop('tx submitted; receipt poll timed out after ~5 min')
|
|
471
|
+
await closeSigners()
|
|
472
|
+
note(
|
|
473
|
+
[
|
|
474
|
+
'Tx submitted but receipt has not surfaced yet.',
|
|
475
|
+
` tx hash: ${txHash}`,
|
|
476
|
+
` verify: cast tx ${txHash} --rpc-url <0g-rpc>`,
|
|
477
|
+
'',
|
|
478
|
+
'If status=success, you can clean up local state with:',
|
|
479
|
+
` rm -rf ${paths.dir}`,
|
|
480
|
+
].join('\n'),
|
|
481
|
+
'verify manually',
|
|
482
|
+
)
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
sTx.stop(`tx confirmed: ${txHash}`)
|
|
487
|
+
|
|
488
|
+
// -------------------------------------------------------------------------
|
|
489
|
+
// Step 8: cleanup + outro. Only fires on confirmed-success receipt.
|
|
490
|
+
// -------------------------------------------------------------------------
|
|
491
|
+
const senderSource = sender.source
|
|
492
|
+
await closeSigners()
|
|
493
|
+
await rm(paths.dir, { recursive: true, force: true }).catch(() => {})
|
|
494
|
+
|
|
495
|
+
if (senderSource.startsWith('raw-privkey:')) {
|
|
496
|
+
note(
|
|
497
|
+
[
|
|
498
|
+
`Sender ${senderAddr} was provided via raw privkey.`,
|
|
499
|
+
'It may hold residual gas; sweep with cast:',
|
|
500
|
+
` cast balance --rpc-url <0g-rpc> ${senderAddr}`,
|
|
501
|
+
' cast send --rpc-url <0g-rpc> --private-key 0x... <main-wallet-addr> --value <wei>',
|
|
502
|
+
].join('\n'),
|
|
503
|
+
'sweep tip',
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
outro(
|
|
508
|
+
[
|
|
509
|
+
'',
|
|
510
|
+
` iNFT #${parsed.tokenId} at ${parsed.contract}`,
|
|
511
|
+
` new owner ${opts.to}`,
|
|
512
|
+
` tx ${txHash}`,
|
|
513
|
+
` explorer ${explorerTokenUrl(parsed.network, parsed.contract, parsed.tokenId)}`,
|
|
514
|
+
'',
|
|
515
|
+
'Recipient: run `promus restore` from your environment with the recipient',
|
|
516
|
+
'wallet to unlock the agent locally.',
|
|
517
|
+
'',
|
|
518
|
+
].join('\n'),
|
|
519
|
+
)
|
|
520
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type {
|
|
3
|
+
SandboxProviderClient,
|
|
4
|
+
ToolboxExecBody,
|
|
5
|
+
ToolboxExecResponse,
|
|
6
|
+
} from '@promus/core'
|
|
7
|
+
import { parseUpgradeArgs, probeContainerBootstrapMode } from './upgrade'
|
|
8
|
+
|
|
9
|
+
// Minimal mock for SandboxProviderClient that lets us assert what `command`
|
|
10
|
+
// got sent to execInToolbox and stub the response. v0.21.19 added this to
|
|
11
|
+
// verify the bash -c wrap fix that closes Bug 2 in
|
|
12
|
+
// feedback-reprovision-skips-tg-and-probe-bug.
|
|
13
|
+
function makeMockProvider(respond: (cmd: string) => ToolboxExecResponse | Error): {
|
|
14
|
+
provider: SandboxProviderClient
|
|
15
|
+
lastCommand: { value: string | null }
|
|
16
|
+
} {
|
|
17
|
+
const lastCommand: { value: string | null } = { value: null }
|
|
18
|
+
const provider = {
|
|
19
|
+
async execInToolbox(_id: string, body: ToolboxExecBody): Promise<ToolboxExecResponse> {
|
|
20
|
+
lastCommand.value = body.command
|
|
21
|
+
const r = respond(body.command)
|
|
22
|
+
if (r instanceof Error) throw r
|
|
23
|
+
return r
|
|
24
|
+
},
|
|
25
|
+
} as unknown as SandboxProviderClient
|
|
26
|
+
return { provider, lastCommand }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('parseUpgradeArgs', () => {
|
|
30
|
+
it('empty tail → no ref, no flags', () => {
|
|
31
|
+
expect(parseUpgradeArgs([])).toEqual({
|
|
32
|
+
ref: undefined,
|
|
33
|
+
yes: false,
|
|
34
|
+
reprovision: false,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
it('--yes alone → no ref', () => {
|
|
38
|
+
expect(parseUpgradeArgs(['--yes'])).toEqual({
|
|
39
|
+
ref: undefined,
|
|
40
|
+
yes: true,
|
|
41
|
+
reprovision: false,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
it('positional `latest`', () => {
|
|
45
|
+
expect(parseUpgradeArgs(['latest'])).toEqual({
|
|
46
|
+
ref: 'latest',
|
|
47
|
+
yes: false,
|
|
48
|
+
reprovision: false,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
it('positional tag `v0.17.8`', () => {
|
|
52
|
+
expect(parseUpgradeArgs(['v0.17.8'])).toEqual({
|
|
53
|
+
ref: 'v0.17.8',
|
|
54
|
+
yes: false,
|
|
55
|
+
reprovision: false,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
it('positional + --yes', () => {
|
|
59
|
+
expect(parseUpgradeArgs(['latest', '--yes'])).toEqual({
|
|
60
|
+
ref: 'latest',
|
|
61
|
+
yes: true,
|
|
62
|
+
reprovision: false,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
it('--ref takes priority over positional', () => {
|
|
66
|
+
expect(parseUpgradeArgs(['main', '--ref', 'v0.17.8'])).toEqual({
|
|
67
|
+
ref: 'v0.17.8',
|
|
68
|
+
yes: false,
|
|
69
|
+
reprovision: false,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
it('--ref + --yes', () => {
|
|
73
|
+
expect(parseUpgradeArgs(['--ref', 'v0.17.8', '--yes'])).toEqual({
|
|
74
|
+
ref: 'v0.17.8',
|
|
75
|
+
yes: true,
|
|
76
|
+
reprovision: false,
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
it('--reprovision flag captured', () => {
|
|
80
|
+
expect(parseUpgradeArgs(['v0.17.8', '--reprovision', '--yes'])).toEqual({
|
|
81
|
+
ref: 'v0.17.8',
|
|
82
|
+
yes: true,
|
|
83
|
+
reprovision: true,
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
it('-y short alias works', () => {
|
|
87
|
+
expect(parseUpgradeArgs(['-y'])).toEqual({
|
|
88
|
+
ref: undefined,
|
|
89
|
+
yes: true,
|
|
90
|
+
reprovision: false,
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('probeContainerBootstrapMode (bash -c wrap fix)', () => {
|
|
96
|
+
// Daytona's execInToolbox endpoint runs `command` argv-style — no shell
|
|
97
|
+
// interpretation. Bare `if [ ... ]; ...; fi` gets tokenised and `if`
|
|
98
|
+
// becomes argv[0]. v0.21.19 wraps the probe in `bash -c '<inner>'` so
|
|
99
|
+
// the inner shell handles the conditional. These tests assert both the
|
|
100
|
+
// command shape (regression-proof against a future un-wrap) AND the
|
|
101
|
+
// three response-parsing branches.
|
|
102
|
+
|
|
103
|
+
it('sends a bash -c wrapped command', async () => {
|
|
104
|
+
const { provider, lastCommand } = makeMockProvider(() => ({
|
|
105
|
+
exitCode: 0,
|
|
106
|
+
result: 'MODE=git\n',
|
|
107
|
+
}))
|
|
108
|
+
await probeContainerBootstrapMode(provider, 'sbx-test')
|
|
109
|
+
expect(lastCommand.value).not.toBeNull()
|
|
110
|
+
expect(lastCommand.value!.startsWith(`bash -c '`)).toBe(true)
|
|
111
|
+
expect(lastCommand.value!).toContain('if [ -d "$HOME/promus/.git" ]')
|
|
112
|
+
expect(lastCommand.value!).toContain('echo MODE=git')
|
|
113
|
+
expect(lastCommand.value!).toContain('echo MODE=npm')
|
|
114
|
+
expect(lastCommand.value!).toContain('echo MODE=none')
|
|
115
|
+
expect(lastCommand.value!.endsWith(`fi'`)).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('returns "git" when stdout contains MODE=git', async () => {
|
|
119
|
+
const { provider } = makeMockProvider(() => ({ exitCode: 0, result: 'MODE=git\n' }))
|
|
120
|
+
expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBe('git')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns "npm" when stdout contains MODE=npm', async () => {
|
|
124
|
+
const { provider } = makeMockProvider(() => ({ exitCode: 0, result: 'MODE=npm\n' }))
|
|
125
|
+
expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBe('npm')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns null when stdout contains MODE=none', async () => {
|
|
129
|
+
const { provider } = makeMockProvider(() => ({ exitCode: 0, result: 'MODE=none\n' }))
|
|
130
|
+
expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('returns null when exec throws (swallows network/auth errors)', async () => {
|
|
134
|
+
const { provider } = makeMockProvider(() => new Error('toolbox 500 internal'))
|
|
135
|
+
expect(await probeContainerBootstrapMode(provider, 'sbx-test')).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
})
|