@0xobelisk/sui-cli 1.2.0-pre.12 → 1.2.0-pre.120

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 (40) hide show
  1. package/README.md +7 -7
  2. package/dist/dubhe.js +152 -51
  3. package/dist/dubhe.js.map +1 -1
  4. package/package.json +31 -19
  5. package/src/commands/build.ts +61 -18
  6. package/src/commands/call.ts +83 -83
  7. package/src/commands/checkBalance.ts +27 -12
  8. package/src/commands/convertJson.ts +84 -0
  9. package/src/commands/doctor.ts +1515 -0
  10. package/src/commands/faucet.ts +20 -10
  11. package/src/commands/generate.ts +61 -0
  12. package/src/commands/generateKey.ts +3 -2
  13. package/src/commands/index.ts +20 -11
  14. package/src/commands/info.ts +61 -0
  15. package/src/commands/loadMetadata.ts +68 -0
  16. package/src/commands/localnode.ts +22 -6
  17. package/src/commands/publish.ts +55 -7
  18. package/src/commands/query.ts +101 -101
  19. package/src/commands/shell.ts +208 -0
  20. package/src/commands/{configStore.ts → storeConfig.ts} +13 -5
  21. package/src/commands/switchEnv.ts +33 -0
  22. package/src/commands/test.ts +143 -31
  23. package/src/commands/upgrade.ts +46 -6
  24. package/src/commands/wait.ts +333 -22
  25. package/src/commands/watch.ts +9 -8
  26. package/src/dubhe.ts +12 -4
  27. package/src/utils/axios-downloader.ts +116 -0
  28. package/src/utils/callHandler.ts +118 -118
  29. package/src/utils/checkBalance.ts +6 -2
  30. package/src/utils/constants.ts +9 -0
  31. package/src/utils/generateAccount.ts +1 -1
  32. package/src/utils/index.ts +4 -3
  33. package/src/utils/metadataHandler.ts +17 -0
  34. package/src/utils/publishHandler.ts +404 -289
  35. package/src/utils/queryStorage.ts +141 -141
  36. package/src/utils/startNode.ts +115 -16
  37. package/src/utils/storeConfig.ts +50 -10
  38. package/src/utils/upgradeHandler.ts +210 -86
  39. package/src/utils/utils.ts +1025 -63
  40. package/src/commands/schemagen.ts +0 -40
@@ -3,19 +3,50 @@ import { execSync } from 'child_process';
3
3
  import chalk from 'chalk';
4
4
  import {
5
5
  saveContractData,
6
- updateDubheDependency,
6
+ updateMoveTomlAddress,
7
7
  switchEnv,
8
8
  delay,
9
- getDubheSchemaId,
10
- initializeDubhe
9
+ getDubheDappHubId,
10
+ initializeDubhe,
11
+ saveMetadata,
12
+ getOriginalDubhePackageId,
13
+ updatePublishedToml,
14
+ syncDubheFrameworkAddress,
15
+ updateEphemeralPubFile,
16
+ getEphemeralPubFilePath,
17
+ getPublishedTomlEntry,
18
+ clearPublishedTomlEntry,
19
+ restorePublishedTomlEntry
11
20
  } from './utils';
12
21
  import { DubheConfig } from '@0xobelisk/sui-common';
13
22
  import * as fs from 'fs';
14
23
  import * as path from 'path';
15
24
 
16
- const MAX_RETRIES = 60; // 60s timeout
17
- const RETRY_INTERVAL = 1000; // 1s retry interval
18
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
25
+ /**
26
+ * Temporarily add localnet to Move.toml [environments] section before building.
27
+ * Sui CLI 1.40+ requires the active environment to be declared in Move.toml even
28
+ * when --build-env is specified. This patches the file and returns the original
29
+ * content so the caller can restore it in a finally block.
30
+ * Returns null if no changes were needed.
31
+ */
32
+ function patchMoveTomlWithLocalnetEnv(moveTomlPath: string, chainId: string): string | null {
33
+ if (!fs.existsSync(moveTomlPath)) return null;
34
+ const content = fs.readFileSync(moveTomlPath, 'utf-8');
35
+
36
+ if (content.includes('localnet')) {
37
+ return null;
38
+ }
39
+
40
+ let updatedContent: string;
41
+ if (content.includes('[environments]')) {
42
+ updatedContent = content.replace('[environments]', `[environments]\nlocalnet = "${chainId}"`);
43
+ } else {
44
+ updatedContent = content.trimEnd() + `\n\n[environments]\nlocalnet = "${chainId}"\n`;
45
+ }
46
+
47
+ fs.writeFileSync(moveTomlPath, updatedContent, 'utf-8');
48
+ return content;
49
+ }
19
50
 
20
51
  async function removeEnvContent(
21
52
  filePath: string,
@@ -127,21 +158,53 @@ published-version = "${config.publishedVersion}"
127
158
  // return segments.length > 0 ? segments[segments.length - 1] : '';
128
159
  // }
129
160
 
130
- function buildContract(projectPath: string): string[][] {
161
+ /**
162
+ * Build a Move package and return [modules, dependencies] as base64 arrays.
163
+ *
164
+ * For localnet (ephemeral) networks:
165
+ * - Uses --build-env testnet so dependency addresses are resolved via testnet
166
+ * Published.toml (no need to add 'localnet' to Move.toml [environments]).
167
+ * - Optionally reads a Pub.localnet.toml pubfile for already-published local deps.
168
+ * - This matches the Sui docs approach:
169
+ * https://docs.sui.io/guides/developer/packages/move-package-management
170
+ *
171
+ * For persistent networks (testnet/mainnet/devnet): uses -e <network> as before.
172
+ */
173
+ function buildContract(
174
+ projectPath: string,
175
+ network?: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
176
+ pubfilePath?: string
177
+ ): string[][] {
131
178
  let modules: any, dependencies: any;
132
179
  try {
180
+ let buildEnvFlag: string;
181
+ if (network === 'localnet') {
182
+ // Ephemeral approach: resolve deps via testnet configuration.
183
+ // --pubfile-path supplies already-published local dep addresses (e.g. dubhe).
184
+ buildEnvFlag = ' --build-env testnet';
185
+ if (pubfilePath) {
186
+ buildEnvFlag += ` --pubfile-path ${pubfilePath}`;
187
+ }
188
+ } else {
189
+ buildEnvFlag = network ? ` -e ${network}` : '';
190
+ }
191
+
192
+ // --no-tree-shaking avoids on-chain RPC calls during build.
133
193
  const buildResult = JSON.parse(
134
- execSync(`sui move build --dump-bytecode-as-base64 --path ${projectPath}`, {
135
- encoding: 'utf-8',
136
- stdio: 'pipe'
137
- })
194
+ execSync(
195
+ `sui move build --dump-bytecode-as-base64 --no-tree-shaking${buildEnvFlag} --path ${projectPath}`,
196
+ {
197
+ encoding: 'utf-8',
198
+ stdio: 'pipe'
199
+ }
200
+ )
138
201
  );
139
202
  modules = buildResult.modules;
140
203
  dependencies = buildResult.dependencies;
141
204
  } catch (error: any) {
142
205
  console.error(chalk.red(' └─ Build failed'));
143
- console.error(error.stdout);
144
- process.exit(1);
206
+ console.error(error.stdout || error.stderr);
207
+ throw new Error(`Build failed: ${error.stdout || error.stderr || error.message}`);
145
208
  }
146
209
  return [modules, dependencies];
147
210
  }
@@ -154,132 +217,28 @@ interface ObjectChange {
154
217
  }
155
218
 
156
219
  async function waitForNode(dubhe: Dubhe): Promise<string> {
157
- let retryCount = 0;
158
- let spinnerIndex = 0;
159
- const startTime = Date.now();
160
- let isInterrupted = false;
161
- let chainId = '';
162
- let hasShownBalanceWarning = false;
163
-
164
- const handleInterrupt = () => {
165
- isInterrupted = true;
166
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
167
- console.log('\n └─ Operation cancelled by user');
168
- process.exit(0);
169
- };
170
- process.on('SIGINT', handleInterrupt);
171
-
172
- try {
173
- // 第一阶段:等待获取 chainId
174
- while (retryCount < MAX_RETRIES && !isInterrupted && !chainId) {
175
- try {
176
- chainId = await dubhe.suiInteractor.currentClient.getChainIdentifier();
177
- } catch (error) {
178
- // 忽略错误,继续重试
179
- }
180
-
181
- if (isInterrupted) break;
182
-
183
- if (!chainId) {
184
- retryCount++;
185
- if (retryCount === MAX_RETRIES) {
186
- console.log(chalk.red(` └─ Failed to connect to node after ${MAX_RETRIES} attempts`));
187
- console.log(chalk.red(' └─ Please check if the Sui node is running.'));
188
- process.exit(1);
189
- }
190
-
191
- const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
192
- const spinner = SPINNER[spinnerIndex % SPINNER.length];
193
- spinnerIndex++;
194
-
195
- process.stdout.write(`\r ├─ ${spinner} Waiting for node... (${elapsedTime}s)`);
196
- await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL));
197
- }
198
- }
199
-
200
- // 显示 chainId
201
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
202
- console.log(` ├─ ChainId: ${chainId}`);
203
-
204
- // 第二阶段:检查部署账户余额
205
- retryCount = 0;
206
- while (retryCount < MAX_RETRIES && !isInterrupted) {
207
- try {
208
- const address = dubhe.getAddress();
209
- const coins = await dubhe.suiInteractor.currentClient.getCoins({
210
- owner: address,
211
- coinType: '0x2::sui::SUI'
212
- });
213
-
214
- if (coins.data.length > 0) {
215
- const balance = coins.data.reduce((sum, coin) => sum + Number(coin.balance), 0);
216
- if (balance > 0) {
217
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
218
- console.log(` ├─ Deployer balance: ${balance / 10 ** 9} SUI`);
219
- return chainId;
220
- } else if (!hasShownBalanceWarning) {
221
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
222
- console.log(
223
- chalk.yellow(
224
- ` ├─ Deployer balance: 0 SUI, please ensure your account has sufficient SUI balance`
225
- )
226
- );
227
- hasShownBalanceWarning = true;
228
- }
229
- } else if (!hasShownBalanceWarning) {
230
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
231
- console.log(
232
- chalk.yellow(
233
- ` ├─ No SUI coins found in deployer account, please ensure your account has sufficient SUI balance`
234
- )
235
- );
236
- hasShownBalanceWarning = true;
237
- }
238
-
239
- retryCount++;
240
- if (retryCount === MAX_RETRIES) {
241
- console.log(
242
- chalk.red(` └─ Deployer account has no SUI balance after ${MAX_RETRIES} attempts`)
243
- );
244
- console.log(chalk.red(' └─ Please ensure your account has sufficient SUI balance.'));
245
- process.exit(1);
246
- }
247
-
248
- const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
249
- const spinner = SPINNER[spinnerIndex % SPINNER.length];
250
- spinnerIndex++;
251
-
252
- process.stdout.write(`\r ├─ ${spinner} Checking deployer balance... (${elapsedTime}s)`);
253
- await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL));
254
- } catch (error) {
255
- if (isInterrupted) break;
256
-
257
- retryCount++;
258
- if (retryCount === MAX_RETRIES) {
259
- console.log(
260
- chalk.red(` └─ Failed to check deployer balance after ${MAX_RETRIES} attempts`)
261
- );
262
- console.log(chalk.red(' └─ Please check your account and network connection.'));
263
- process.exit(1);
264
- }
265
-
266
- const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
267
- const spinner = SPINNER[spinnerIndex % SPINNER.length];
268
- spinnerIndex++;
220
+ const chainId = await dubhe.suiInteractor.currentClient.getChainIdentifier();
221
+ console.log(` ├─ ChainId: ${chainId}`);
222
+ const address = dubhe.getAddress();
223
+ const coins = await dubhe.suiInteractor.currentClient.getCoins({
224
+ owner: address,
225
+ coinType: '0x2::sui::SUI'
226
+ });
269
227
 
270
- process.stdout.write(`\r ├─ ${spinner} Checking deployer balance... (${elapsedTime}s)`);
271
- await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL));
272
- }
228
+ if (coins.data.length > 0) {
229
+ const balance = coins.data.reduce((sum, coin) => sum + Number(coin.balance), 0);
230
+ if (balance > 0) {
231
+ console.log(` ├─ Deployer balance: ${balance / 10 ** 9} SUI`);
232
+ return chainId;
233
+ } else {
234
+ console.log(
235
+ chalk.yellow(
236
+ ` ├─ Deployer balance: 0 SUI, please ensure your account has sufficient SUI balance`
237
+ )
238
+ );
273
239
  }
274
- } finally {
275
- process.removeListener('SIGINT', handleInterrupt);
276
- }
277
-
278
- if (isInterrupted) {
279
- process.exit(0);
280
240
  }
281
-
282
- throw new Error('Failed to connect to node');
241
+ return chainId;
283
242
  }
284
243
 
285
244
  async function publishContract(
@@ -287,7 +246,9 @@ async function publishContract(
287
246
  dubheConfig: DubheConfig,
288
247
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
289
248
  projectPath: string,
290
- gasBudget?: number
249
+ gasBudget?: number,
250
+ force?: boolean,
251
+ fullnodeUrls?: string[]
291
252
  ) {
292
253
  console.log('\n🚀 Starting Contract Publication...');
293
254
  console.log(` ├─ Project: ${projectPath}`);
@@ -300,8 +261,116 @@ async function publishContract(
300
261
  await removeEnvContent(`${projectPath}/Move.lock`, network);
301
262
  console.log(` └─ Account: ${dubhe.getAddress()}`);
302
263
 
264
+ // Ensure src/dubhe/Published.toml references the canonical framework address
265
+ // for this network before building. This is a no-op when the address is
266
+ // already current, and automatically corrects stale entries whenever the
267
+ // framework is redeployed on testnet/mainnet without a manual file update.
268
+ if (dubheConfig.name !== 'dubhe') {
269
+ syncDubheFrameworkAddress(process.cwd(), network, chainId);
270
+ }
271
+
303
272
  console.log('\n📦 Building Contract...');
304
- const [modules, dependencies] = buildContract(projectPath);
273
+ // For localnet: pass the ephemeral pubfile so the build system can resolve
274
+ // the dubhe dependency that was just published in publishDubheFramework().
275
+ // If the file was written for a different chain (node restarted with
276
+ // --force-regenesis), discard it to avoid a chain-id mismatch build error.
277
+ let pubfilePath =
278
+ network === 'localnet' ? getEphemeralPubFilePath(process.cwd(), network) : undefined;
279
+ if (pubfilePath && fs.existsSync(pubfilePath)) {
280
+ const pubfileContent = fs.readFileSync(pubfilePath, 'utf-8');
281
+ const chainIdMatch = pubfileContent.match(/^chain-id\s*=\s*"([^"]*)"/m);
282
+ const pubfileChainId = chainIdMatch ? chainIdMatch[1] : '';
283
+ if (pubfileChainId && pubfileChainId !== chainId) {
284
+ console.log(
285
+ chalk.yellow(
286
+ ` ├─ Stale Pub.localnet.toml (chain ${pubfileChainId} → ${chainId}), discarding`
287
+ )
288
+ );
289
+ fs.unlinkSync(pubfilePath);
290
+ pubfilePath = undefined;
291
+ }
292
+ }
293
+
294
+ // Move.toml paths — declared early so both the Published.toml handling block and
295
+ // the localnet env-patching block can reference them.
296
+ const contractMoveTomlPath = `${projectPath}/Move.toml`;
297
+ const dubheMoveTomlPath = path.join(path.dirname(projectPath), 'dubhe', 'Move.toml');
298
+ let savedContractMoveToml: string | null = null;
299
+ let savedDubheMoveToml: string | null = null;
300
+
301
+ // So the build uses package address 0x0: for localnet always remove the contract's
302
+ // Published.toml; for testnet/mainnet/devnet only when --force (clear current network entry).
303
+ // Otherwise Sui CLI bakes the existing [published.<network>] address into the bytecode and
304
+ // the chain rejects with PublishErrorNonZeroAddress.
305
+ const contractPublishedTomlPath = `${projectPath}/Published.toml`;
306
+ let savedContractPublishedToml: string | null = null;
307
+ let savedContractPublishedEntry: {
308
+ network: string;
309
+ entry: Exclude<ReturnType<typeof getPublishedTomlEntry>, undefined>;
310
+ } | null = null;
311
+ if (network === 'localnet' && fs.existsSync(contractPublishedTomlPath)) {
312
+ savedContractPublishedToml = fs.readFileSync(contractPublishedTomlPath, 'utf-8');
313
+ fs.unlinkSync(contractPublishedTomlPath);
314
+ } else if (network === 'testnet' || network === 'mainnet' || network === 'devnet') {
315
+ const entry = getPublishedTomlEntry(projectPath, network);
316
+ if (entry && force) {
317
+ // Existing entry + --force: clear it so the build uses 0x0 instead of the old address.
318
+ savedContractPublishedEntry = { network, entry };
319
+ clearPublishedTomlEntry(projectPath, network);
320
+ } else if (!entry) {
321
+ // No Published.toml entry for this network (first-time deploy to this network).
322
+ // The Sui CLI has no per-network override and falls back to Move.toml's [addresses]
323
+ // value, which may be non-zero from a previous deployment on a different network,
324
+ // causing PublishErrorNonZeroAddress.
325
+ // Temporarily zero out Move.toml before building so the self-address is 0x0.
326
+ savedContractMoveToml = fs.readFileSync(contractMoveTomlPath, 'utf-8');
327
+ updateMoveTomlAddress(projectPath, '0x0');
328
+ }
329
+ }
330
+
331
+ // For localnet: also temporarily remove dubhe's Published.toml when building the
332
+ // contract package. After publishDubheFramework restores dubhe/Published.toml, its
333
+ // testnet entry's original-id may be "0x0", which collides with the contract package's
334
+ // own address (also 0x0 before publish), triggering a spurious cyclic-dependency error.
335
+ const dubhePublishedTomlPath = path.join(path.dirname(projectPath), 'dubhe', 'Published.toml');
336
+ let savedDubhePublishedToml: string | null = null;
337
+ if (network === 'localnet' && fs.existsSync(dubhePublishedTomlPath)) {
338
+ savedDubhePublishedToml = fs.readFileSync(dubhePublishedTomlPath, 'utf-8');
339
+ fs.unlinkSync(dubhePublishedTomlPath);
340
+ }
341
+
342
+ // Sui CLI 1.40+ checks that the active environment is declared in Move.toml
343
+ // even when --build-env is specified. Temporarily inject localnet into [environments]
344
+ // for both the contract and its dubhe dependency.
345
+ if (network === 'localnet') {
346
+ savedContractMoveToml = patchMoveTomlWithLocalnetEnv(contractMoveTomlPath, chainId);
347
+ savedDubheMoveToml = patchMoveTomlWithLocalnetEnv(dubheMoveTomlPath, chainId);
348
+ }
349
+
350
+ let modules: any, dependencies: any;
351
+ try {
352
+ [modules, dependencies] = buildContract(projectPath, network, pubfilePath);
353
+ } finally {
354
+ if (savedContractPublishedToml !== null) {
355
+ fs.writeFileSync(contractPublishedTomlPath, savedContractPublishedToml, 'utf-8');
356
+ }
357
+ if (savedContractPublishedEntry !== null) {
358
+ restorePublishedTomlEntry(
359
+ projectPath,
360
+ savedContractPublishedEntry.network,
361
+ savedContractPublishedEntry.entry
362
+ );
363
+ }
364
+ if (savedDubhePublishedToml !== null) {
365
+ fs.writeFileSync(dubhePublishedTomlPath, savedDubhePublishedToml, 'utf-8');
366
+ }
367
+ if (savedContractMoveToml !== null) {
368
+ fs.writeFileSync(contractMoveTomlPath, savedContractMoveToml, 'utf-8');
369
+ }
370
+ if (savedDubheMoveToml !== null) {
371
+ fs.writeFileSync(dubheMoveTomlPath, savedDubheMoveToml, 'utf-8');
372
+ }
373
+ }
305
374
 
306
375
  console.log('\n🔄 Publishing Contract...');
307
376
  const tx = new Transaction();
@@ -311,63 +380,38 @@ async function publishContract(
311
380
  const [upgradeCap] = tx.publish({ modules, dependencies });
312
381
  tx.transferObjects([upgradeCap], dubhe.getAddress());
313
382
 
314
- let result: any = null;
315
- let retryCount = 0;
316
- let spinnerIndex = 0;
317
- const startTime = Date.now();
318
- let isInterrupted = false;
319
-
320
- const handleInterrupt = () => {
321
- isInterrupted = true;
322
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
323
- console.log('\n └─ Operation cancelled by user');
324
- process.exit(0);
325
- };
326
- process.on('SIGINT', handleInterrupt);
327
-
383
+ let result;
328
384
  try {
329
- while (retryCount < MAX_RETRIES && !result && !isInterrupted) {
330
- try {
331
- result = await dubhe.signAndSendTxn({ tx });
332
- } catch (error) {
333
- if (isInterrupted) break;
334
-
335
- retryCount++;
336
- if (retryCount === MAX_RETRIES) {
337
- console.log(chalk.red(` └─ Publication failed after ${MAX_RETRIES} attempts`));
338
- console.log(chalk.red(' └─ Please check your network connection and try again later.'));
339
- process.exit(1);
340
- }
341
-
342
- const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
343
- const spinner = SPINNER[spinnerIndex % SPINNER.length];
344
- spinnerIndex++;
345
-
346
- process.stdout.write(`\r ├─ ${spinner} Retrying... (${elapsedTime}s)`);
347
- await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL));
348
- }
385
+ result = await dubhe.signAndSendTxn({ tx });
386
+ } catch (error: any) {
387
+ console.error(chalk.red(' └─ Publication failed'));
388
+ console.error(error.message);
389
+ if (
390
+ !force &&
391
+ (network === 'testnet' || network === 'mainnet' || network === 'devnet') &&
392
+ /PublishErrorNonZeroAddress/i.test(String(error?.message))
393
+ ) {
394
+ console.error(
395
+ chalk.yellow(
396
+ ' Tip: This package may already be published on this network. Use --force to clear the stored address and publish as new, or use "dubhe upgrade" to update the existing package.'
397
+ )
398
+ );
349
399
  }
350
- } finally {
351
- process.removeListener('SIGINT', handleInterrupt);
400
+ throw new Error(`Contract publication failed: ${error.message}`);
352
401
  }
353
402
 
354
- if (isInterrupted) {
355
- process.exit(0);
356
- }
357
-
358
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
359
-
360
403
  if (!result || result.effects?.status.status === 'failure') {
361
- console.log(chalk.red(' └─ Publication failed'));
362
- process.exit(1);
404
+ throw new Error('Contract publication transaction failed');
363
405
  }
364
406
 
365
407
  console.log(' ├─ Processing publication results...');
366
408
  let version = 1;
367
409
  let packageId = '';
368
- let schemaId = '';
369
- let schemas = dubheConfig.schemas;
410
+ let dappHubId = '';
411
+ let resources = dubheConfig.resources ?? {};
412
+ let enums = dubheConfig.enums;
370
413
  let upgradeCapId = '';
414
+ let startCheckpoint = '';
371
415
 
372
416
  let printObjects: any[] = [];
373
417
 
@@ -384,6 +428,13 @@ async function publishContract(
384
428
  console.log(` ├─ Upgrade Cap: ${object.objectId}`);
385
429
  upgradeCapId = object.objectId || '';
386
430
  }
431
+ if (
432
+ object.type === 'created' &&
433
+ object.objectType &&
434
+ object.objectType.includes('dapp_service::DappHub')
435
+ ) {
436
+ dappHubId = object.objectId || '';
437
+ }
387
438
  if (object.type === 'created') {
388
439
  printObjects.push(object);
389
440
  }
@@ -392,19 +443,25 @@ async function publishContract(
392
443
  console.log(` └─ Transaction: ${result.digest}`);
393
444
 
394
445
  updateEnvFile(`${projectPath}/Move.lock`, network, 'publish', chainId, packageId);
446
+ updatePublishedToml(projectPath, network, chainId, packageId, packageId, 1);
395
447
 
396
448
  console.log('\n⚡ Executing Deploy Hook...');
397
449
  await delay(5000);
398
450
 
451
+ startCheckpoint = await dubhe.suiInteractor.currentClient.getLatestCheckpointSequenceNumber();
452
+
399
453
  const deployHookTx = new Transaction();
400
454
  let args = [];
455
+ let frameworkDappHubId =
456
+ dubheConfig.name === 'dubhe' ? dappHubId : await getDubheDappHubId(network);
457
+ args.push(deployHookTx.object(frameworkDappHubId));
458
+ // Dubhe framework genesis::run(dapp_hub, ctx) does not take clock.
459
+ // DApp genesis::run(dapp_hub, clock, ctx) still takes clock.
401
460
  if (dubheConfig.name !== 'dubhe') {
402
- let dubheSchemaId = await getDubheSchemaId(network);
403
- args.push(deployHookTx.object(dubheSchemaId));
461
+ args.push(deployHookTx.object('0x6'));
404
462
  }
405
- args.push(deployHookTx.object('0x6'));
406
463
  deployHookTx.moveCall({
407
- target: `${packageId}::${dubheConfig.name}_genesis::run`,
464
+ target: `${packageId}::genesis::run`,
408
465
  arguments: args
409
466
  });
410
467
 
@@ -414,53 +471,78 @@ async function publishContract(
414
471
  } catch (error: any) {
415
472
  console.error(chalk.red(' └─ Deploy hook execution failed'));
416
473
  console.error(error.message);
417
- process.exit(1);
474
+ throw new Error(`genesis::run failed: ${error.message}`);
418
475
  }
419
476
 
420
477
  if (deployHookResult.effects?.status.status === 'success') {
421
478
  console.log(' ├─ Hook execution successful');
422
479
  console.log(` ├─ Transaction: ${deployHookResult.digest}`);
423
480
 
424
- console.log('\n📋 Created Objects:');
425
- deployHookResult.objectChanges?.map((object: ObjectChange) => {
481
+ // Capture the DappStorage object created by genesis::run so we can persist
482
+ // its ID for later use in migrate_to_vN transactions during upgrades.
483
+ let dappStorageId = '';
484
+ deployHookResult.objectChanges!.map((object: ObjectChange) => {
426
485
  if (
427
486
  object.type === 'created' &&
428
487
  object.objectType &&
429
- object.objectType.includes('schema::Schema')
488
+ object.objectType.includes('dapp_service::DappStorage')
430
489
  ) {
431
- schemaId = object.objectId || '';
432
- }
433
- if (
434
- object.type === 'created' &&
435
- object.objectType &&
436
- object.objectType.includes('schema') &&
437
- !object.objectType.includes('dynamic_field')
438
- ) {
439
- printObjects.push(object);
490
+ dappStorageId = object.objectId || '';
491
+ console.log(` ├─ DappStorage: ${dappStorageId}`);
440
492
  }
441
493
  });
442
494
 
495
+ console.log('\n📋 Created Objects:');
443
496
  printObjects.map((object: ObjectChange) => {
444
- console.log(` ├─ Type: ${object.objectType}`);
445
- console.log(` └─ ID: ${object.objectId}`);
497
+ console.log(` ├─ ID: ${object.objectId}`);
498
+ console.log(` └─ Type: ${object.objectType}`);
446
499
  });
447
500
 
448
- saveContractData(
501
+ await saveContractData(
449
502
  dubheConfig.name,
450
503
  network,
504
+ startCheckpoint,
451
505
  packageId,
452
- schemaId,
506
+ packageId, // originalPackageId: first publish, so original == current
507
+ frameworkDappHubId,
453
508
  upgradeCapId,
454
509
  version,
455
- schemas
510
+ resources,
511
+ enums,
512
+ // localnet: persist the locally deployed framework ID so the SDK can be
513
+ // initialised without hardcoding it. testnet/mainnet use a well-known
514
+ // constant already embedded in the SDK defaults, so we store undefined.
515
+ network === 'localnet' ? await getOriginalDubhePackageId(network) : undefined,
516
+ dappStorageId || undefined
456
517
  );
518
+
519
+ await saveMetadata(dubheConfig.name, network, packageId, fullnodeUrls);
520
+
521
+ // Insert package id to dubhe config
522
+ let config = JSON.parse(fs.readFileSync(`${process.cwd()}/dubhe.config.json`, 'utf-8'));
523
+ config.original_package_id = packageId;
524
+ config.dubhe_object_id = frameworkDappHubId;
525
+ // When deploying the dubhe framework itself, the "original dubhe package ID" is
526
+ // the package we just published. For user packages, look up the well-known
527
+ // framework address for the target network from the client config.
528
+ config.original_dubhe_package_id =
529
+ dubheConfig.name === 'dubhe' ? packageId : await getOriginalDubhePackageId(network);
530
+ config.start_checkpoint = startCheckpoint;
531
+ // Canonical dapp_key type string: stable across upgrades, no "0x" prefix, padded to 64 hex chars.
532
+ // Matches the Move type_name::with_defining_ids<DappKey>().into_string() format.
533
+ const pkgHex = packageId.replace(/^0x/i, '').padStart(64, '0');
534
+ config.dapp_key = `${pkgHex}::dapp_key::DappKey`;
535
+ // Persist the DappStorage object ID so store-config can include it in deployment.ts
536
+ // and upgrade transactions can reference it without reading from .history.
537
+ if (dappStorageId) {
538
+ config.dapp_storage_id = dappStorageId;
539
+ }
540
+
541
+ fs.writeFileSync(`${process.cwd()}/dubhe.config.json`, JSON.stringify(config, null, 2));
542
+
457
543
  console.log('\n✅ Contract Publication Complete\n');
458
544
  } else {
459
- console.log(chalk.yellow(' └─ Deploy hook execution failed'));
460
- console.log(chalk.yellow(' Please republish or manually call deploy_hook::run'));
461
- console.log(chalk.yellow(' Please check the transaction digest:'));
462
- console.log(chalk.yellow(` ${deployHookResult.digest}`));
463
- process.exit(1);
545
+ throw new Error(`genesis::run transaction failed. Digest: ${deployHookResult.digest}`);
464
546
  }
465
547
  }
466
548
 
@@ -469,15 +551,13 @@ async function checkDubheFramework(projectPath: string): Promise<boolean> {
469
551
  console.log(chalk.yellow('\nℹ️ Dubhe Framework Files Not Found'));
470
552
  console.log(chalk.yellow(' ├─ Expected Path:'), projectPath);
471
553
  console.log(chalk.yellow(' ├─ To set up Dubhe Framework:'));
472
- console.log(chalk.yellow(' │ 1. Create directory: mkdir -p contracts/dubhe'));
554
+ console.log(chalk.yellow(' │ 1. Create directory: mkdir -p src/dubhe'));
473
555
  console.log(
474
556
  chalk.yellow(
475
- ' │ 2. Clone repository: git clone https://github.com/0xobelisk/dubhe contracts/dubhe'
557
+ ' │ 2. Clone repository: git clone https://github.com/0xobelisk/dubhe src/dubhe'
476
558
  )
477
559
  );
478
- console.log(
479
- chalk.yellow(' │ 3. Or download from: https://github.com/0xobelisk/dubhe')
480
- );
560
+ console.log(chalk.yellow(' │ 3. Or download from: https://github.com/0xobelisk/dubhe'));
481
561
  console.log(chalk.yellow(' └─ After setup, restart the local node'));
482
562
  return false;
483
563
  }
@@ -488,8 +568,8 @@ export async function publishDubheFramework(
488
568
  dubhe: Dubhe,
489
569
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet'
490
570
  ) {
491
- const path = process.cwd();
492
- const projectPath = `${path}/src/dubhe`;
571
+ const cwd = process.cwd();
572
+ const projectPath = `${cwd}/src/dubhe`;
493
573
 
494
574
  if (!(await checkDubheFramework(projectPath))) {
495
575
  return;
@@ -501,66 +581,72 @@ export async function publishDubheFramework(
501
581
  const chainId = await waitForNode(dubhe);
502
582
 
503
583
  await removeEnvContent(`${projectPath}/Move.lock`, network);
504
- const [modules, dependencies] = buildContract(projectPath);
505
- const tx = new Transaction();
506
- const [upgradeCap] = tx.publish({ modules, dependencies });
507
- tx.transferObjects([upgradeCap], dubhe.getAddress());
584
+ if (network === 'localnet') {
585
+ // When building with --build-env testnet, Sui CLI reads Move.lock's [env.testnet] section
586
+ // and bakes its original-published-id (non-zero for a previously published dubhe) into the
587
+ // bytecode as the package self-address. Publishing then fails with PublishErrorNonZeroAddress
588
+ // because Sui requires the self-address to be 0x0 for a first-time publish.
589
+ // Fix: clear the testnet env section before building so the CLI uses 0x0 from Move.toml.
590
+ await removeEnvContent(`${projectPath}/Move.lock`, 'testnet');
591
+ }
592
+ await updateMoveTomlAddress(projectPath, '0x0');
593
+
594
+ const startCheckpoint =
595
+ await dubhe.suiInteractor.currentClient.getLatestCheckpointSequenceNumber();
596
+
597
+ // For localnet: --build-env testnet is used to resolve git dependencies, but the
598
+ // Move CLI will also read Published.toml and use any existing testnet address for
599
+ // dubhe — causing PublishErrorNonZeroAddress if a testnet entry already exists.
600
+ // Fix: temporarily remove Published.toml before the build, then restore it.
601
+ // This ensures the dubhe package compiles with address 0x0 (from Move.toml).
602
+ const publishedTomlPath = `${projectPath}/Published.toml`;
603
+ let savedPublishedTomlContent: string | null = null;
604
+ if (network === 'localnet' && fs.existsSync(publishedTomlPath)) {
605
+ savedPublishedTomlContent = fs.readFileSync(publishedTomlPath, 'utf-8');
606
+ fs.unlinkSync(publishedTomlPath);
607
+ }
508
608
 
509
- let result: any = null;
510
- let retryCount = 0;
511
- let spinnerIndex = 0;
512
- const startTime = Date.now();
513
- let isInterrupted = false;
514
-
515
- const handleInterrupt = () => {
516
- isInterrupted = true;
517
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
518
- console.log('\n └─ Operation cancelled by user');
519
- process.exit(0);
520
- };
521
- process.on('SIGINT', handleInterrupt);
609
+ // Sui CLI 1.40+ checks that the active environment is declared in Move.toml
610
+ // even when --build-env is specified. Temporarily inject localnet into [environments].
611
+ const moveTomlPath = `${projectPath}/Move.toml`;
612
+ let savedMoveTomlContent: string | null = null;
613
+ if (network === 'localnet') {
614
+ savedMoveTomlContent = patchMoveTomlWithLocalnetEnv(moveTomlPath, chainId);
615
+ }
522
616
 
617
+ let modules: any, dependencies: any;
523
618
  try {
524
- while (retryCount < MAX_RETRIES && !result && !isInterrupted) {
525
- try {
526
- result = await dubhe.signAndSendTxn({ tx });
527
- } catch (error) {
528
- if (isInterrupted) break;
529
-
530
- retryCount++;
531
- if (retryCount === MAX_RETRIES) {
532
- console.log(chalk.red(` └─ Publication failed after ${MAX_RETRIES} attempts`));
533
- console.log(chalk.red(' └─ Please check your network connection and try again later.'));
534
- process.exit(1);
535
- }
536
-
537
- const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
538
- const spinner = SPINNER[spinnerIndex % SPINNER.length];
539
- spinnerIndex++;
540
-
541
- process.stdout.write(`\r ├─ ${spinner} Retrying... (${elapsedTime}s)`);
542
- await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL));
543
- }
544
- }
619
+ [modules, dependencies] = buildContract(projectPath, network);
545
620
  } finally {
546
- process.removeListener('SIGINT', handleInterrupt);
621
+ // Always restore Published.toml and Move.toml (successful build or error)
622
+ if (savedPublishedTomlContent !== null) {
623
+ fs.writeFileSync(publishedTomlPath, savedPublishedTomlContent, 'utf-8');
624
+ }
625
+ if (savedMoveTomlContent !== null) {
626
+ fs.writeFileSync(moveTomlPath, savedMoveTomlContent, 'utf-8');
627
+ }
547
628
  }
548
629
 
549
- if (isInterrupted) {
550
- process.exit(0);
551
- }
630
+ const tx = new Transaction();
631
+ const [upgradeCap] = tx.publish({ modules, dependencies });
632
+ tx.transferObjects([upgradeCap], dubhe.getAddress());
552
633
 
553
- process.stdout.write('\r' + ' '.repeat(50) + '\r');
634
+ let result;
635
+ try {
636
+ result = await dubhe.signAndSendTxn({ tx });
637
+ } catch (error: any) {
638
+ console.error(chalk.red(' └─ Publication failed'));
639
+ console.error(error.message);
640
+ throw new Error(`Dubhe framework publication failed: ${error.message}`);
641
+ }
554
642
 
555
643
  if (!result || result.effects?.status.status === 'failure') {
556
- console.log(chalk.red(' └─ Publication failed'));
557
- process.exit(1);
644
+ throw new Error('Dubhe framework publication transaction failed');
558
645
  }
559
646
 
560
647
  let version = 1;
561
648
  let packageId = '';
562
- let schemaId = '';
563
- let schemas: Record<string, string> = {};
649
+ let dappHubId = '';
564
650
  let upgradeCapId = '';
565
651
 
566
652
  result.objectChanges!.map((object: ObjectChange) => {
@@ -574,14 +660,21 @@ export async function publishDubheFramework(
574
660
  ) {
575
661
  upgradeCapId = object.objectId || '';
576
662
  }
663
+ if (
664
+ object.type === 'created' &&
665
+ object.objectType &&
666
+ object.objectType.includes('dapp_service::DappHub')
667
+ ) {
668
+ dappHubId = object.objectId || '';
669
+ }
577
670
  });
578
671
 
579
672
  await delay(3000);
580
-
581
673
  const deployHookTx = new Transaction();
674
+ // Dubhe framework genesis::run(dapp_hub, ctx) — clock no longer required.
582
675
  deployHookTx.moveCall({
583
- target: `${packageId}::dubhe_genesis::run`,
584
- arguments: [deployHookTx.object('0x6')]
676
+ target: `${packageId}::genesis::run`,
677
+ arguments: [deployHookTx.object(dappHubId)]
585
678
  });
586
679
 
587
680
  let deployHookResult;
@@ -590,44 +683,66 @@ export async function publishDubheFramework(
590
683
  } catch (error: any) {
591
684
  console.error(chalk.red(' └─ Deploy hook execution failed'));
592
685
  console.error(error.message);
593
- process.exit(1);
686
+ throw new Error(`Dubhe genesis::run failed: ${error.message}`);
594
687
  }
595
688
 
596
- if (deployHookResult.effects?.status.status === 'success') {
597
- deployHookResult.objectChanges?.map((object: ObjectChange) => {
598
- if (
599
- object.type === 'created' &&
600
- object.objectType &&
601
- object.objectType.includes('dubhe_schema::Schema')
602
- ) {
603
- schemaId = object.objectId || '';
604
- }
605
- });
689
+ if (deployHookResult.effects?.status.status !== 'success') {
690
+ throw new Error('Deploy hook execution failed');
606
691
  }
607
692
 
608
- saveContractData('dubhe', network, packageId, schemaId, upgradeCapId, version, schemas);
693
+ await updateMoveTomlAddress(projectPath, packageId);
694
+ await saveContractData(
695
+ 'dubhe',
696
+ network,
697
+ startCheckpoint,
698
+ packageId,
699
+ packageId, // originalPackageId: first publish, original == current
700
+ dappHubId,
701
+ upgradeCapId,
702
+ version,
703
+ {},
704
+ {},
705
+ // Store the localnet framework package ID so other packages can read it
706
+ // from deployment JSON and pass it to the Dubhe client constructor.
707
+ network === 'localnet' ? packageId : undefined
708
+ );
609
709
 
610
710
  updateEnvFile(`${projectPath}/Move.lock`, network, 'publish', chainId, packageId);
611
- await delay(1000);
711
+ updatePublishedToml(projectPath, network, chainId, packageId, packageId, 1);
712
+
713
+ // For localnet: write dubhe's published address to Pub.localnet.toml so that
714
+ // the counter package build (next step) can resolve the dubhe dependency.
715
+ if (network === 'localnet') {
716
+ const pubfilePath = getEphemeralPubFilePath(cwd, network);
717
+ updateEphemeralPubFile(pubfilePath, chainId, 'testnet', {
718
+ source: projectPath,
719
+ publishedAt: packageId,
720
+ originalId: packageId,
721
+ upgradeCap: upgradeCapId
722
+ });
723
+ }
612
724
  }
613
725
 
614
726
  export async function publishHandler(
615
727
  dubheConfig: DubheConfig,
616
728
  network: 'mainnet' | 'testnet' | 'devnet' | 'localnet',
617
- gasBudget?: number
729
+ force: boolean,
730
+ gasBudget?: number,
731
+ fullnodeUrls?: string[]
618
732
  ) {
619
- await switchEnv(network);
733
+ await switchEnv(network, fullnodeUrls?.[0]);
620
734
 
621
735
  const dubhe = initializeDubhe({
622
- network
736
+ network,
737
+ fullnodeUrls
623
738
  });
624
739
 
625
- if (network === 'localnet') {
740
+ const path = process.cwd();
741
+ const projectPath = `${path}/src/${dubheConfig.name}`;
742
+
743
+ if (network === 'localnet' && dubheConfig.name !== 'dubhe') {
626
744
  await publishDubheFramework(dubhe, network);
627
745
  }
628
746
 
629
- const path = process.cwd();
630
- const projectPath = `${path}/src/${dubheConfig.name}`;
631
- await updateDubheDependency(`${projectPath}/Move.toml`, network);
632
- await publishContract(dubhe, dubheConfig, network, projectPath, gasBudget);
747
+ await publishContract(dubhe, dubheConfig, network, projectPath, gasBudget, force, fullnodeUrls);
633
748
  }