nebulon-escrow-cli 0.1.0

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.
@@ -0,0 +1,4895 @@
1
+
2
+ const { prompt } = require("enquirer");
3
+ const chalk = require("chalk");
4
+ const anchor = require("@coral-xyz/anchor");
5
+ const crypto = require("crypto");
6
+ const nacl = require("tweetnacl");
7
+ const {
8
+ PublicKey,
9
+ Keypair,
10
+ SystemProgram,
11
+ Transaction,
12
+ Connection,
13
+ } = require("@solana/web3.js");
14
+ const {
15
+ createAssociatedTokenAccountIdempotent,
16
+ getAssociatedTokenAddress,
17
+ getAccount,
18
+ TOKEN_PROGRAM_ID,
19
+ ASSOCIATED_TOKEN_PROGRAM_ID,
20
+ } = require("@solana/spl-token");
21
+ const { SessionTokenManager } = require("@magicblock-labs/gum-sdk");
22
+ const {
23
+ createDelegatePermissionInstruction,
24
+ permissionPdaFromAccount,
25
+ PERMISSION_PROGRAM_ID,
26
+ AUTHORITY_FLAG,
27
+ TX_LOGS_FLAG,
28
+ MAGIC_PROGRAM_ID,
29
+ MAGIC_CONTEXT_ID,
30
+ DELEGATION_PROGRAM_ID,
31
+ ConnectionMagicRouter,
32
+ getAuthToken,
33
+ getPermissionStatus,
34
+ waitUntilPermissionActive,
35
+ } = require("@magicblock-labs/ephemeral-rollups-sdk");
36
+ const { loadConfig, saveConfig } = require("../config");
37
+ const { loadWalletKeypair } = require("../wallets");
38
+ const { ensureHostedSession } = require("../session");
39
+ const { successMessage, errorMessage } = require("../ui");
40
+ const { checkTeeAvailability } = require("./tee");
41
+ const {
42
+ getContracts,
43
+ getContract,
44
+ getContractKeys,
45
+ createInvite,
46
+ setContractKey,
47
+ updateContract,
48
+ lockContract,
49
+ signContract,
50
+ markFunded,
51
+ refreshContract,
52
+ linkEscrow,
53
+ rateContract,
54
+ } = require("../hosted");
55
+ const {
56
+ formatAmount,
57
+ getConnection,
58
+ getSolBalance,
59
+ getTokenBalance,
60
+ toPublicKey,
61
+ getEscrowState,
62
+ getMilestoneState,
63
+ } = require("../solana");
64
+ const {
65
+ getProgram,
66
+ deriveMilestonePda,
67
+ derivePrivateMilestonePda,
68
+ deriveEscrowPda,
69
+ deriveTermsPda,
70
+ derivePerVaultPda,
71
+ deriveDisputePda,
72
+ textToHash,
73
+ } = require("../nebulon");
74
+ const {
75
+ ensureContractKeypair,
76
+ deriveContractKey,
77
+ encryptPayload,
78
+ decryptPayload,
79
+ isEncryptedPayload,
80
+ } = require("../privacy");
81
+
82
+ const FEE_RECEIVER = new PublicKey(
83
+ "w8sdYr2sM1dfyD7vsTt6EXcQWQ1mfNWfQJMzQNNnUXq"
84
+ );
85
+ const LOCAL_VALIDATOR_IDENTITY = new PublicKey(
86
+ "mAGicPQYBMvcYveUZA5F5UNNwyHvfYh5xkLS2Fr1mev"
87
+ );
88
+ const FEE_BPS = 200n;
89
+ const BPS_DENOMINATOR = 10_000n;
90
+
91
+ const ACTIVE_STATUSES = new Set([
92
+ "waiting_for_milestones_report",
93
+ "in_progress",
94
+ "ready_to_claim",
95
+ ]);
96
+
97
+ const EXCLUDE_INVITE_STATUSES = new Set([
98
+ "pending_invite",
99
+ "invite_expired",
100
+ "invite_canceled",
101
+ ]);
102
+
103
+ const ROLE_CHOICES = [
104
+ {
105
+ name: "client",
106
+ message: `Im the Client ${chalk.gray("(Ill fund this contract)")}`,
107
+ value: "client",
108
+ },
109
+ {
110
+ name: "contractor",
111
+ message: `Im the Service Provider ${chalk.gray(
112
+ "(The other user will fund this contract)"
113
+ )}`,
114
+ value: "contractor",
115
+ },
116
+ ];
117
+
118
+ const SHARE_CHOICES = [
119
+ { name: "direct", message: "invite directly by Nebulon ID", value: "direct" },
120
+ { name: "link", message: "generate an invitation link", value: "link" },
121
+ { name: "code", message: "generate a contract code", value: "code" },
122
+ ];
123
+
124
+ const isPromptCancel = (error) => {
125
+ if (!error) {
126
+ return true;
127
+ }
128
+ if (typeof error === "string") {
129
+ return error.trim() === "";
130
+ }
131
+ const message = (error.message || "").toLowerCase();
132
+ return (
133
+ message.includes("cancel") ||
134
+ message.includes("aborted") ||
135
+ message.includes("terminated")
136
+ );
137
+ };
138
+
139
+ const normalizeHandle = (value) =>
140
+ value.trim().toLowerCase().replace(/^@/, "");
141
+
142
+ let cachedValidator = null;
143
+ let cachedValidatorEndpoint = null;
144
+
145
+ const normalizeEndpoint = (endpoint) => endpoint.replace(/\/$/, "");
146
+
147
+ const TEE_TOKEN_TTL_SECONDS = 240;
148
+ const teeTokenCache = new Map();
149
+ const teeWsHealthCache = new Map();
150
+ const teeProgramCache = new Map();
151
+
152
+ const isLocalnetConfig = (config) => {
153
+ const network = (config?.network || config?.solanaNetwork || "").toLowerCase();
154
+ if (network === "localnet") {
155
+ return true;
156
+ }
157
+ const rpc = String(config?.rpcUrl || "");
158
+ const er = String(config?.ephemeralProviderUrl || "");
159
+ return (
160
+ rpc.includes("localhost:8899") ||
161
+ rpc.includes("127.0.0.1:8899") ||
162
+ er.includes("localhost:7799") ||
163
+ er.includes("127.0.0.1:7799")
164
+ );
165
+ };
166
+
167
+ const getTeeEndpoint = async (config, keypair, options = {}) => {
168
+ const base = normalizeEndpoint(
169
+ config.ephemeralTeeEndpoint ||
170
+ config.ephemeralPermissionEndpoint ||
171
+ "https://tee.magicblock.app"
172
+ );
173
+ const cacheKey = `${base}:${keypair.publicKey.toBase58()}`;
174
+ const cached = teeTokenCache.get(cacheKey);
175
+ const now = Math.floor(Date.now() / 1000);
176
+ if (!options.forceRefresh && cached && cached.expiresAt > now) {
177
+ return cached;
178
+ }
179
+ const auth = await getAuthToken(
180
+ base,
181
+ keypair.publicKey,
182
+ (message) => nacl.sign.detached(message, keypair.secretKey)
183
+ );
184
+ const endpoint = `${base}?token=${auth.token}`;
185
+ const expiresAt = now + TEE_TOKEN_TTL_SECONDS;
186
+ const record = { endpoint, token: auth.token, expiresAt };
187
+ teeTokenCache.set(cacheKey, record);
188
+ return record;
189
+ };
190
+
191
+ const getEphemeralProgram = async (config, signerKeypair, options = {}) => {
192
+ if (isLocalnetConfig(config)) {
193
+ return getProgram(config, signerKeypair, {
194
+ endpoint: config.ephemeralProviderUrl || config.rpcUrl,
195
+ wsEndpoint: config.ephemeralWsUrl || undefined,
196
+ });
197
+ }
198
+ const cacheKey = signerKeypair.publicKey.toBase58();
199
+ const cached = teeProgramCache.get(cacheKey);
200
+ const now = Math.floor(Date.now() / 1000);
201
+ if (!options.forceRefresh && cached && cached.expiresAt > now + 30) {
202
+ return cached.bundle;
203
+ }
204
+ const { endpoint, token, expiresAt } = await getTeeEndpoint(
205
+ config,
206
+ signerKeypair,
207
+ options
208
+ );
209
+ let wsEndpoint =
210
+ config.ephemeralTeeWsEndpoint ||
211
+ endpoint.replace(/^https:/, "wss:").replace(/^http:/, "ws:");
212
+ if (token && !wsEndpoint.includes("token=")) {
213
+ wsEndpoint += wsEndpoint.includes("?") ? `&token=${token}` : `?token=${token}`;
214
+ }
215
+ if (process.env.NEBULON_TEE_DEBUG === "1") {
216
+ console.log("TEE signer:", signerKeypair.publicKey.toBase58());
217
+ console.log("TEE RPC endpoint:", endpoint);
218
+ console.log("TEE WS endpoint:", wsEndpoint);
219
+ }
220
+ const programBundle = getProgram(config, signerKeypair, {
221
+ endpoint,
222
+ wsEndpoint,
223
+ });
224
+ await ensureTeeWsHealthy(programBundle.provider.connection, wsEndpoint);
225
+ teeProgramCache.set(cacheKey, { bundle: programBundle, expiresAt });
226
+ return programBundle;
227
+ };
228
+
229
+ const ensureTeeWsHealthy = async (connection, wsEndpoint) => {
230
+ if (!connection || !wsEndpoint) {
231
+ return;
232
+ }
233
+ const cacheKey = wsEndpoint;
234
+ const now = Date.now();
235
+ const cached = teeWsHealthCache.get(cacheKey);
236
+ if (cached && cached.expiresAt > now) {
237
+ return;
238
+ }
239
+
240
+ const timeoutMs = 4000;
241
+ let subscriptionId = null;
242
+ let resolved = false;
243
+ try {
244
+ if (process.env.NEBULON_TEE_DEBUG === "1") {
245
+ console.log("TEE WS health check: subscribing for slot change...");
246
+ }
247
+ subscriptionId = connection.onSlotChange((info) => {
248
+ if (!resolved) {
249
+ resolved = true;
250
+ if (process.env.NEBULON_TEE_DEBUG === "1") {
251
+ console.log(`TEE WS health check: slot ${info.slot}`);
252
+ }
253
+ }
254
+ });
255
+ await new Promise((resolve, reject) => {
256
+ const timeout = setTimeout(() => {
257
+ if (!resolved) {
258
+ reject(
259
+ new Error(
260
+ "TEE WS health check failed (no slot notifications). Check MagicBlock WS connectivity."
261
+ )
262
+ );
263
+ } else {
264
+ resolve();
265
+ }
266
+ }, timeoutMs);
267
+ const poll = () => {
268
+ if (resolved) {
269
+ clearTimeout(timeout);
270
+ resolve();
271
+ return;
272
+ }
273
+ setTimeout(poll, 50);
274
+ };
275
+ poll();
276
+ });
277
+ if (process.env.NEBULON_TEE_DEBUG === "1") {
278
+ console.log("TEE WS health check: OK");
279
+ }
280
+ teeWsHealthCache.set(cacheKey, { expiresAt: now + 15000 });
281
+ } finally {
282
+ if (subscriptionId !== null) {
283
+ try {
284
+ await connection.removeSlotChangeListener(subscriptionId);
285
+ } catch {
286
+ // ignore cleanup errors
287
+ }
288
+ }
289
+ }
290
+ };
291
+
292
+ const buildPermissionEndpoint = async (config, keypair) => {
293
+ const network = (config?.network || config?.solanaNetwork || "").toLowerCase();
294
+ const rpc = String(config?.rpcUrl || "");
295
+ const isLocal =
296
+ network === "localnet" ||
297
+ rpc.includes("localhost:8899") ||
298
+ rpc.includes("127.0.0.1:8899");
299
+ if (isLocal) {
300
+ return null;
301
+ }
302
+ const base = normalizeEndpoint(
303
+ config.ephemeralPermissionEndpoint || "https://tee.magicblock.app"
304
+ );
305
+ if (base.includes("localhost") || base.includes("127.0.0.1")) {
306
+ return null;
307
+ }
308
+ try {
309
+ const auth = await getAuthToken(
310
+ base,
311
+ keypair.publicKey,
312
+ (message) => nacl.sign.detached(message, keypair.secretKey)
313
+ );
314
+ return `${base}?token=${auth.token}`;
315
+ } catch (error) {
316
+ console.warn("Permission token fetch failed. Skipping permission checks.");
317
+ return base;
318
+ }
319
+ };
320
+
321
+ const waitForPermissionActive = async (config, keypair, pda, label) => {
322
+ const network = (config?.network || config?.solanaNetwork || "").toLowerCase();
323
+ const rpc = String(config?.rpcUrl || "");
324
+ if (
325
+ network === "localnet" ||
326
+ rpc.includes("localhost:8899") ||
327
+ rpc.includes("127.0.0.1:8899")
328
+ ) {
329
+ return;
330
+ }
331
+ const endpoint = await buildPermissionEndpoint(config, keypair);
332
+ if (!endpoint) {
333
+ return;
334
+ }
335
+ try {
336
+ const ready = await waitUntilPermissionActive(endpoint, pda, 20_000);
337
+ if (ready) {
338
+ return;
339
+ }
340
+ const status = await getPermissionStatus(endpoint, pda).catch(() => null);
341
+ const state = status?.status || "unknown";
342
+ throw new Error(
343
+ `${label} permission is not active yet (status: ${state}). Try again in a few seconds.`
344
+ );
345
+ } catch (error) {
346
+ console.warn(
347
+ `Warning: permission check failed for ${label}. Proceeding without confirmation.`
348
+ );
349
+ }
350
+ };
351
+
352
+ const getDelegationValidator = async (config) => {
353
+ const endpoint = (config.ephemeralProviderUrl || "").toLowerCase();
354
+ if (endpoint.includes("localhost") || endpoint.includes("127.0.0.1")) {
355
+ return LOCAL_VALIDATOR_IDENTITY;
356
+ }
357
+ if (endpoint.includes("router")) {
358
+ if (cachedValidator && cachedValidatorEndpoint === endpoint) {
359
+ return cachedValidator;
360
+ }
361
+ const router = new ConnectionMagicRouter(config.ephemeralProviderUrl, {
362
+ wsEndpoint: config.ephemeralWsUrl || undefined,
363
+ });
364
+ const closest = await router.getClosestValidator();
365
+ cachedValidator = new PublicKey(closest.pubkey || closest.validator || closest);
366
+ cachedValidatorEndpoint = endpoint;
367
+ return cachedValidator;
368
+ }
369
+ if (config.ephemeralValidatorIdentity) {
370
+ return new PublicKey(config.ephemeralValidatorIdentity);
371
+ }
372
+ return null;
373
+ };
374
+
375
+ const formatCreatedAt = (value) => {
376
+ if (!value) {
377
+ return "n/a";
378
+ }
379
+ const date = new Date(value * 1000);
380
+ if (Number.isNaN(date.getTime())) {
381
+ return "n/a";
382
+ }
383
+ return date.toISOString().replace("T", " ").slice(0, 19);
384
+ };
385
+
386
+ const trimPubkey = (value) =>
387
+ value ? `${value.slice(0, 4)}...${value.slice(-4)}` : "n/a";
388
+
389
+ const formatParticipant = (label, handle, wallet, selfWallet) => {
390
+ const tag = handle ? `@${handle}` : "unknown";
391
+ const suffix = wallet === selfWallet ? " (YOU)" : "";
392
+ return `${label}: ${tag} (${trimPubkey(wallet)})${suffix}`;
393
+ };
394
+
395
+ const formatSignerLabel = (label, handle, wallet) => {
396
+ const tag = handle ? `@${handle}` : "unknown";
397
+ return `${label}: ${tag} (${trimPubkey(wallet)})`;
398
+ };
399
+
400
+ const ensureEditableContract = (contract) => {
401
+ if (contract.status !== "negotiating") {
402
+ console.error("Contract terms/milestones are locked.");
403
+ process.exit(1);
404
+ }
405
+ if (contract.client_signed_at || contract.contractor_signed_at) {
406
+ console.error("Contract is already signed. Terms and milestones are immutable.");
407
+ if (contract.client_signed_at) {
408
+ console.error(
409
+ `Signed by customer: ${formatSignerLabel(
410
+ "Customer",
411
+ contract.client_handle,
412
+ contract.client_wallet
413
+ )}`
414
+ );
415
+ }
416
+ if (contract.contractor_signed_at) {
417
+ console.error(
418
+ `Signed by service provider: ${formatSignerLabel(
419
+ "Service Provider",
420
+ contract.contractor_handle,
421
+ contract.contractor_wallet
422
+ )}`
423
+ );
424
+ }
425
+ process.exit(1);
426
+ }
427
+ };
428
+
429
+ const ensureContractPhase = (contract, allowed, nextHint) => {
430
+ if (allowed.includes(contract.status)) {
431
+ return;
432
+ }
433
+ console.error(`Invalid command for current phase. [Current_phase : ${contract.status}]`);
434
+ process.exit(1);
435
+ };
436
+
437
+ const ensureEscrowUndelegated = async (config, keypair, escrowPda) => {
438
+ const { connection } = getProgram(config, keypair);
439
+ const info = await connection.getAccountInfo(escrowPda, "confirmed");
440
+ if (!info || !info.owner || !info.owner.equals(DELEGATION_PROGRAM_ID)) {
441
+ return;
442
+ }
443
+ const { programId } = getProgram(config, keypair);
444
+ const { program: erProgram } = await getEphemeralProgram(config, keypair);
445
+ await erProgram.methods
446
+ .undelegateEscrow()
447
+ .accounts({
448
+ payer: keypair.publicKey,
449
+ escrow: escrowPda,
450
+ magicProgram: MAGIC_PROGRAM_ID,
451
+ magicContext: MAGIC_CONTEXT_ID,
452
+ })
453
+ .signers([keypair])
454
+ .rpc({ skipPreflight: true });
455
+ };
456
+
457
+ const ensureEscrowOwnedByProgramL1 = async (config, keypair, escrowPda) => {
458
+ const { connection, programId } = getProgram(config, keypair);
459
+ const info = await connection.getAccountInfo(escrowPda, "confirmed");
460
+ if (!info || !info.owner) {
461
+ console.error("Escrow not found on-chain.");
462
+ return false;
463
+ }
464
+ if (info.owner.equals(DELEGATION_PROGRAM_ID)) {
465
+ console.error(
466
+ "Escrow is delegated to MagicBlock (PER). L1 MODE cannot modify it while delegated."
467
+ );
468
+ console.error(
469
+ "Switch back to PER mode or wait for TEE availability and run `nebulon contract <id> sync` to undelegate."
470
+ );
471
+ return false;
472
+ }
473
+ if (!info.owner.equals(programId)) {
474
+ console.error("Escrow is owned by an unexpected program.");
475
+ console.error(`Owner: ${info.owner.toBase58()}`);
476
+ return false;
477
+ }
478
+ return true;
479
+ };
480
+
481
+ const buildPrivacyContext = (scope, contractId, index) => {
482
+ const tail = index === undefined ? "" : `:${index}`;
483
+ return `nebulon:${scope}:v1:${contractId}${tail}`;
484
+ };
485
+
486
+ const getContractPrivacyKey = async (config, contractId, wallet) => {
487
+ const keypair = ensureContractKeypair(config, contractId);
488
+ const keys = await getContractKeys(
489
+ config.backendUrl,
490
+ config.auth.token,
491
+ contractId
492
+ );
493
+ const entries = Array.isArray(keys.keys) ? keys.keys : [];
494
+ const peer = entries.find((entry) => entry.wallet !== wallet);
495
+ if (!peer || !peer.public_key) {
496
+ throw new Error("privacy_key_pending");
497
+ }
498
+ return deriveContractKey(keypair.secretKey, peer.public_key, contractId);
499
+ };
500
+
501
+ const encryptTermsPayload = (key, contractId, deadline, totalPayment) => {
502
+ const payload = JSON.stringify({
503
+ deadline: deadline || null,
504
+ totalPayment: totalPayment || null,
505
+ });
506
+ return encryptPayload(key, payload, buildPrivacyContext("terms", contractId));
507
+ };
508
+
509
+ const decryptTermsPayload = (key, contractId, encrypted) => {
510
+ const plaintext = decryptPayload(
511
+ key,
512
+ encrypted,
513
+ buildPrivacyContext("terms", contractId)
514
+ );
515
+ const parsed = JSON.parse(plaintext);
516
+ return {
517
+ deadline: parsed.deadline || null,
518
+ totalPayment: parsed.totalPayment || null,
519
+ };
520
+ };
521
+
522
+ const encryptMilestonePayload = (key, contractId, index, title) => {
523
+ const payload = JSON.stringify({ title });
524
+ return encryptPayload(
525
+ key,
526
+ payload,
527
+ buildPrivacyContext("milestone", contractId, index)
528
+ );
529
+ };
530
+
531
+ const decryptMilestonePayload = (key, contractId, index, encrypted) => {
532
+ const plaintext = decryptPayload(
533
+ key,
534
+ encrypted,
535
+ buildPrivacyContext("milestone", contractId, index)
536
+ );
537
+ const parsed = JSON.parse(plaintext);
538
+ return parsed.title || "";
539
+ };
540
+
541
+ const getMilestoneLabel = (milestone) => {
542
+ if (!milestone) {
543
+ return "(untitled)";
544
+ }
545
+ if (milestone.title) {
546
+ return milestone.title;
547
+ }
548
+ if (milestone.details && !isEncryptedPayload(milestone.details)) {
549
+ return milestone.details;
550
+ }
551
+ if (milestone.details && isEncryptedPayload(milestone.details)) {
552
+ return "(encrypted)";
553
+ }
554
+ return "(untitled)";
555
+ };
556
+
557
+ const updateContractIfNegotiating = async (config, contract, payload) => {
558
+ if (contract.status !== "negotiating") {
559
+ return false;
560
+ }
561
+ try {
562
+ await updateContract(config.backendUrl, config.auth.token, contract.id, payload);
563
+ return true;
564
+ } catch (error) {
565
+ if (error?.message === "invalid_status") {
566
+ return false;
567
+ }
568
+ throw error;
569
+ }
570
+ };
571
+
572
+ const updateContractMilestones = async (config, contract, milestones) => {
573
+ try {
574
+ await updateContract(config.backendUrl, config.auth.token, contract.id, {
575
+ milestones,
576
+ });
577
+ return true;
578
+ } catch (error) {
579
+ if (error?.message === "invalid_status") {
580
+ return false;
581
+ }
582
+ throw error;
583
+ }
584
+ };
585
+
586
+ const formatUsdc = (amountBase) => {
587
+ const raw = BigInt(amountBase || 0);
588
+ const whole = raw / 1_000_000n;
589
+ const fraction = (raw % 1_000_000n).toString().padStart(6, "0");
590
+ return `${whole.toString()}.${fraction}`;
591
+ };
592
+
593
+ const printTxLogs = async (error) => {
594
+ if (!error) {
595
+ return;
596
+ }
597
+ if (Array.isArray(error.logs) && error.logs.length) {
598
+ console.error("Transaction logs:");
599
+ error.logs.forEach((line) => console.error(line));
600
+ return;
601
+ }
602
+ if (typeof error.getLogs === "function") {
603
+ try {
604
+ const logs = await error.getLogs();
605
+ if (Array.isArray(logs) && logs.length) {
606
+ console.error("Transaction logs:");
607
+ logs.forEach((line) => console.error(line));
608
+ }
609
+ } catch {
610
+ // ignore log fetch failures
611
+ }
612
+ }
613
+ };
614
+
615
+ const parseAmountInput = (value) => {
616
+ const trimmed = String(value || "").trim();
617
+ const cleaned = trimmed
618
+ .toLowerCase()
619
+ .replace(/usdc/g, "")
620
+ .replace(/\\s+/g, "")
621
+ .replace(/[$,_]/g, "")
622
+ .replace(/,/g, "");
623
+ if (!cleaned || !/^\d+(\.\d+)?$/.test(cleaned)) {
624
+ throw new Error("Invalid amount.");
625
+ }
626
+ const [wholePart, fracPart = ""] = cleaned.split(".");
627
+ if (fracPart.length > 6) {
628
+ throw new Error("Amount supports up to 6 decimals.");
629
+ }
630
+ const normalized = fracPart.padEnd(6, "0");
631
+ const base = BigInt(wholePart) * 1_000_000n + BigInt(normalized || "0");
632
+ return {
633
+ base,
634
+ display: `${formatUsdc(base)} USDC`,
635
+ };
636
+ };
637
+
638
+ const formatRelativeSeconds = (seconds) => {
639
+ const abs = Math.abs(seconds);
640
+ if (abs % 86400 === 0) {
641
+ return `${abs / 86400}d`;
642
+ }
643
+ if (abs % 3600 === 0) {
644
+ return `${abs / 3600}h`;
645
+ }
646
+ if (abs % 60 === 0) {
647
+ return `${abs / 60}m`;
648
+ }
649
+ return `${abs}s`;
650
+ };
651
+
652
+ const parseDeadlineInput = (value) => {
653
+ const trimmed = (value || "").trim();
654
+ if (!trimmed) {
655
+ throw new Error("Deadline required.");
656
+ }
657
+ const relativeMatch = trimmed.match(/^(\d+)([smhd])$/i);
658
+ if (relativeMatch) {
659
+ const qty = Number(relativeMatch[1]);
660
+ const unit = relativeMatch[2].toLowerCase();
661
+ const seconds =
662
+ unit === "d"
663
+ ? qty * 86400
664
+ : unit === "h"
665
+ ? qty * 3600
666
+ : unit === "m"
667
+ ? qty * 60
668
+ : qty;
669
+ return {
670
+ deadline: new anchor.BN(seconds).neg(),
671
+ label: `${formatRelativeSeconds(seconds)} from funding`,
672
+ kind: "relative",
673
+ };
674
+ }
675
+
676
+ if (/^\d+$/.test(trimmed)) {
677
+ const seconds = Number(trimmed);
678
+ if (!Number.isFinite(seconds)) {
679
+ throw new Error("Invalid deadline.");
680
+ }
681
+ return {
682
+ deadline: new anchor.BN(seconds),
683
+ label: new Date(seconds * 1000)
684
+ .toISOString()
685
+ .replace("T", " ")
686
+ .slice(0, 19),
687
+ kind: "absolute",
688
+ };
689
+ }
690
+
691
+ const parsed = new Date(trimmed);
692
+ if (!Number.isNaN(parsed.getTime())) {
693
+ const seconds = Math.floor(parsed.getTime() / 1000);
694
+ return {
695
+ deadline: new anchor.BN(seconds),
696
+ label: parsed.toISOString().replace("T", " ").slice(0, 19),
697
+ kind: "absolute",
698
+ };
699
+ }
700
+
701
+ throw new Error("Invalid deadline format.");
702
+ };
703
+
704
+ const formatDeadlineValue = (deadline) => {
705
+ if (deadline === null || deadline === undefined) {
706
+ return "n/a";
707
+ }
708
+ const value = Number(deadline);
709
+ if (!Number.isFinite(value) || value === 0) {
710
+ return "n/a";
711
+ }
712
+ if (value < 0) {
713
+ return `${formatRelativeSeconds(value)} from funding`;
714
+ }
715
+ return new Date(value * 1000)
716
+ .toISOString()
717
+ .replace("T", " ")
718
+ .slice(0, 19);
719
+ };
720
+
721
+ const buildTermsHash = (deadline, payment) => {
722
+ const payload = `deadline:${deadline ?? ""}|payment:${payment ?? ""}`;
723
+ return textToHash(payload);
724
+ };
725
+
726
+ const buildTermsHashHex = (deadline, payment) =>
727
+ Buffer.from(buildTermsHash(deadline, payment)).toString("hex");
728
+
729
+ const parsePublicKey = (value, label) => {
730
+ if (!value) {
731
+ console.error(`Missing ${label} address.`);
732
+ process.exit(1);
733
+ }
734
+ try {
735
+ if (value instanceof PublicKey) {
736
+ return value;
737
+ }
738
+ if (value?.toBase58) {
739
+ return new PublicKey(value.toBase58());
740
+ }
741
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
742
+ return new PublicKey(value);
743
+ }
744
+ return new PublicKey(String(value));
745
+ } catch (error) {
746
+ console.error(`Invalid ${label} address: ${String(value)}`);
747
+ process.exit(1);
748
+ }
749
+ };
750
+
751
+ const getExecutionMode = (contract) => {
752
+ const mode = (contract?.execution_mode || contract?.executionMode || "per")
753
+ .toString()
754
+ .toLowerCase();
755
+ return mode === "l1" ? "l1" : "per";
756
+ };
757
+
758
+ const isL1Mode = (contract) => getExecutionMode(contract) === "l1";
759
+
760
+ const ensureExecutionMode = async (config, contract, keypair, options = {}) => {
761
+ if (isL1Mode(contract)) {
762
+ config.__l1_mode = true;
763
+ return "l1";
764
+ }
765
+ config.__l1_mode = false;
766
+ if (isLocalnetConfig(config)) {
767
+ config.__l1_mode = false;
768
+ return "per";
769
+ }
770
+ const check = await checkTeeAvailability(config, keypair, {
771
+ timeoutMs: 4000,
772
+ });
773
+ if (check.ok) {
774
+ config.__l1_mode = false;
775
+ return "per";
776
+ }
777
+ console.warn("Warning: MagicBlock TEE is not available from this location.");
778
+ console.warn(
779
+ "You can proceed in L1 MODE (no privacy, but functional on-chain flow)."
780
+ );
781
+ const proceed = await confirmAction(
782
+ options.confirm,
783
+ "Enable L1 MODE for this contract? yes/no"
784
+ );
785
+ if (!proceed) {
786
+ console.log("Continuing without L1 mode (TEE may still fail).");
787
+ config.__l1_mode = false;
788
+ return "per";
789
+ }
790
+ await updateContract(config.backendUrl, config.auth.token, contract.id, {
791
+ executionMode: "l1",
792
+ });
793
+ contract.execution_mode = "l1";
794
+ config.__l1_mode = true;
795
+ console.log("L1 MODE enabled for this contract.");
796
+ return "l1";
797
+ };
798
+
799
+ const feeFromGross = (amount) => (amount * FEE_BPS) / BPS_DENOMINATOR;
800
+
801
+ const netFromGross = (amount) => amount - feeFromGross(amount);
802
+
803
+ const grossFromNet = (amount) => {
804
+ const denom = BPS_DENOMINATOR - FEE_BPS;
805
+ return (amount * BPS_DENOMINATOR + denom - 1n) / denom;
806
+ };
807
+
808
+ const normalizeHashBytes = (value) => {
809
+ if (!value) {
810
+ return Buffer.alloc(32);
811
+ }
812
+ if (Buffer.isBuffer(value)) {
813
+ return value;
814
+ }
815
+ if (value instanceof Uint8Array) {
816
+ return Buffer.from(value);
817
+ }
818
+ if (Array.isArray(value)) {
819
+ return Buffer.from(value);
820
+ }
821
+ return Buffer.from(String(value), "utf8").subarray(0, 32);
822
+ };
823
+
824
+ const buildMilestonesHash = (milestones) => {
825
+ const hash = crypto.createHash("sha256");
826
+ const ordered = [...milestones].sort((a, b) => a.index - b.index);
827
+ ordered.forEach((milestone) => {
828
+ hash.update(Buffer.from([milestone.index]));
829
+ hash.update(normalizeHashBytes(milestone.descriptionHash));
830
+ });
831
+ return hash.digest();
832
+ };
833
+
834
+ const resolveTermsDeadline = (deadline, fundedAt) => {
835
+ const value = Number(deadline);
836
+ if (!Number.isFinite(value)) {
837
+ return null;
838
+ }
839
+ if (value > 0) {
840
+ return value;
841
+ }
842
+ const funded = Number(fundedAt || 0);
843
+ if (!funded) {
844
+ return null;
845
+ }
846
+ return funded + Math.abs(value);
847
+ };
848
+
849
+ const formatPrivateMilestoneStatus = (status) => {
850
+ switch (status) {
851
+ case 0:
852
+ return "created";
853
+ case 1:
854
+ return "submitted";
855
+ case 2:
856
+ return "rejected";
857
+ case 3:
858
+ return "paid";
859
+ case 4:
860
+ return "disabled";
861
+ default:
862
+ return "unknown";
863
+ }
864
+ };
865
+
866
+ const isRemoteSubscriptionError = (error) => {
867
+ const message = (error?.message || "").toLowerCase();
868
+ return (
869
+ message.includes("remote account provider") ||
870
+ message.includes("failed to manage subscriptions") ||
871
+ message.includes("accountsubscriptionstaskfailed") ||
872
+ message.includes("magicblock-0 disconnected")
873
+ );
874
+ };
875
+
876
+ const getPrivateMilestoneStatus = async (
877
+ config,
878
+ contract,
879
+ keypair,
880
+ walletKey,
881
+ index
882
+ ) => {
883
+ const { program, programId } = getProgram(config, keypair);
884
+ const escrowPda = new PublicKey(contract.escrow_pda);
885
+ if (isL1Mode(contract)) {
886
+ const milestones = await fetchPublicMilestones(
887
+ program,
888
+ escrowPda,
889
+ programId,
890
+ [index]
891
+ );
892
+ return milestones[0];
893
+ }
894
+ const { program: erProgram, sessionSigner, sessionPda } =
895
+ await getPerProgramBundle(config, keypair, programId, program.provider);
896
+ const milestones = await fetchPrivateMilestones(
897
+ erProgram,
898
+ escrowPda,
899
+ programId,
900
+ [index]
901
+ );
902
+ return milestones[0];
903
+ };
904
+
905
+ const getRole = (contract, wallet) => {
906
+ if (!contract) {
907
+ return "unknown";
908
+ }
909
+ if (contract.client_wallet === wallet) {
910
+ return "client";
911
+ }
912
+ if (contract.contractor_wallet === wallet) {
913
+ return "contractor";
914
+ }
915
+ if (contract.issuer_wallet === wallet) {
916
+ return "issuer";
917
+ }
918
+ return "other";
919
+ };
920
+
921
+ const truncateField = (value, width) => {
922
+ const text = String(value);
923
+ if (text.length <= width) {
924
+ return text;
925
+ }
926
+ if (width <= 1) {
927
+ return text.slice(0, width);
928
+ }
929
+ return `${text.slice(0, width - 1)}…`;
930
+ };
931
+
932
+ const printContracts = (items, options = {}) => {
933
+ if (!items.length) {
934
+ console.log("No contracts found.");
935
+ return;
936
+ }
937
+ if (options.fullFields) {
938
+ console.log(
939
+ "# Contract ID Escrow PDA Role Status Created"
940
+ );
941
+ } else {
942
+ const header = [
943
+ "#".padEnd(3, " "),
944
+ "Contract ID".padEnd(12, " "),
945
+ "Escrow PDA".padEnd(10, " "),
946
+ "Role".padEnd(10, " "),
947
+ "Status".padEnd(30, " "),
948
+ "Created",
949
+ ];
950
+ console.log(header.join(" "));
951
+ }
952
+ items.forEach((item, index) => {
953
+ const full = Boolean(options.fullFields);
954
+ const idField = full ? item.id : truncateField(item.id, 12);
955
+ const escrowField = full
956
+ ? item.escrowPda || "n/a"
957
+ : truncateField(item.escrowPda || "n/a", 10);
958
+ const roleField = item.role;
959
+ const statusField = item.status;
960
+ const row = [
961
+ String(index + 1).padEnd(3, " "),
962
+ String(idField).padEnd(12, " "),
963
+ String(escrowField).padEnd(10, " "),
964
+ String(roleField).padEnd(10, " "),
965
+ String(statusField).padEnd(30, " "),
966
+ item.createdAt,
967
+ ];
968
+ console.log(row.join(" "));
969
+ });
970
+ };
971
+
972
+ const ensureHosted = async (config) => {
973
+ if (config.mode !== "hosted") {
974
+ console.error("Direct mode contract commands are not implemented yet.");
975
+ process.exit(1);
976
+ }
977
+ try {
978
+ await ensureHostedSession(config, { quiet: true, requireHandle: true });
979
+ } catch (error) {
980
+ console.error(error.message);
981
+ process.exit(1);
982
+ }
983
+ };
984
+
985
+ const getWalletContext = (config) => {
986
+ if (!config.activeWallet) {
987
+ throw new Error("No active wallet. Run: nebulon init");
988
+ }
989
+ const keypair = loadWalletKeypair(config, config.activeWallet);
990
+ return {
991
+ keypair,
992
+ wallet: keypair.publicKey.toBase58(),
993
+ walletKey: keypair.publicKey,
994
+ };
995
+ };
996
+
997
+ const getContractsForListing = (contracts) =>
998
+ contracts.filter((contract) => !EXCLUDE_INVITE_STATUSES.has(contract.status));
999
+
1000
+ const resolveContractFromList = (contracts, target) => {
1001
+ const list = getContractsForListing(contracts);
1002
+ const idx = Number.parseInt(target, 10);
1003
+ if (!Number.isNaN(idx)) {
1004
+ return list[idx - 1] || null;
1005
+ }
1006
+ if (target.length === 44) {
1007
+ return list.find((contract) => contract.escrow_pda === target) || null;
1008
+ }
1009
+ return list.find((contract) => contract.id === target) || null;
1010
+ };
1011
+
1012
+ const confirmAction = async (skip, message) => {
1013
+ if (skip) {
1014
+ return true;
1015
+ }
1016
+ const normalize = (value) => {
1017
+ const lower = String(value || "").trim().toLowerCase();
1018
+ if (["yes", "y"].includes(lower)) {
1019
+ return true;
1020
+ }
1021
+ if (["no", "n"].includes(lower)) {
1022
+ return false;
1023
+ }
1024
+ return null;
1025
+ };
1026
+ const answer = await prompt({
1027
+ type: "input",
1028
+ name: "decision",
1029
+ message,
1030
+ initial: "yes",
1031
+ validate: (value) =>
1032
+ normalize(value) === null ? "Type yes or no." : true,
1033
+ });
1034
+ return normalize(answer.decision);
1035
+ };
1036
+
1037
+ const resolveContract = async (config, target) => {
1038
+ const result = await getContracts(config.backendUrl, config.auth.token);
1039
+ const contracts = Array.isArray(result.contracts) ? result.contracts : [];
1040
+ const match = resolveContractFromList(contracts, target);
1041
+ if (!match) {
1042
+ return null;
1043
+ }
1044
+ const detail = await getContract(config.backendUrl, config.auth.token, match.id);
1045
+ const contract = detail.contract || match;
1046
+ const hydrated = { ...contract };
1047
+ if (!contract.terms_encrypted && !Array.isArray(contract.milestones)) {
1048
+ return hydrated;
1049
+ }
1050
+ const { wallet } = getWalletContext(config);
1051
+ try {
1052
+ const key = await getContractPrivacyKey(config, contract.id, wallet);
1053
+ hydrated.privacyReady = true;
1054
+ if (contract.terms_encrypted) {
1055
+ const terms = decryptTermsPayload(
1056
+ key,
1057
+ contract.id,
1058
+ contract.terms_encrypted
1059
+ );
1060
+ hydrated.deadline = terms.deadline;
1061
+ hydrated.total_payment = terms.totalPayment;
1062
+ }
1063
+ if (Array.isArray(contract.milestones)) {
1064
+ hydrated.milestones = contract.milestones.map((milestone) => {
1065
+ const next = { ...milestone };
1066
+ if (next.details && isEncryptedPayload(next.details)) {
1067
+ try {
1068
+ next.title = decryptMilestonePayload(
1069
+ key,
1070
+ contract.id,
1071
+ next.index,
1072
+ next.details
1073
+ );
1074
+ } catch {
1075
+ next.title = null;
1076
+ }
1077
+ }
1078
+ return next;
1079
+ });
1080
+ }
1081
+ } catch (error) {
1082
+ hydrated.privacyReady = false;
1083
+ }
1084
+ return hydrated;
1085
+ };
1086
+
1087
+ const renderContractSummary = (contract, selfWallet) => {
1088
+ const customer = formatParticipant(
1089
+ "Customer",
1090
+ contract.client_handle,
1091
+ contract.client_wallet,
1092
+ selfWallet
1093
+ );
1094
+ const provider = formatParticipant(
1095
+ "Service Provider",
1096
+ contract.contractor_handle,
1097
+ contract.contractor_wallet,
1098
+ selfWallet
1099
+ );
1100
+ console.log(`Contract ID: ${contract.id}`);
1101
+ console.log(customer);
1102
+ console.log(provider);
1103
+ };
1104
+
1105
+ const renderBalances = async (config, walletKey, mintAddress) => {
1106
+ console.log("-Balances-");
1107
+ try {
1108
+ const connection = getConnection(config.rpcUrl);
1109
+ const mintKey = toPublicKey(mintAddress);
1110
+ const [sol, usdc] = await Promise.all([
1111
+ getSolBalance(connection, walletKey),
1112
+ getTokenBalance(connection, walletKey, mintKey),
1113
+ ]);
1114
+ console.log(`SOL : ${formatAmount(sol, 6)} SOL`);
1115
+ console.log(`USDC : ${formatAmount(usdc, 6)} USDC`);
1116
+ console.log("");
1117
+ return { sol, usdc };
1118
+ } catch (error) {
1119
+ console.log("SOL : unavailable");
1120
+ console.log("USDC : unavailable");
1121
+ console.log("");
1122
+ return null;
1123
+ }
1124
+ };
1125
+
1126
+ const ensureSolBalance = (balances) => {
1127
+ if (!balances) {
1128
+ return true;
1129
+ }
1130
+ if (balances.sol <= 0) {
1131
+ console.error("Insufficient SOL balance to perform this action.");
1132
+ return false;
1133
+ }
1134
+ return true;
1135
+ };
1136
+
1137
+ const renderBalancesOrAbort = async (config, walletKey, mintAddress) => {
1138
+ const balances = await renderBalances(config, walletKey, mintAddress);
1139
+ return ensureSolBalance(balances);
1140
+ };
1141
+
1142
+ const renderMilestones = (milestones) => {
1143
+ if (!milestones || !milestones.length) {
1144
+ console.log("(No milestones)");
1145
+ return;
1146
+ }
1147
+ milestones.forEach((milestone, idx) => {
1148
+ const label = getMilestoneLabel(milestone);
1149
+ console.log(`${idx + 1}) ${label}`);
1150
+ });
1151
+ };
1152
+
1153
+ const ensureSessionSigner = (config, wallet) => {
1154
+ config.sessions = config.sessions || {};
1155
+ if (config.sessions[wallet] && config.sessions[wallet].secretKey) {
1156
+ return Keypair.fromSecretKey(
1157
+ Uint8Array.from(config.sessions[wallet].secretKey)
1158
+ );
1159
+ }
1160
+ const signer = Keypair.generate();
1161
+ config.sessions[wallet] = {
1162
+ secretKey: Array.from(signer.secretKey),
1163
+ };
1164
+ saveConfig(config);
1165
+ return signer;
1166
+ };
1167
+
1168
+ const deriveSessionTokenPda = (
1169
+ programId,
1170
+ sessionProgramId,
1171
+ signer,
1172
+ authority
1173
+ ) =>
1174
+ PublicKey.findProgramAddressSync(
1175
+ [
1176
+ Buffer.from("session_token"),
1177
+ programId.toBytes(),
1178
+ signer.toBytes(),
1179
+ authority.toBytes(),
1180
+ ],
1181
+ sessionProgramId
1182
+ )[0];
1183
+
1184
+ const ensureSessionToken = async (config, provider, programId, authorityKey) => {
1185
+ const sessionManager = new SessionTokenManager(
1186
+ provider.wallet,
1187
+ provider.connection
1188
+ );
1189
+ let sessionSigner = ensureSessionSigner(config, authorityKey.toBase58());
1190
+ const sessionProgramId = sessionManager.program.programId;
1191
+ let sessionPda = deriveSessionTokenPda(
1192
+ programId,
1193
+ sessionProgramId,
1194
+ sessionSigner.publicKey,
1195
+ authorityKey
1196
+ );
1197
+ const now = Math.floor(Date.now() / 1000);
1198
+ let existing = null;
1199
+ try {
1200
+ existing = await sessionManager.get(sessionPda);
1201
+ } catch {
1202
+ existing = null;
1203
+ }
1204
+
1205
+ const existingUntil = existing?.validUntil ?? existing?.valid_until ?? null;
1206
+ if (existingUntil && Number(existingUntil) > now + 30) {
1207
+ return { sessionSigner, sessionPda };
1208
+ }
1209
+
1210
+ if (existing) {
1211
+ try {
1212
+ const revokeTx = await sessionManager.program.methods
1213
+ .revokeSession()
1214
+ .accounts({
1215
+ sessionToken: sessionPda,
1216
+ authority: authorityKey,
1217
+ systemProgram: SystemProgram.programId,
1218
+ })
1219
+ .transaction();
1220
+ await provider.sendAndConfirm(revokeTx, []);
1221
+ } catch {
1222
+ sessionSigner = Keypair.generate();
1223
+ config.sessions[authorityKey.toBase58()] = {
1224
+ secretKey: Array.from(sessionSigner.secretKey),
1225
+ };
1226
+ saveConfig(config);
1227
+ sessionPda = deriveSessionTokenPda(
1228
+ programId,
1229
+ sessionProgramId,
1230
+ sessionSigner.publicKey,
1231
+ authorityKey
1232
+ );
1233
+ }
1234
+ }
1235
+
1236
+ const validUntil = new anchor.BN(now + 3600);
1237
+ const tx = await sessionManager.program.methods
1238
+ .createSession(true, validUntil, new anchor.BN(0))
1239
+ .accounts({
1240
+ targetProgram: programId,
1241
+ sessionSigner: sessionSigner.publicKey,
1242
+ authority: authorityKey,
1243
+ })
1244
+ .transaction();
1245
+ await provider.sendAndConfirm(tx, [sessionSigner]);
1246
+
1247
+ return { sessionSigner, sessionPda };
1248
+ };
1249
+
1250
+ const getSessionContext = async (config, keypair, program) => {
1251
+ if (isLocalnetConfig(config)) {
1252
+ const { sessionSigner, sessionPda } = await ensureSessionToken(
1253
+ config,
1254
+ program.provider,
1255
+ program.programId,
1256
+ keypair.publicKey
1257
+ );
1258
+ return { signer: sessionSigner, sessionPda, payer: sessionSigner.publicKey };
1259
+ }
1260
+ return { signer: keypair, sessionPda: null, payer: keypair.publicKey };
1261
+ };
1262
+
1263
+ const getPerProgramBundle = async (config, keypair, programId, provider) => {
1264
+ if (isLocalnetConfig(config)) {
1265
+ const { sessionSigner, sessionPda } = await ensureSessionToken(
1266
+ config,
1267
+ provider,
1268
+ programId,
1269
+ keypair.publicKey
1270
+ );
1271
+ const { program } = getProgram(config, sessionSigner, { useEphemeral: true });
1272
+ return { program, sessionSigner, sessionPda };
1273
+ }
1274
+ const { program } = await getEphemeralProgram(config, keypair);
1275
+ return { program, sessionSigner: keypair, sessionPda: null };
1276
+ };
1277
+
1278
+ const isAlreadyExistsError = (error) => {
1279
+ const message = (error?.message || "").toLowerCase();
1280
+ return (
1281
+ message.includes("already in use") ||
1282
+ message.includes("exists") ||
1283
+ message.includes("already initialized")
1284
+ );
1285
+ };
1286
+
1287
+ const ensurePermission = async (
1288
+ _config,
1289
+ program,
1290
+ keypair,
1291
+ permissionedAccount,
1292
+ members
1293
+ ) => {
1294
+ try {
1295
+ await program.methods
1296
+ .createPermission(members.accountType, members.entries)
1297
+ .accountsPartial({
1298
+ payer: keypair.publicKey,
1299
+ permissionedAccount,
1300
+ permission: permissionPdaFromAccount(permissionedAccount),
1301
+ permissionProgram: PERMISSION_PROGRAM_ID,
1302
+ systemProgram: SystemProgram.programId,
1303
+ })
1304
+ .signers([keypair])
1305
+ .rpc();
1306
+ return true;
1307
+ } catch (error) {
1308
+ if (isAlreadyExistsError(error)) {
1309
+ return false;
1310
+ }
1311
+ throw error;
1312
+ }
1313
+ };
1314
+
1315
+ const ensureDelegatedPermission = async (
1316
+ config,
1317
+ provider,
1318
+ keypair,
1319
+ permissionedAccount
1320
+ ) => {
1321
+ if (config && config.__l1_mode) {
1322
+ return;
1323
+ }
1324
+ const permissionPda = permissionPdaFromAccount(permissionedAccount);
1325
+ const info = await provider.connection.getAccountInfo(
1326
+ permissionPda,
1327
+ "confirmed"
1328
+ );
1329
+ if (info && info.owner.equals(DELEGATION_PROGRAM_ID)) {
1330
+ return;
1331
+ }
1332
+ if (info && !info.owner.equals(PERMISSION_PROGRAM_ID)) {
1333
+ throw new Error(
1334
+ `Permission ${permissionPda.toBase58()} is owned by ${info.owner.toBase58()}, expected ${PERMISSION_PROGRAM_ID.toBase58()}.`
1335
+ );
1336
+ }
1337
+ const validator = await getDelegationValidator(config);
1338
+ const ix = createDelegatePermissionInstruction({
1339
+ payer: keypair.publicKey,
1340
+ validator,
1341
+ permissionedAccount: [permissionedAccount, false],
1342
+ authority: [keypair.publicKey, true],
1343
+ });
1344
+ const permissionMeta = ix.keys.find((key) =>
1345
+ key.pubkey.equals(permissionedAccount)
1346
+ );
1347
+ if (permissionMeta) {
1348
+ permissionMeta.isWritable = true;
1349
+ }
1350
+ const tx = new Transaction().add(ix);
1351
+ try {
1352
+ await provider.sendAndConfirm(tx, [keypair]);
1353
+ } catch (error) {
1354
+ if (!isAlreadyExistsError(error)) {
1355
+ throw error;
1356
+ }
1357
+ }
1358
+ };
1359
+
1360
+ const ensureDelegatedAccount = async (
1361
+ config,
1362
+ program,
1363
+ keypair,
1364
+ accountType,
1365
+ pda
1366
+ ) => {
1367
+ if (config && config.__l1_mode) {
1368
+ return;
1369
+ }
1370
+ const info = await program.provider.connection.getAccountInfo(pda, "confirmed");
1371
+ if (info && info.owner.equals(DELEGATION_PROGRAM_ID)) {
1372
+ return;
1373
+ }
1374
+ const isPerVault = accountType && Object.prototype.hasOwnProperty.call(accountType, "perVault");
1375
+ if (
1376
+ info &&
1377
+ !info.owner.equals(program.programId) &&
1378
+ !(isPerVault && info.owner.equals(SystemProgram.programId))
1379
+ ) {
1380
+ throw new Error(
1381
+ `Account ${pda.toBase58()} is owned by ${info.owner.toBase58()}, expected ${program.programId.toBase58()}.`
1382
+ );
1383
+ }
1384
+ try {
1385
+ const validator = await getDelegationValidator(config);
1386
+ const remainingAccounts = validator
1387
+ ? [{ pubkey: validator, isWritable: false, isSigner: false }]
1388
+ : [];
1389
+ await program.methods
1390
+ .delegateAccount(accountType)
1391
+ .accounts({
1392
+ payer: keypair.publicKey,
1393
+ pda,
1394
+ })
1395
+ .remainingAccounts(remainingAccounts)
1396
+ .signers([keypair])
1397
+ .rpc();
1398
+ } catch (error) {
1399
+ if (!isAlreadyExistsError(error)) {
1400
+ throw error;
1401
+ }
1402
+ }
1403
+ };
1404
+
1405
+ const ensureDelegatedEscrow = async (
1406
+ config,
1407
+ program,
1408
+ keypair,
1409
+ escrowId,
1410
+ escrowPda,
1411
+ client
1412
+ ) => {
1413
+ if (config && config.__l1_mode) {
1414
+ return;
1415
+ }
1416
+ const info = await program.provider.connection.getAccountInfo(
1417
+ escrowPda,
1418
+ "confirmed"
1419
+ );
1420
+ if (info && info.owner.equals(DELEGATION_PROGRAM_ID)) {
1421
+ return;
1422
+ }
1423
+ if (info && !info.owner.equals(program.programId)) {
1424
+ throw new Error(
1425
+ `Escrow ${escrowPda.toBase58()} is owned by ${info.owner.toBase58()}, expected ${program.programId.toBase58()}.`
1426
+ );
1427
+ }
1428
+ try {
1429
+ const validator = await getDelegationValidator(config);
1430
+ const remainingAccounts = validator
1431
+ ? [{ pubkey: validator, isWritable: false, isSigner: false }]
1432
+ : [];
1433
+ await program.methods
1434
+ .delegateEscrow(new anchor.BN(escrowId.toString()))
1435
+ .accounts({
1436
+ payer: keypair.publicKey,
1437
+ pda: escrowPda,
1438
+ client,
1439
+ })
1440
+ .remainingAccounts(remainingAccounts)
1441
+ .signers([keypair])
1442
+ .rpc();
1443
+ } catch (error) {
1444
+ if (!isAlreadyExistsError(error)) {
1445
+ throw error;
1446
+ }
1447
+ }
1448
+ };
1449
+
1450
+ const buildMembers = (contract) => {
1451
+ const flags = AUTHORITY_FLAG | TX_LOGS_FLAG;
1452
+ return {
1453
+ entries: [
1454
+ { flags, pubkey: new PublicKey(contract.client_wallet) },
1455
+ { flags, pubkey: new PublicKey(contract.contractor_wallet) },
1456
+ ],
1457
+ };
1458
+ };
1459
+
1460
+ const ensureAta = async (connection, payer, owner, mint) => {
1461
+ const ata = await getAssociatedTokenAddress(mint, owner, true);
1462
+ try {
1463
+ await getAccount(connection, ata);
1464
+ return ata;
1465
+ } catch (error) {
1466
+ await createAssociatedTokenAccountIdempotent(
1467
+ connection,
1468
+ payer,
1469
+ mint,
1470
+ owner,
1471
+ { commitment: "confirmed" },
1472
+ TOKEN_PROGRAM_ID,
1473
+ undefined,
1474
+ true
1475
+ );
1476
+ return ata;
1477
+ }
1478
+ };
1479
+
1480
+ const getMilestonesForContract = async (connection, programId, escrowPda, count) => {
1481
+ const milestones = [];
1482
+ for (let index = 0; index < count; index += 1) {
1483
+ const pda = deriveMilestonePda(escrowPda, index, programId);
1484
+ const state = await getMilestoneState(connection, programId, pda);
1485
+ if (state) {
1486
+ milestones.push({ index, pda, state });
1487
+ } else {
1488
+ milestones.push({ index, pda, state: null });
1489
+ }
1490
+ }
1491
+ return milestones;
1492
+ };
1493
+
1494
+ const runContractsShow = async (scope, options = {}) => {
1495
+ const config = loadConfig();
1496
+ await ensureHosted(config);
1497
+
1498
+ const { keypair } = getWalletContext(config);
1499
+ const wallet = keypair.publicKey.toBase58();
1500
+
1501
+ const target = (scope || "").trim().toLowerCase();
1502
+ const result = await getContracts(config.backendUrl, config.auth.token);
1503
+ const contracts = Array.isArray(result.contracts) ? result.contracts : [];
1504
+
1505
+ if (target === "disputed") {
1506
+ if (config.auth.role !== "judge") {
1507
+ console.error("Only the judge can list disputed contracts.");
1508
+ return;
1509
+ }
1510
+ const items = contracts
1511
+ .filter((contract) => contract.status === "dispute_open")
1512
+ .map((contract) => ({
1513
+ id: contract.id,
1514
+ escrowPda: contract.escrow_pda || "n/a",
1515
+ role: getRole(contract, wallet),
1516
+ status: contract.status,
1517
+ createdAt: formatCreatedAt(contract.created_at),
1518
+ }));
1519
+ printContracts(items, options);
1520
+ return;
1521
+ }
1522
+
1523
+ if (target === "active") {
1524
+ const items = contracts
1525
+ .filter((contract) => ACTIVE_STATUSES.has(contract.status))
1526
+ .map((contract) => ({
1527
+ id: contract.id,
1528
+ escrowPda: contract.escrow_pda || "n/a",
1529
+ role: getRole(contract, wallet),
1530
+ status: contract.status,
1531
+ createdAt: formatCreatedAt(contract.created_at),
1532
+ }));
1533
+ printContracts(items, options);
1534
+ return;
1535
+ }
1536
+
1537
+ if (target === "all" || !target) {
1538
+ const items = getContractsForListing(contracts).map((contract) => ({
1539
+ id: contract.id,
1540
+ escrowPda: contract.escrow_pda || "n/a",
1541
+ role: getRole(contract, wallet),
1542
+ status: contract.status,
1543
+ createdAt: formatCreatedAt(contract.created_at),
1544
+ }));
1545
+ printContracts(items, options);
1546
+ return;
1547
+ }
1548
+
1549
+ const match = resolveContractFromList(contracts, target);
1550
+ if (!match) {
1551
+ console.log("Contract not found.");
1552
+ return;
1553
+ }
1554
+ printContracts(
1555
+ [
1556
+ {
1557
+ id: match.id,
1558
+ escrowPda: match.escrow_pda || "n/a",
1559
+ role: getRole(match, wallet),
1560
+ status: match.status,
1561
+ createdAt: formatCreatedAt(match.created_at),
1562
+ },
1563
+ ],
1564
+ options
1565
+ );
1566
+ };
1567
+
1568
+ const runContractsCreate = async () => {
1569
+ const config = loadConfig();
1570
+ await ensureHosted(config);
1571
+
1572
+ let inviteeRole = null;
1573
+ let shareMode = null;
1574
+ let inviteeHandle = null;
1575
+
1576
+ try {
1577
+ const roleAnswer = await prompt({
1578
+ type: "select",
1579
+ name: "selfRole",
1580
+ message: "Whats your role on this contract?",
1581
+ choices: ROLE_CHOICES,
1582
+ });
1583
+ inviteeRole = roleAnswer.selfRole === "client" ? "contractor" : "client";
1584
+
1585
+ const shareAnswer = await prompt({
1586
+ type: "select",
1587
+ name: "share",
1588
+ message: "How would you like to share this contract?",
1589
+ choices: SHARE_CHOICES,
1590
+ });
1591
+ shareMode = shareAnswer.share;
1592
+
1593
+ if (shareMode === "direct") {
1594
+ const handleAnswer = await prompt({
1595
+ type: "input",
1596
+ name: "handle",
1597
+ message: "Nebulon ID",
1598
+ validate: (value) => {
1599
+ if (!value || !value.trim()) {
1600
+ return "Nebulon ID required.";
1601
+ }
1602
+ return true;
1603
+ },
1604
+ });
1605
+ inviteeHandle = normalizeHandle(handleAnswer.handle);
1606
+ }
1607
+ } catch (error) {
1608
+ if (isPromptCancel(error)) {
1609
+ console.log("Contract creation canceled.");
1610
+ return;
1611
+ }
1612
+ throw error;
1613
+ }
1614
+
1615
+ const payload = { inviteeRole };
1616
+ if (inviteeHandle) {
1617
+ payload.inviteeHandle = inviteeHandle;
1618
+ }
1619
+
1620
+ const result = await createInvite(config.backendUrl, config.auth.token, payload);
1621
+
1622
+ const keypair = ensureContractKeypair(config, result.contractId);
1623
+ console.log("Setting up privacy keys...");
1624
+ try {
1625
+ await setContractKey(
1626
+ config.backendUrl,
1627
+ config.auth.token,
1628
+ result.contractId,
1629
+ keypair.publicKey
1630
+ );
1631
+ const keys = await getContractKeys(
1632
+ config.backendUrl,
1633
+ config.auth.token,
1634
+ result.contractId
1635
+ );
1636
+ const count = Array.isArray(keys.keys) ? keys.keys.length : 0;
1637
+ if (count > 1) {
1638
+ console.log("Privacy key exchange complete.");
1639
+ } else {
1640
+ console.log("Privacy key registered. Awaiting counterparty.");
1641
+ }
1642
+ } catch (error) {
1643
+ console.log("Privacy key setup pending. Try again after invite acceptance.");
1644
+ }
1645
+
1646
+ console.log("Contract invite created.");
1647
+ console.log(`Contract ID: ${result.contractId}`);
1648
+
1649
+ if (shareMode === "direct") {
1650
+ console.log(`Invite sent to @${inviteeHandle}.`);
1651
+ }
1652
+ if (shareMode === "link") {
1653
+ console.log(`Invite link: ${result.inviteUrl}`);
1654
+ console.log(`Contract code: ${result.token}`);
1655
+ }
1656
+ if (shareMode === "code") {
1657
+ console.log(`Contract code: ${result.token}`);
1658
+ }
1659
+
1660
+ console.log("Invite status: pending");
1661
+ successMessage("Invite created.");
1662
+ };
1663
+
1664
+ const runInitContract = async (config, contract, options) => {
1665
+ const { keypair, wallet, walletKey } = getWalletContext(config);
1666
+ if (!contract.client_wallet || !contract.contractor_wallet) {
1667
+ console.error("Invite not accepted yet.");
1668
+ process.exit(1);
1669
+ }
1670
+ if (contract.escrow_pda) {
1671
+ console.log("Escrow already initialized.");
1672
+ return;
1673
+ }
1674
+ if (wallet !== contract.client_wallet && wallet !== contract.contractor_wallet) {
1675
+ console.error("Only contract participants can initialize the escrow.");
1676
+ process.exit(1);
1677
+ }
1678
+ await ensureExecutionMode(config, contract, keypair, options);
1679
+
1680
+ console.log("Verify data and confirm actions please.");
1681
+ renderContractSummary(contract, wallet);
1682
+ console.log("-Action-");
1683
+ console.log("Initialize contract escrow");
1684
+ console.log("");
1685
+
1686
+ const balances = await renderBalances(
1687
+ config,
1688
+ walletKey,
1689
+ contract.mint || config.usdcMint
1690
+ );
1691
+ if (!ensureSolBalance(balances)) {
1692
+ return;
1693
+ }
1694
+
1695
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
1696
+ if (!proceed) {
1697
+ console.log("Canceled.");
1698
+ return;
1699
+ }
1700
+
1701
+ try {
1702
+ let step = "init";
1703
+ const { program, connection, programId } = getProgram(config, keypair);
1704
+ step = "program_info";
1705
+ const programInfo = await connection.getAccountInfo(programId);
1706
+ if (!programInfo || !programInfo.executable) {
1707
+ console.error("Nebulon program is not deployed on this network.");
1708
+ console.error(`Program ID: ${programId.toBase58()}`);
1709
+ console.error("Deploy it with `anchor deploy` and retry.");
1710
+ return;
1711
+ }
1712
+ step = "ata_program_info";
1713
+ const ataProgramInfo = await connection.getAccountInfo(
1714
+ ASSOCIATED_TOKEN_PROGRAM_ID
1715
+ );
1716
+ if (!ataProgramInfo || !ataProgramInfo.executable) {
1717
+ console.error("Associated Token Program is not available on this network.");
1718
+ console.error(
1719
+ `Program ID: ${ASSOCIATED_TOKEN_PROGRAM_ID.toBase58()}`
1720
+ );
1721
+ console.error(
1722
+ "Start localnet with the program or deploy it before initializing escrows."
1723
+ );
1724
+ return;
1725
+ }
1726
+ step = "parse_mint";
1727
+ const mint = parsePublicKey(
1728
+ contract.mint || config.usdcMint,
1729
+ "USDC mint"
1730
+ );
1731
+ step = "mint_info";
1732
+ const mintInfo = await connection.getAccountInfo(mint);
1733
+ if (!mintInfo) {
1734
+ console.error(
1735
+ "Test-USDC mint is not initialized on this network. Run `nebulon request-usdc` or create the mint first."
1736
+ );
1737
+ return;
1738
+ }
1739
+ step = "derive_keys";
1740
+ const escrowId = new anchor.BN(Date.now());
1741
+ const clientKey = parsePublicKey(contract.client_wallet, "client wallet");
1742
+ const contractorKey = parsePublicKey(contract.contractor_wallet, "contractor wallet");
1743
+ const escrowPda = deriveEscrowPda(clientKey, escrowId, programId);
1744
+ const perVaultPda = derivePerVaultPda(escrowPda, programId);
1745
+ step = "vault_token";
1746
+ const vaultToken = await getAssociatedTokenAddress(mint, escrowPda, true);
1747
+
1748
+ console.log("Processing.");
1749
+ step = "create_escrow";
1750
+ const sig = await program.methods
1751
+ .createEscrow(escrowId, FEE_RECEIVER)
1752
+ .accounts({
1753
+ payer: walletKey,
1754
+ client: clientKey,
1755
+ contractor: contractorKey,
1756
+ mint,
1757
+ escrow: escrowPda,
1758
+ perVault: perVaultPda,
1759
+ vaultToken,
1760
+ tokenProgram: TOKEN_PROGRAM_ID,
1761
+ associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
1762
+ systemProgram: SystemProgram.programId,
1763
+ })
1764
+ .signers([keypair])
1765
+ .rpc();
1766
+ console.log(`Escrow initialized. (tx: ${sig})`);
1767
+
1768
+ await linkEscrow(config.backendUrl, config.auth.token, contract.id, {
1769
+ escrowPda: escrowPda.toBase58(),
1770
+ mint: mint.toBase58(),
1771
+ vaultToken: vaultToken.toBase58(),
1772
+ });
1773
+ console.log("Escrow linked to contract.");
1774
+ successMessage("Escrow initialized.");
1775
+ } catch (error) {
1776
+ const message = error?.message || "";
1777
+ if (message.includes("Expected Buffer")) {
1778
+ console.error("Expected Buffer!");
1779
+ console.error(`Debug step: ${typeof step !== "undefined" ? step : "unknown"}`);
1780
+ if (error?.stack) {
1781
+ console.error(error.stack);
1782
+ }
1783
+ return;
1784
+ }
1785
+ throw error;
1786
+ }
1787
+ };
1788
+
1789
+ const runAddMilestone = async (config, contract, title, options) => {
1790
+ const { keypair, wallet, walletKey } = getWalletContext(config);
1791
+ ensureEditableContract(contract);
1792
+ if (!contract.escrow_pda) {
1793
+ console.error("Contract has no escrow. Unable to add milestones yet.");
1794
+ process.exit(1);
1795
+ }
1796
+
1797
+ const { program, connection, programId } = getProgram(config, keypair);
1798
+ const escrowPda = new PublicKey(contract.escrow_pda);
1799
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
1800
+ if (!escrowState) {
1801
+ console.error("Escrow not found on-chain.");
1802
+ process.exit(1);
1803
+ }
1804
+ if (escrowState.paidOut) {
1805
+ console.log("Funds already claimed. Contract completed successfully.");
1806
+ return;
1807
+ }
1808
+
1809
+ const escrowId = escrowState.escrowId;
1810
+ const index = Array.isArray(contract.milestones)
1811
+ ? contract.milestones.length
1812
+ : 0;
1813
+
1814
+ console.log("Verify data and confirm actions please.");
1815
+ renderContractSummary(contract, wallet);
1816
+ console.log("-Action-");
1817
+ console.log("Add Milestone");
1818
+ console.log(`Number : ${index + 1}`);
1819
+ console.log(`Details : \"${title}\"`);
1820
+ console.log("");
1821
+ const canProceed = await renderBalancesOrAbort(
1822
+ config,
1823
+ walletKey,
1824
+ contract.mint || config.usdcMint
1825
+ );
1826
+ if (!canProceed) {
1827
+ return;
1828
+ }
1829
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
1830
+ if (!proceed) {
1831
+ console.log("Canceled.");
1832
+ return;
1833
+ }
1834
+
1835
+ const executionMode = await ensureExecutionMode(config, contract, keypair, options);
1836
+ const l1Mode = executionMode === "l1";
1837
+
1838
+ console.log("Processing.");
1839
+ if (l1Mode) {
1840
+ const milestonePda = deriveMilestonePda(escrowPda, index, programId);
1841
+ const sig = await program.methods
1842
+ .addMilestone(new anchor.BN(escrowId.toString()), index)
1843
+ .accounts({
1844
+ actor: walletKey,
1845
+ escrow: escrowPda,
1846
+ milestone: milestonePda,
1847
+ systemProgram: SystemProgram.programId,
1848
+ })
1849
+ .signers([keypair])
1850
+ .rpc();
1851
+ console.log(`Milestone created. (tx: ${sig})`);
1852
+
1853
+ const milestones = Array.isArray(contract.milestones)
1854
+ ? [...contract.milestones]
1855
+ : [];
1856
+ milestones.push({ index, details: title, status: "created" });
1857
+ const persisted = await updateContractMilestones(config, contract, milestones);
1858
+ if (!persisted) {
1859
+ console.log(
1860
+ "Warning: backend did not persist milestone status (check backend version)."
1861
+ );
1862
+ }
1863
+ successMessage("Milestone added.");
1864
+ return;
1865
+ }
1866
+ let privacyKey;
1867
+ try {
1868
+ privacyKey = await getContractPrivacyKey(config, contract.id, wallet);
1869
+ } catch (error) {
1870
+ console.error(
1871
+ "Privacy key exchange pending. Wait for the counterparty to accept."
1872
+ );
1873
+ process.exit(1);
1874
+ }
1875
+ const encryptedDetails = encryptMilestonePayload(
1876
+ privacyKey,
1877
+ contract.id,
1878
+ index,
1879
+ title
1880
+ );
1881
+
1882
+ const encryptedBytes = Buffer.from(encryptedDetails, "utf8");
1883
+
1884
+ // Ensure encrypted bytes fit exactly into the expected buffer size
1885
+ // Look for the error message "requires (length X) Buffer as src"
1886
+ // and set the buffer size to X bytes
1887
+ const expectedSize = 73; // From error: "requires (length 73) Buffer as src"
1888
+ const paddedBytes = Buffer.alloc(expectedSize, 0);
1889
+ encryptedBytes.copy(paddedBytes, 0, 0, Math.min(encryptedBytes.length, expectedSize));
1890
+
1891
+ const descriptionHash = crypto
1892
+ .createHash("sha256")
1893
+ .update(encryptedDetails)
1894
+ .digest();
1895
+
1896
+ const privateMilestonePda = derivePrivateMilestonePda(
1897
+ escrowPda,
1898
+ index,
1899
+ programId
1900
+ );
1901
+ await program.methods
1902
+ .initPrivateMilestoneStub(new anchor.BN(escrowId.toString()), index)
1903
+ .accounts({
1904
+ payer: walletKey,
1905
+ escrow: escrowPda,
1906
+ privateMilestone: privateMilestonePda,
1907
+ systemProgram: SystemProgram.programId,
1908
+ })
1909
+ .signers([keypair])
1910
+ .rpc();
1911
+
1912
+ const members = buildMembers(contract);
1913
+ members.accountType = {
1914
+ privateMilestone: {
1915
+ escrow: escrowPda,
1916
+ index,
1917
+ },
1918
+ };
1919
+ await ensurePermission(config, program, keypair, privateMilestonePda, members);
1920
+ await ensureDelegatedPermission(
1921
+ config,
1922
+ program.provider,
1923
+ keypair,
1924
+ privateMilestonePda
1925
+ );
1926
+ await ensureDelegatedAccount(
1927
+ config,
1928
+ program,
1929
+ keypair,
1930
+ members.accountType,
1931
+ privateMilestonePda
1932
+ );
1933
+
1934
+ const perVaultPda = derivePerVaultPda(escrowPda, programId);
1935
+ await ensureDelegatedAccount(
1936
+ config,
1937
+ program,
1938
+ keypair,
1939
+ { perVault: { escrow: escrowPda } },
1940
+ perVaultPda
1941
+ );
1942
+ await ensureDelegatedEscrow(
1943
+ config,
1944
+ program,
1945
+ keypair,
1946
+ escrowId,
1947
+ escrowPda,
1948
+ new PublicKey(contract.client_wallet)
1949
+ );
1950
+
1951
+ const { program: erProgram, sessionSigner, sessionPda } =
1952
+ await getPerProgramBundle(config, keypair, programId, program.provider);
1953
+ const metaSig = await erProgram.methods
1954
+ .createPrivateMilestone(
1955
+ new anchor.BN(escrowId.toString()),
1956
+ index,
1957
+ Buffer.from(descriptionHash),
1958
+ paddedBytes
1959
+ )
1960
+ .accounts({
1961
+ user: walletKey,
1962
+ payer: sessionSigner.publicKey,
1963
+ sessionToken: sessionPda,
1964
+ escrow: escrowPda,
1965
+ privateMilestone: privateMilestonePda,
1966
+ perVault: perVaultPda,
1967
+ systemProgram: SystemProgram.programId,
1968
+ })
1969
+ .signers([sessionSigner])
1970
+ .rpc({ skipPreflight: true });
1971
+ console.log("Submitting Metadata...");
1972
+ console.log(`Done. (tx: ${metaSig})`);
1973
+
1974
+ const milestones = Array.isArray(contract.milestones)
1975
+ ? [...contract.milestones]
1976
+ : [];
1977
+ milestones.push({ index, details: encryptedDetails, status: "created" });
1978
+ const persisted = await updateContractMilestones(config, contract, milestones);
1979
+ if (!persisted) {
1980
+ console.log(
1981
+ "Warning: backend did not persist milestone status (check backend version)."
1982
+ );
1983
+ }
1984
+ successMessage("Milestone added.");
1985
+ };
1986
+
1987
+ const runDisableMilestone = async (config, contract, number, options) => {
1988
+ const { keypair, wallet, walletKey } = getWalletContext(config);
1989
+ if (!contract.escrow_pda) {
1990
+ console.error("Contract has no escrow. Unable to disable milestones.");
1991
+ process.exit(1);
1992
+ }
1993
+ const role = getRole(contract, wallet);
1994
+ if (role !== "client" && role !== "contractor") {
1995
+ console.error("Only contract participants can disable milestones.");
1996
+ process.exit(1);
1997
+ }
1998
+ const index = Number(number) - 1;
1999
+ if (!Number.isFinite(index) || index < 0) {
2000
+ console.error("Invalid milestone number.");
2001
+ process.exit(1);
2002
+ }
2003
+ const milestoneList = Array.isArray(contract.milestones)
2004
+ ? contract.milestones
2005
+ : [];
2006
+ const milestoneMeta = milestoneList.find((milestone) => milestone.index === index);
2007
+ if (!milestoneMeta) {
2008
+ console.error("Milestone not found.");
2009
+ console.error("Check the list with: nebulon contract <id> check milestone list");
2010
+ return;
2011
+ }
2012
+ const executionMode = await ensureExecutionMode(config, contract, keypair, options);
2013
+ const l1Mode = executionMode === "l1";
2014
+ if (l1Mode) {
2015
+ const { program, connection, programId } = getProgram(config, keypair);
2016
+ const escrowPda = new PublicKey(contract.escrow_pda);
2017
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
2018
+ if (!escrowState) {
2019
+ console.error("Escrow not found on-chain.");
2020
+ process.exit(1);
2021
+ }
2022
+ const ok = await ensureEscrowOwnedByProgramL1(config, keypair, escrowPda);
2023
+ if (!ok) {
2024
+ return;
2025
+ }
2026
+ const escrowId = escrowState.escrowId;
2027
+ const milestonePda = deriveMilestonePda(escrowPda, index, programId);
2028
+ console.log("Verify data and confirm actions please.");
2029
+ renderContractSummary(contract, wallet);
2030
+ console.log("-Action-");
2031
+ console.log("Disable milestone");
2032
+ console.log(`Number : ${index + 1}`);
2033
+ const milestones = Array.isArray(contract.milestones) ? contract.milestones : [];
2034
+ const current = milestones.find((m) => m.index === index);
2035
+ console.log(`Details : \"${current ? getMilestoneLabel(current) : "unknown"}\"`);
2036
+ console.log("");
2037
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
2038
+ if (!proceed) {
2039
+ console.log("Canceled.");
2040
+ return;
2041
+ }
2042
+ console.log("Processing.");
2043
+ const sig = await program.methods
2044
+ .disableMilestone(new anchor.BN(escrowId.toString()), index)
2045
+ .accounts({
2046
+ actor: walletKey,
2047
+ escrow: escrowPda,
2048
+ milestone: milestonePda,
2049
+ })
2050
+ .signers([keypair])
2051
+ .rpc();
2052
+ console.log(`Milestone disabled. (tx: ${sig})`);
2053
+ const nextMilestones = milestones.map((milestone) =>
2054
+ milestone.index === index
2055
+ ? { ...milestone, status: "disabled" }
2056
+ : milestone
2057
+ );
2058
+ const persisted = await updateContractMilestones(config, contract, nextMilestones);
2059
+ if (!persisted) {
2060
+ console.log(
2061
+ "Warning: backend did not persist milestone status (check backend version)."
2062
+ );
2063
+ }
2064
+ successMessage("Milestone disabled.");
2065
+ return;
2066
+ }
2067
+ if (!config.ephemeralProviderUrl) {
2068
+ console.error(
2069
+ "Warning: MagicBlock RPC is not configured; milestone status checks may be unreliable."
2070
+ );
2071
+ console.error("Run `nebulon init` to fetch the MagicBlock endpoints.");
2072
+ }
2073
+ if (milestoneMeta.status === "ready") {
2074
+ console.error("Milestone already submitted and awaiting customer confirmation.");
2075
+ console.error("Current status: submitted");
2076
+ return;
2077
+ }
2078
+ if (milestoneMeta.status === "approved") {
2079
+ console.error("Milestone already confirmed.");
2080
+ console.error("Current status: paid");
2081
+ return;
2082
+ }
2083
+ if (milestoneMeta.status === "disabled") {
2084
+ console.error("Milestone is disabled.");
2085
+ console.error("Current status: disabled");
2086
+ return;
2087
+ }
2088
+ let currentStatus = null;
2089
+ try {
2090
+ const milestone = await getPrivateMilestoneStatus(
2091
+ config,
2092
+ contract,
2093
+ keypair,
2094
+ walletKey,
2095
+ index
2096
+ );
2097
+ currentStatus = milestone ? milestone.status : null;
2098
+ } catch (error) {
2099
+ console.error("Unable to verify milestone status.");
2100
+ process.exit(1);
2101
+ }
2102
+ const statusValue = Number(currentStatus);
2103
+ if (!Number.isFinite(statusValue)) {
2104
+ console.error("Unable to determine milestone status.");
2105
+ return;
2106
+ }
2107
+ const statusLabel = formatPrivateMilestoneStatus(statusValue);
2108
+ if (statusValue === 1) {
2109
+ console.error("Milestone already submitted and awaiting customer confirmation.");
2110
+ console.error("Current status: submitted");
2111
+ return;
2112
+ }
2113
+ if (statusValue === 3) {
2114
+ console.error("Milestone already confirmed.");
2115
+ console.error("Current status: paid");
2116
+ return;
2117
+ }
2118
+ if (statusValue === 4) {
2119
+ console.error("Milestone is disabled.");
2120
+ console.error("Current status: disabled");
2121
+ return;
2122
+ }
2123
+ if (statusValue !== 0 && statusValue !== 2) {
2124
+ console.error(`Cannot mark milestone as ready from status: ${statusLabel}`);
2125
+ return;
2126
+ }
2127
+
2128
+ console.log("Verify data and confirm actions please.");
2129
+ renderContractSummary(contract, wallet);
2130
+ console.log("-Action-");
2131
+ console.log("Disable milestone");
2132
+ console.log(`Number : ${index + 1}`);
2133
+ const milestones = Array.isArray(contract.milestones) ? contract.milestones : [];
2134
+ const current = milestones.find((m) => m.index === index);
2135
+ console.log(`Details : \"${current ? getMilestoneLabel(current) : "unknown"}\"`);
2136
+ console.log("");
2137
+
2138
+ const canProceed = await renderBalancesOrAbort(
2139
+ config,
2140
+ walletKey,
2141
+ contract.mint || config.usdcMint
2142
+ );
2143
+ if (!canProceed) {
2144
+ return;
2145
+ }
2146
+
2147
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
2148
+ if (!proceed) {
2149
+ console.log("Canceled.");
2150
+ return;
2151
+ }
2152
+
2153
+ console.log("Processing.");
2154
+ const sig = await updatePrivateMilestoneStatus(
2155
+ config,
2156
+ contract,
2157
+ keypair,
2158
+ walletKey,
2159
+ index,
2160
+ 4
2161
+ );
2162
+ console.log(`Milestone disabled. (tx: ${sig})`);
2163
+
2164
+ const nextMilestones = milestones.map((milestone) =>
2165
+ milestone.index === index
2166
+ ? { ...milestone, status: "disabled" }
2167
+ : milestone
2168
+ );
2169
+ await updateContractIfNegotiating(config, contract, {
2170
+ milestones: nextMilestones,
2171
+ });
2172
+ await runSyncFlags(config, contract, { ...options, confirm: true });
2173
+ successMessage("Milestone disabled.");
2174
+ };
2175
+
2176
+ const ensureTermsPrepared = async (
2177
+ config,
2178
+ contract,
2179
+ keypair,
2180
+ escrowPda,
2181
+ escrowId
2182
+ ) => {
2183
+ const { program, programId } = getProgram(config, keypair);
2184
+ const termsPda = deriveTermsPda(escrowPda, programId);
2185
+ await program.methods
2186
+ .initPrivateTermsStub(new anchor.BN(escrowId.toString()))
2187
+ .accounts({
2188
+ payer: keypair.publicKey,
2189
+ escrow: escrowPda,
2190
+ terms: termsPda,
2191
+ systemProgram: SystemProgram.programId,
2192
+ })
2193
+ .signers([keypair])
2194
+ .rpc();
2195
+
2196
+ const members = buildMembers(contract);
2197
+ members.accountType = { terms: { escrow: escrowPda } };
2198
+ await ensurePermission(config, program, keypair, termsPda, members);
2199
+ await ensureDelegatedPermission(config, program.provider, keypair, termsPda);
2200
+ await waitForPermissionActive(config, keypair, termsPda, "Terms");
2201
+ await ensureDelegatedAccount(
2202
+ config,
2203
+ program,
2204
+ keypair,
2205
+ members.accountType,
2206
+ termsPda
2207
+ );
2208
+
2209
+ const perVaultPda = derivePerVaultPda(escrowPda, programId);
2210
+ await ensureDelegatedAccount(
2211
+ config,
2212
+ program,
2213
+ keypair,
2214
+ { perVault: { escrow: escrowPda } },
2215
+ perVaultPda
2216
+ );
2217
+ await ensureDelegatedEscrow(
2218
+ config,
2219
+ program,
2220
+ keypair,
2221
+ escrowId,
2222
+ escrowPda,
2223
+ new PublicKey(contract.client_wallet)
2224
+ );
2225
+
2226
+ return { termsPda, perVaultPda, program, programId };
2227
+ };
2228
+
2229
+ const submitTerms = async (
2230
+ config,
2231
+ contract,
2232
+ keypair,
2233
+ escrowPda,
2234
+ escrowId,
2235
+ totalPayment,
2236
+ deadline,
2237
+ encryptedTerms
2238
+ ) => {
2239
+ const { termsPda, perVaultPda, program, programId } = await ensureTermsPrepared(
2240
+ config,
2241
+ contract,
2242
+ keypair,
2243
+ escrowPda,
2244
+ escrowId
2245
+ );
2246
+
2247
+ const { program: erProgram, sessionSigner, sessionPda } =
2248
+ await getPerProgramBundle(config, keypair, programId, program.provider);
2249
+ const encryptedBytes = Buffer.from(encryptedTerms || "", "utf8");
2250
+ const attemptCreate = async (forceRefresh = false) => {
2251
+ const { program } = forceRefresh
2252
+ ? await getEphemeralProgram(config, keypair, { forceRefresh: true })
2253
+ : { program: erProgram };
2254
+ return program.methods
2255
+ .createPrivateTerms(
2256
+ new anchor.BN(escrowId.toString()),
2257
+ buildTermsHash(deadline, totalPayment),
2258
+ new anchor.BN(totalPayment.toString()),
2259
+ new anchor.BN(deadline.toString()),
2260
+ encryptedBytes
2261
+ )
2262
+ .accounts({
2263
+ user: keypair.publicKey,
2264
+ payer: sessionSigner.publicKey,
2265
+ sessionToken: sessionPda,
2266
+ escrow: escrowPda,
2267
+ terms: termsPda,
2268
+ perVault: perVaultPda,
2269
+ systemProgram: SystemProgram.programId,
2270
+ })
2271
+ .signers([sessionSigner])
2272
+ .rpc({ skipPreflight: true });
2273
+ };
2274
+
2275
+ const attemptCreateDirect = async () => {
2276
+ const { program: userErProgram } = await getEphemeralProgram(
2277
+ config,
2278
+ keypair,
2279
+ { forceRefresh: true }
2280
+ );
2281
+ return userErProgram.methods
2282
+ .createPrivateTerms(
2283
+ new anchor.BN(escrowId.toString()),
2284
+ buildTermsHash(deadline, totalPayment),
2285
+ new anchor.BN(totalPayment.toString()),
2286
+ new anchor.BN(deadline.toString()),
2287
+ encryptedBytes
2288
+ )
2289
+ .accounts({
2290
+ user: keypair.publicKey,
2291
+ payer: sessionSigner.publicKey,
2292
+ sessionToken: sessionPda,
2293
+ escrow: escrowPda,
2294
+ terms: termsPda,
2295
+ perVault: perVaultPda,
2296
+ systemProgram: SystemProgram.programId,
2297
+ })
2298
+ .signers([sessionSigner])
2299
+ .rpc({ skipPreflight: true });
2300
+ };
2301
+
2302
+ const retries = [0, 3000, 6000];
2303
+ let lastError = null;
2304
+ for (const delay of retries) {
2305
+ if (delay) {
2306
+ await sleep(delay);
2307
+ }
2308
+ try {
2309
+ return await attemptCreate(delay > 0);
2310
+ } catch (error) {
2311
+ lastError = error;
2312
+ const message = (error?.message || "").toLowerCase();
2313
+ if (
2314
+ message.includes("writable account") ||
2315
+ message.includes("cannot be written")
2316
+ ) {
2317
+ continue;
2318
+ }
2319
+ if (isRemoteSubscriptionError(error)) {
2320
+ try {
2321
+ return await attemptCreateDirect();
2322
+ } catch (directError) {
2323
+ lastError = directError;
2324
+ }
2325
+ }
2326
+ throw error;
2327
+ }
2328
+ }
2329
+ throw lastError;
2330
+ };
2331
+
2332
+ const submitPublicTerms = async (
2333
+ config,
2334
+ contract,
2335
+ keypair,
2336
+ escrowPda,
2337
+ escrowId,
2338
+ totalPayment,
2339
+ deadline
2340
+ ) => {
2341
+ const { program, programId } = getProgram(config, keypair);
2342
+ const termsPda = deriveTermsPda(escrowPda, programId);
2343
+ const ok = await ensureEscrowOwnedByProgramL1(config, keypair, escrowPda);
2344
+ if (!ok) {
2345
+ return null;
2346
+ }
2347
+ const ix = await program.methods
2348
+ .setPublicTerms(
2349
+ new anchor.BN(escrowId.toString()),
2350
+ buildTermsHash(deadline, totalPayment),
2351
+ new anchor.BN(totalPayment.toString()),
2352
+ new anchor.BN(deadline.toString())
2353
+ )
2354
+ .accounts({
2355
+ user: keypair.publicKey,
2356
+ escrow: escrowPda,
2357
+ terms: termsPda,
2358
+ systemProgram: SystemProgram.programId,
2359
+ })
2360
+ .instruction();
2361
+ ix.keys = ix.keys.map((key) => {
2362
+ if (key.pubkey.equals(escrowPda) || key.pubkey.equals(termsPda)) {
2363
+ return { ...key, isWritable: true };
2364
+ }
2365
+ return key;
2366
+ });
2367
+ const tx = new Transaction().add(ix);
2368
+ const sig = await program.provider.sendAndConfirm(tx, [keypair]);
2369
+ return sig;
2370
+ };
2371
+
2372
+ const signPublicTermsOnChain = async (
2373
+ config,
2374
+ keypair,
2375
+ escrowPda,
2376
+ escrowId
2377
+ ) => {
2378
+ const { program, programId } = getProgram(config, keypair);
2379
+ const termsPda = deriveTermsPda(escrowPda, programId);
2380
+ try {
2381
+ const escrowState = await program.account.escrow.fetch(escrowPda);
2382
+ const termsState = await program.account.terms.fetch(termsPda);
2383
+ const escrowIdOnChain = escrowState.escrowId?.toString?.() || String(escrowState.escrowId);
2384
+ const expectedEscrowId = escrowId.toString();
2385
+ if (escrowIdOnChain !== expectedEscrowId) {
2386
+ console.error(
2387
+ `L1 sign precheck: escrow_id mismatch (chain=${escrowIdOnChain}, local=${expectedEscrowId})`
2388
+ );
2389
+ }
2390
+ if (termsState.escrow && termsState.escrow.toBase58) {
2391
+ const termsEscrow = termsState.escrow.toBase58();
2392
+ if (termsEscrow !== escrowPda.toBase58()) {
2393
+ console.error(
2394
+ `L1 sign precheck: terms.escrow mismatch (terms=${termsEscrow}, escrow=${escrowPda.toBase58()})`
2395
+ );
2396
+ }
2397
+ }
2398
+ if (escrowState.fundedAmount && escrowState.fundedAmount.toString) {
2399
+ const funded = escrowState.fundedAmount.toString();
2400
+ if (funded !== "0") {
2401
+ console.error(`L1 sign precheck: funded_amount is ${funded}, expected 0`);
2402
+ }
2403
+ }
2404
+ if (escrowState.fundingOk) {
2405
+ console.error("L1 sign precheck: funding_ok already true");
2406
+ }
2407
+ } catch (error) {
2408
+ console.error("L1 sign precheck failed to fetch escrow/terms.");
2409
+ }
2410
+ const ix = await program.methods
2411
+ .signPublicTerms(new anchor.BN(escrowId.toString()))
2412
+ .accounts({
2413
+ user: keypair.publicKey,
2414
+ escrow: escrowPda,
2415
+ terms: termsPda,
2416
+ })
2417
+ .instruction();
2418
+ ix.keys = ix.keys.map((key) => {
2419
+ if (key.pubkey.equals(escrowPda) || key.pubkey.equals(termsPda)) {
2420
+ return { ...key, isWritable: true };
2421
+ }
2422
+ return key;
2423
+ });
2424
+ const tx = new Transaction().add(ix);
2425
+ const sig = await program.provider.sendAndConfirm(tx, [keypair]);
2426
+ return { sig, termsPda };
2427
+ };
2428
+
2429
+ const commitPublicTermsOnChain = async (
2430
+ config,
2431
+ keypair,
2432
+ escrowPda,
2433
+ escrowId
2434
+ ) => {
2435
+ const { program, programId } = getProgram(config, keypair);
2436
+ const termsPda = deriveTermsPda(escrowPda, programId);
2437
+ const ix = await program.methods
2438
+ .commitPublicTerms(new anchor.BN(escrowId.toString()))
2439
+ .accounts({
2440
+ user: keypair.publicKey,
2441
+ escrow: escrowPda,
2442
+ terms: termsPda,
2443
+ })
2444
+ .instruction();
2445
+ ix.keys = ix.keys.map((key) => {
2446
+ if (key.pubkey.equals(escrowPda) || key.pubkey.equals(termsPda)) {
2447
+ return { ...key, isWritable: true };
2448
+ }
2449
+ return key;
2450
+ });
2451
+ const tx = new Transaction().add(ix);
2452
+ return program.provider.sendAndConfirm(tx, [keypair]);
2453
+ };
2454
+
2455
+ const fetchPublicMilestones = async (
2456
+ program,
2457
+ escrowPda,
2458
+ programId,
2459
+ indices
2460
+ ) => {
2461
+ const milestones = [];
2462
+ for (const index of indices) {
2463
+ const pda = deriveMilestonePda(escrowPda, index, programId);
2464
+ try {
2465
+ const state = await program.account.milestone.fetch(pda);
2466
+ milestones.push({ index, status: Number(state.status), pda });
2467
+ } catch {
2468
+ milestones.push({ index, status: null, pda });
2469
+ }
2470
+ }
2471
+ return milestones;
2472
+ };
2473
+
2474
+ const runAddTerm = async (config, contract, field, value, options) => {
2475
+ const { keypair, wallet, walletKey } = getWalletContext(config);
2476
+ ensureEditableContract(contract);
2477
+ if (!contract.escrow_pda) {
2478
+ console.error("Contract has no escrow. Unable to edit terms.");
2479
+ process.exit(1);
2480
+ }
2481
+
2482
+ const { program, connection, programId } = getProgram(config, keypair);
2483
+ const escrowPda = new PublicKey(contract.escrow_pda);
2484
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
2485
+ if (!escrowState) {
2486
+ console.error("Escrow not found on-chain.");
2487
+ console.error(
2488
+ "If you restarted localnet, re-run `nebulon contract <id> init`."
2489
+ );
2490
+ process.exit(1);
2491
+ }
2492
+ const escrowId = escrowState.escrowId;
2493
+
2494
+ let deadline = contract.deadline;
2495
+ let totalPayment = contract.total_payment;
2496
+ let promptLabel = "";
2497
+
2498
+ if (field === "deadline") {
2499
+ if (wallet !== contract.client_wallet) {
2500
+ console.error("Only the customer can set the deadline.");
2501
+ process.exit(1);
2502
+ }
2503
+ const parsed = parseDeadlineInput(value);
2504
+ deadline = parsed.deadline.toString();
2505
+ promptLabel = parsed.label;
2506
+ } else if (field === "payment") {
2507
+ const parsed = parseAmountInput(value);
2508
+ totalPayment = parsed.base.toString();
2509
+ promptLabel = parsed.display;
2510
+ } else {
2511
+ console.error("Unknown term field.");
2512
+ process.exit(1);
2513
+ }
2514
+
2515
+ console.log("Verify data and confirm actions please.");
2516
+ renderContractSummary(contract, wallet);
2517
+ console.log("-Action-");
2518
+ console.log(`Add term ${field}`);
2519
+ console.log(`Value : ${promptLabel}`);
2520
+ console.log("");
2521
+
2522
+ const canProceed = await renderBalancesOrAbort(
2523
+ config,
2524
+ walletKey,
2525
+ contract.mint || config.usdcMint
2526
+ );
2527
+ if (!canProceed) {
2528
+ return;
2529
+ }
2530
+
2531
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
2532
+ if (!proceed) {
2533
+ console.log("Canceled.");
2534
+ return;
2535
+ }
2536
+
2537
+ const executionMode = await ensureExecutionMode(config, contract, keypair, options);
2538
+ const l1Mode = executionMode === "l1";
2539
+
2540
+ let encryptedTerms = null;
2541
+ if (!l1Mode) {
2542
+ try {
2543
+ const privacyKey = await getContractPrivacyKey(config, contract.id, wallet);
2544
+ encryptedTerms = encryptTermsPayload(
2545
+ privacyKey,
2546
+ contract.id,
2547
+ deadline,
2548
+ totalPayment
2549
+ );
2550
+ } catch (error) {
2551
+ console.error(
2552
+ "Privacy key exchange pending. Wait for the counterparty to accept."
2553
+ );
2554
+ process.exit(1);
2555
+ }
2556
+ }
2557
+
2558
+ await updateContractIfNegotiating(config, contract, {
2559
+ ...(l1Mode ? {} : { termsEncrypted: encryptedTerms }),
2560
+ termsHash: buildTermsHashHex(deadline, totalPayment),
2561
+ ...(l1Mode ? { deadline, totalPayment } : {}),
2562
+ });
2563
+
2564
+ if (!deadline || !totalPayment) {
2565
+ console.log("Saved term. Set the remaining term to submit on-chain.");
2566
+ successMessage("Term saved.");
2567
+ return;
2568
+ }
2569
+ if (wallet !== contract.client_wallet) {
2570
+ console.log(
2571
+ "Saved term. Ask the customer to submit the terms on-chain to continue."
2572
+ );
2573
+ successMessage("Term saved.");
2574
+ return;
2575
+ }
2576
+
2577
+ console.log("Processing.");
2578
+ try {
2579
+ const sig = l1Mode
2580
+ ? await submitPublicTerms(
2581
+ config,
2582
+ contract,
2583
+ keypair,
2584
+ escrowPda,
2585
+ escrowId,
2586
+ totalPayment,
2587
+ deadline
2588
+ )
2589
+ : await submitTerms(
2590
+ config,
2591
+ contract,
2592
+ keypair,
2593
+ escrowPda,
2594
+ escrowId,
2595
+ totalPayment,
2596
+ deadline,
2597
+ encryptedTerms
2598
+ );
2599
+ if (!sig) {
2600
+ return;
2601
+ }
2602
+ console.log(`Terms submitted. (tx: ${sig})`);
2603
+ successMessage("Terms submitted.");
2604
+ } catch (error) {
2605
+ await printTxLogs(error);
2606
+ throw error;
2607
+ }
2608
+ };
2609
+
2610
+ const runSignContract = async (config, contract, options) => {
2611
+ const { keypair, wallet, walletKey } = getWalletContext(config);
2612
+ if (!contract.escrow_pda) {
2613
+ console.error("Contract has no escrow. Unable to sign.");
2614
+ process.exit(1);
2615
+ }
2616
+ const executionMode = await ensureExecutionMode(config, contract, keypair, options);
2617
+ const l1Mode = executionMode === "l1";
2618
+ ensureContractPhase(
2619
+ contract,
2620
+ ["negotiating", "awaiting_signatures"],
2621
+ "Finish terms/milestones, then sign."
2622
+ );
2623
+ if (!l1Mode && contract.terms_encrypted && !contract.privacyReady) {
2624
+ console.error("Privacy key exchange pending. Unable to read terms.");
2625
+ process.exit(1);
2626
+ }
2627
+ const missing = [];
2628
+ const milestones = Array.isArray(contract.milestones)
2629
+ ? contract.milestones
2630
+ : [];
2631
+ if (!milestones.length) {
2632
+ missing.push("It needs at least 1 Milestone");
2633
+ }
2634
+ if (!contract.deadline) {
2635
+ missing.push("Missing Term : Deadline");
2636
+ }
2637
+ if (!contract.total_payment) {
2638
+ missing.push("Missing Term : Payment");
2639
+ }
2640
+ if (missing.length) {
2641
+ console.error("Cant sign contract until the following is resolved.");
2642
+ missing.forEach((item) => console.error(`-${item}`));
2643
+ process.exit(1);
2644
+ }
2645
+
2646
+ const { program, connection, programId } = getProgram(config, keypair);
2647
+ const escrowPda = new PublicKey(contract.escrow_pda);
2648
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
2649
+ if (!escrowState) {
2650
+ console.error("Escrow not found on-chain.");
2651
+ process.exit(1);
2652
+ }
2653
+ const escrowId = escrowState.escrowId;
2654
+
2655
+ console.log("Verify data and confirm actions please.");
2656
+ renderContractSummary(contract, wallet);
2657
+ console.log("-Action-");
2658
+ console.log("Sign Contract");
2659
+ console.log("");
2660
+ console.log("-Milestone List-");
2661
+ renderMilestones(contract.milestones || []);
2662
+ console.log("");
2663
+ console.log("-Terms-");
2664
+ console.log(`Deadline : ${formatDeadlineValue(contract.deadline)}`);
2665
+ console.log(`Payment : ${formatUsdc(contract.total_payment)} USDC`);
2666
+ console.log("");
2667
+ console.log(
2668
+ "By signing this contract you agree to the milestones and terms of it, and commit to be transparent and active with the progress of it."
2669
+ );
2670
+ console.log("");
2671
+
2672
+ const canProceed = await renderBalancesOrAbort(
2673
+ config,
2674
+ walletKey,
2675
+ contract.mint || config.usdcMint
2676
+ );
2677
+ if (!canProceed) {
2678
+ return;
2679
+ }
2680
+
2681
+ const proceed = await confirmAction(options.confirm, "Sign contract? yes/no");
2682
+ if (!proceed) {
2683
+ console.log("Canceled.");
2684
+ return;
2685
+ }
2686
+
2687
+ console.log("Processing.");
2688
+ let signSig = null;
2689
+ let commitSig = null;
2690
+ if (l1Mode) {
2691
+ signSig = (await signPublicTermsOnChain(
2692
+ config,
2693
+ keypair,
2694
+ escrowPda,
2695
+ escrowId
2696
+ )).sig;
2697
+ console.log(`Signature submitted. (tx: ${signSig})`);
2698
+ try {
2699
+ commitSig = await commitPublicTermsOnChain(
2700
+ config,
2701
+ keypair,
2702
+ escrowPda,
2703
+ escrowId
2704
+ );
2705
+ } catch (error) {
2706
+ commitSig = null;
2707
+ }
2708
+ } else {
2709
+ const { termsPda } = await ensureTermsPrepared(
2710
+ config,
2711
+ contract,
2712
+ keypair,
2713
+ escrowPda,
2714
+ escrowId
2715
+ );
2716
+ const { program: erProgram, sessionSigner, sessionPda } =
2717
+ await getPerProgramBundle(
2718
+ config,
2719
+ keypair,
2720
+ programId,
2721
+ program.provider
2722
+ );
2723
+ signSig = await erProgram.methods
2724
+ .signPrivateTerms(new anchor.BN(escrowId.toString()))
2725
+ .accounts({
2726
+ user: keypair.publicKey,
2727
+ payer: sessionSigner.publicKey,
2728
+ sessionToken: sessionPda,
2729
+ escrow: escrowPda,
2730
+ terms: termsPda,
2731
+ })
2732
+ .signers([sessionSigner])
2733
+ .rpc({ skipPreflight: true });
2734
+ console.log(`Signature submitted. (tx: ${signSig})`);
2735
+ try {
2736
+ commitSig = await erProgram.methods
2737
+ .commitTerms(new anchor.BN(escrowId.toString()))
2738
+ .accounts({
2739
+ user: keypair.publicKey,
2740
+ payer: sessionSigner.publicKey,
2741
+ sessionToken: sessionPda,
2742
+ escrow: escrowPda,
2743
+ terms: termsPda,
2744
+ })
2745
+ .signers([sessionSigner])
2746
+ .rpc({ skipPreflight: true });
2747
+ } catch (error) {
2748
+ commitSig = null;
2749
+ }
2750
+ }
2751
+ if (commitSig) {
2752
+ console.log(`Terms committed. (tx: ${commitSig})`);
2753
+ console.log("Syncing contract flags...");
2754
+ let synced = false;
2755
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2756
+ try {
2757
+ await runSyncFlags(config, contract, { confirm: true });
2758
+ synced = true;
2759
+ break;
2760
+ } catch (error) {
2761
+ console.log("DEBUG: Sync error:", error);
2762
+ const errorString = JSON.stringify(error);
2763
+ console.log("DEBUG: Error string:", errorString);
2764
+ const message = (error && error.message ? error.message : errorString).toLowerCase();
2765
+ if (message.includes("cannot be written") || message.includes("writable account") || message.includes("invalidwritableaccount")) {
2766
+ if (attempt === 0) {
2767
+ await sleep(3000);
2768
+ continue;
2769
+ }
2770
+ console.log("Sync failed. Run `nebulon contract <id> sync` and retry after a few seconds.");
2771
+ return;
2772
+ }
2773
+ throw error;
2774
+ }
2775
+ }
2776
+ } else {
2777
+ console.log("Waiting for the other party to sign.");
2778
+ }
2779
+
2780
+ if (contract.status === "negotiating") {
2781
+ await lockContract(config.backendUrl, config.auth.token, contract.id);
2782
+ }
2783
+ try {
2784
+ await signContract(config.backendUrl, config.auth.token, contract.id);
2785
+ } catch (error) {
2786
+ if (error && error.message === "already_signed") {
2787
+ console.log("You have already signed this contract.");
2788
+ return;
2789
+ }
2790
+ if (error && error.message === "invalid_status") {
2791
+ try {
2792
+ const refreshed = await refreshContract(
2793
+ config.backendUrl,
2794
+ config.auth.token,
2795
+ contract.id
2796
+ );
2797
+ if (refreshed && refreshed.contract && refreshed.contract.status) {
2798
+ console.log(
2799
+ `Contract phase updated to ${refreshed.contract.status}.`
2800
+ );
2801
+ } else {
2802
+ console.log("Contract phase does not allow signing right now.");
2803
+ }
2804
+ } catch (refreshError) {
2805
+ console.log("Contract phase does not allow signing right now.");
2806
+ }
2807
+ return;
2808
+ }
2809
+ throw error;
2810
+ }
2811
+ successMessage("Contract signed.");
2812
+ };
2813
+
2814
+ const runFundContract = async (config, contract, options) => {
2815
+ const { keypair, wallet, walletKey } = getWalletContext(config);
2816
+ if (!contract.escrow_pda) {
2817
+ console.error("Contract has no escrow. Unable to fund.");
2818
+ process.exit(1);
2819
+ }
2820
+ ensureContractPhase(contract, ["waiting_for_funding"], "Run sign first.");
2821
+ if (!isL1Mode(contract) && contract.terms_encrypted && !contract.privacyReady) {
2822
+ console.error("Privacy key exchange pending. Unable to read terms.");
2823
+ process.exit(1);
2824
+ }
2825
+ const role = getRole(contract, wallet);
2826
+ if (role !== "client") {
2827
+ console.error("Only the customer can fund this contract.");
2828
+ process.exit(1);
2829
+ }
2830
+ await ensureExecutionMode(config, contract, keypair, options);
2831
+ if (!contract.total_payment) {
2832
+ console.error("Contract payment is missing.");
2833
+ process.exit(1);
2834
+ }
2835
+
2836
+ const { program, connection, programId } = getProgram(config, keypair);
2837
+ const escrowPda = new PublicKey(contract.escrow_pda);
2838
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
2839
+ if (!escrowState) {
2840
+ console.error("Escrow not found on-chain.");
2841
+ process.exit(1);
2842
+ }
2843
+ if (escrowState._ownerIsDelegated) {
2844
+ console.error("Escrow is delegated. Run `nebulon contract <id> sync` to undelegate before funding.");
2845
+ process.exit(1);
2846
+ }
2847
+ const hasTerms = escrowState.termsHash
2848
+ ? escrowState.termsHash.some((byte) => byte !== 0)
2849
+ : false;
2850
+ if (!hasTerms) {
2851
+ console.error("Terms are not committed on-chain yet.");
2852
+ process.exit(1);
2853
+ }
2854
+ const hasMilestones = Array.isArray(contract.milestones)
2855
+ ? contract.milestones.length > 0
2856
+ : false;
2857
+ const hasMilestonesHash = escrowState.milestonesHash
2858
+ ? escrowState.milestonesHash.some((byte) => byte !== 0)
2859
+ : false;
2860
+ if (hasMilestones && !hasMilestonesHash) {
2861
+ console.error("Milestones are not committed on-chain yet.");
2862
+ console.error("Run `nebulon contract <id> sync` before funding.");
2863
+ process.exit(1);
2864
+ }
2865
+ const escrowId = escrowState.escrowId;
2866
+ const amount = BigInt(contract.total_payment);
2867
+
2868
+ console.log("Verify data and confirm actions please.");
2869
+ renderContractSummary(contract, wallet);
2870
+ console.log("-Action-");
2871
+ console.log("Fund Contract");
2872
+ console.log(`Amount : ${formatUsdc(amount)} USDC`);
2873
+ const feeEstimate = feeFromGross(amount);
2874
+ const netEstimate = netFromGross(amount);
2875
+ const coverAmount = grossFromNet(amount);
2876
+ console.log(
2877
+ ` Protocol fee (2%): ${formatUsdc(feeEstimate)} USDC (contractor receives ${formatUsdc(netEstimate)}).`
2878
+ );
2879
+ console.log(
2880
+ ` To cover the fee so the contractor receives your desired amount, set payment to ~${formatUsdc(coverAmount)} USDC.`
2881
+ );
2882
+ console.log("");
2883
+
2884
+ const balances = await renderBalances(
2885
+ config,
2886
+ walletKey,
2887
+ contract.mint || config.usdcMint
2888
+ );
2889
+ if (!ensureSolBalance(balances)) {
2890
+ return;
2891
+ }
2892
+ if (balances) {
2893
+ const requiredUsdc = Number(formatUsdc(contract.total_payment));
2894
+ if (Number.isFinite(requiredUsdc) && balances.usdc < requiredUsdc) {
2895
+ console.error(
2896
+ `Insufficient USDC balance. Need ${formatUsdc(contract.total_payment)} USDC, have ${balances.usdc.toFixed(6)} USDC.`
2897
+ );
2898
+ return;
2899
+ }
2900
+ }
2901
+
2902
+ const proceed = await confirmAction(
2903
+ options.confirm,
2904
+ `Do you want to fund this contract with ${formatUsdc(amount)} USDC? yes/no`
2905
+ );
2906
+ if (!proceed) {
2907
+ console.log("Canceled.");
2908
+ return;
2909
+ }
2910
+
2911
+ console.log("Processing.");
2912
+ const mint = new PublicKey(contract.mint || config.usdcMint);
2913
+ const clientToken = await ensureAta(connection, keypair, walletKey, mint);
2914
+ const feeReceiverToken = await ensureAta(
2915
+ connection,
2916
+ keypair,
2917
+ FEE_RECEIVER,
2918
+ mint
2919
+ );
2920
+ const vaultToken = await getAssociatedTokenAddress(mint, escrowPda, true);
2921
+
2922
+ const sig = await program.methods
2923
+ .fundEscrow(
2924
+ new anchor.BN(escrowId.toString()),
2925
+ new anchor.BN(amount.toString())
2926
+ )
2927
+ .accounts({
2928
+ client: walletKey,
2929
+ escrow: escrowPda,
2930
+ clientToken,
2931
+ feeReceiverToken,
2932
+ vaultToken,
2933
+ tokenProgram: TOKEN_PROGRAM_ID,
2934
+ })
2935
+ .signers([keypair])
2936
+ .rpc();
2937
+ console.log(`Funding submitted. (tx: ${sig})`);
2938
+
2939
+ await markFunded(config.backendUrl, config.auth.token, contract.id);
2940
+ console.log("Syncing private state to escrow flags.");
2941
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2942
+ try {
2943
+ await runSyncFlags(config, contract, { ...options, confirm: true });
2944
+ break;
2945
+ } catch (error) {
2946
+ const message = (error && error.message ? error.message : JSON.stringify(error)).toLowerCase();
2947
+ if (message.includes("cannot be written") || message.includes("writable account") || message.includes("invalidwritableaccount")) {
2948
+ if (attempt < 2) {
2949
+ console.log(`Sync attempt ${attempt + 1} failed. Retrying in 5 seconds...`);
2950
+ await sleep(5000);
2951
+ continue;
2952
+ }
2953
+ console.log("Sync failed. Run `nebulon contract <id> sync` and retry after a few seconds.");
2954
+ return;
2955
+ }
2956
+ throw error;
2957
+ }
2958
+ }
2959
+ successMessage("Contract funded.");
2960
+ };
2961
+
2962
+ const runUpdateMilestone = async (config, contract, number, options) => {
2963
+ const { keypair, wallet, walletKey } = getWalletContext(config);
2964
+ if (!contract.escrow_pda) {
2965
+ console.error("Contract has no escrow.");
2966
+ process.exit(1);
2967
+ }
2968
+ ensureContractPhase(
2969
+ contract,
2970
+ ["waiting_for_milestones_report", "in_progress"],
2971
+ "Fund the contract first."
2972
+ );
2973
+ const role = getRole(contract, wallet);
2974
+ if (role !== "contractor") {
2975
+ console.error("Only the service provider can submit milestones.");
2976
+ process.exit(1);
2977
+ }
2978
+ const index = Number(number) - 1;
2979
+ if (!Number.isFinite(index) || index < 0) {
2980
+ console.error("Invalid milestone number.");
2981
+ process.exit(1);
2982
+ }
2983
+ const milestoneList = Array.isArray(contract.milestones)
2984
+ ? contract.milestones
2985
+ : [];
2986
+ const milestoneMeta = milestoneList.find((milestone) => milestone.index === index);
2987
+ if (!milestoneMeta) {
2988
+ console.error("Milestone not found.");
2989
+ console.error("Check the list with: nebulon contract <id> check milestone list");
2990
+ return;
2991
+ }
2992
+ const executionMode = await ensureExecutionMode(config, contract, keypair, options);
2993
+ const l1Mode = executionMode === "l1";
2994
+ if (l1Mode) {
2995
+ const { program, connection, programId } = getProgram(config, keypair);
2996
+ const escrowPda = new PublicKey(contract.escrow_pda);
2997
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
2998
+ if (!escrowState) {
2999
+ console.error("Escrow not found on-chain.");
3000
+ process.exit(1);
3001
+ }
3002
+ const ok = await ensureEscrowOwnedByProgramL1(config, keypair, escrowPda);
3003
+ if (!ok) {
3004
+ return;
3005
+ }
3006
+ const escrowId = escrowState.escrowId;
3007
+ const milestonePda = deriveMilestonePda(escrowPda, index, programId);
3008
+
3009
+ console.log("Verify data and confirm actions please.");
3010
+ renderContractSummary(contract, wallet);
3011
+ console.log("-Action-");
3012
+ console.log("Update milestone");
3013
+ console.log(`Number : ${index + 1}`);
3014
+ console.log("Status : ready");
3015
+ console.log("");
3016
+
3017
+ const canProceed = await renderBalancesOrAbort(
3018
+ config,
3019
+ walletKey,
3020
+ contract.mint || config.usdcMint
3021
+ );
3022
+ if (!canProceed) {
3023
+ return;
3024
+ }
3025
+
3026
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
3027
+ if (!proceed) {
3028
+ console.log("Canceled.");
3029
+ return;
3030
+ }
3031
+
3032
+ console.log("Processing.");
3033
+ const sig = await program.methods
3034
+ .submitMilestone(new anchor.BN(escrowId.toString()), index)
3035
+ .accounts({
3036
+ contractor: walletKey,
3037
+ escrow: escrowPda,
3038
+ milestone: milestonePda,
3039
+ })
3040
+ .signers([keypair])
3041
+ .rpc();
3042
+ console.log(`Milestone updated. (tx: ${sig})`);
3043
+
3044
+ const milestones = Array.isArray(contract.milestones)
3045
+ ? contract.milestones.map((milestone) =>
3046
+ milestone.index === index
3047
+ ? { ...milestone, status: "ready" }
3048
+ : milestone
3049
+ )
3050
+ : [];
3051
+ const persisted = await updateContractMilestones(config, contract, milestones);
3052
+ if (!persisted) {
3053
+ console.log(
3054
+ "Warning: backend did not persist milestone status (check backend version)."
3055
+ );
3056
+ }
3057
+ await runSyncFlags(config, contract, { ...options, confirm: true });
3058
+ successMessage("Milestone updated.");
3059
+ return;
3060
+ }
3061
+ if (!config.ephemeralProviderUrl) {
3062
+ console.error(
3063
+ "Warning: MagicBlock RPC is not configured; milestone status checks may be unreliable."
3064
+ );
3065
+ console.error("Run `nebulon init` to fetch the MagicBlock endpoints.");
3066
+ }
3067
+ if (milestoneMeta.status === "approved") {
3068
+ console.error("Milestone already confirmed.");
3069
+ console.error("Current status: paid");
3070
+ return;
3071
+ }
3072
+ if (milestoneMeta.status === "disabled") {
3073
+ console.error("Milestone is disabled.");
3074
+ console.error("Current status: disabled");
3075
+ return;
3076
+ }
3077
+ let currentStatus = null;
3078
+ try {
3079
+ const milestone = await getPrivateMilestoneStatus(
3080
+ config,
3081
+ contract,
3082
+ keypair,
3083
+ walletKey,
3084
+ index
3085
+ );
3086
+ currentStatus = milestone ? milestone.status : null;
3087
+ } catch (error) {
3088
+ console.error("Unable to verify milestone status.");
3089
+ process.exit(1);
3090
+ }
3091
+ const statusValue = Number(currentStatus);
3092
+ if (!Number.isFinite(statusValue)) {
3093
+ console.error("Unable to determine milestone status.");
3094
+ return;
3095
+ }
3096
+ const statusLabel = formatPrivateMilestoneStatus(statusValue);
3097
+ if (statusValue === 1) {
3098
+ console.error("Milestone already submitted and awaiting customer confirmation.");
3099
+ console.error("Current status: submitted");
3100
+ return;
3101
+ }
3102
+ if (statusValue === 3) {
3103
+ console.error("Milestone already confirmed.");
3104
+ console.error("Current status: paid");
3105
+ return;
3106
+ }
3107
+ if (statusValue === 4) {
3108
+ console.error("Milestone is disabled.");
3109
+ console.error("Current status: disabled");
3110
+ return;
3111
+ }
3112
+ if (statusValue !== 0 && statusValue !== 2) {
3113
+ console.error(`Cannot mark milestone as ready from status: ${statusLabel}`);
3114
+ return;
3115
+ }
3116
+
3117
+ console.log("Verify data and confirm actions please.");
3118
+ renderContractSummary(contract, wallet);
3119
+ console.log("-Action-");
3120
+ console.log("Update milestone");
3121
+ console.log(`Number : ${index + 1}`);
3122
+ console.log("Status : ready");
3123
+ console.log("");
3124
+
3125
+ const canProceed = await renderBalancesOrAbort(
3126
+ config,
3127
+ walletKey,
3128
+ contract.mint || config.usdcMint
3129
+ );
3130
+ if (!canProceed) {
3131
+ return;
3132
+ }
3133
+
3134
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
3135
+ if (!proceed) {
3136
+ console.log("Canceled.");
3137
+ return;
3138
+ }
3139
+
3140
+ console.log("Processing.");
3141
+ const sig = await updatePrivateMilestoneStatus(
3142
+ config,
3143
+ contract,
3144
+ keypair,
3145
+ walletKey,
3146
+ index,
3147
+ 1
3148
+ );
3149
+ console.log(`Milestone updated. (tx: ${sig})`);
3150
+
3151
+ const milestones = Array.isArray(contract.milestones)
3152
+ ? contract.milestones.map((milestone) =>
3153
+ milestone.index === index
3154
+ ? { ...milestone, status: "ready" }
3155
+ : milestone
3156
+ )
3157
+ : [];
3158
+ const persisted = await updateContractMilestones(config, contract, milestones);
3159
+ if (!persisted) {
3160
+ console.log(
3161
+ "Warning: backend did not persist milestone status (check backend version)."
3162
+ );
3163
+ }
3164
+ await runSyncFlags(config, contract, { ...options, confirm: true });
3165
+ successMessage("Milestone updated.");
3166
+ };
3167
+
3168
+ const runConfirmMilestone = async (config, contract, number, options) => {
3169
+ const { keypair, wallet, walletKey } = getWalletContext(config);
3170
+ if (!contract.escrow_pda) {
3171
+ console.error("Contract has no escrow.");
3172
+ process.exit(1);
3173
+ }
3174
+ ensureContractPhase(
3175
+ contract,
3176
+ ["in_progress"],
3177
+ "Waiting on contractor submissions."
3178
+ );
3179
+ const role = getRole(contract, wallet);
3180
+ if (role !== "client") {
3181
+ console.error("Only the customer can confirm milestones.");
3182
+ process.exit(1);
3183
+ }
3184
+ const index = Number(number) - 1;
3185
+ if (!Number.isFinite(index) || index < 0) {
3186
+ console.error("Invalid milestone number.");
3187
+ process.exit(1);
3188
+ }
3189
+
3190
+ const executionMode = await ensureExecutionMode(config, contract, keypair, options);
3191
+ const l1Mode = executionMode === "l1";
3192
+ if (l1Mode) {
3193
+ const { program, connection, programId } = getProgram(config, keypair);
3194
+ const escrowPda = new PublicKey(contract.escrow_pda);
3195
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
3196
+ if (!escrowState) {
3197
+ console.error("Escrow not found on-chain.");
3198
+ process.exit(1);
3199
+ }
3200
+ const ok = await ensureEscrowOwnedByProgramL1(config, keypair, escrowPda);
3201
+ if (!ok) {
3202
+ return;
3203
+ }
3204
+ const escrowId = escrowState.escrowId;
3205
+ const milestonePda = deriveMilestonePda(escrowPda, index, programId);
3206
+
3207
+ console.log("Verify data and confirm actions please.");
3208
+ renderContractSummary(contract, wallet);
3209
+ console.log("-Action-");
3210
+ console.log("Confirm milestone");
3211
+ console.log(`Number : ${index + 1}`);
3212
+ console.log("");
3213
+
3214
+ const canProceed = await renderBalancesOrAbort(
3215
+ config,
3216
+ walletKey,
3217
+ contract.mint || config.usdcMint
3218
+ );
3219
+ if (!canProceed) {
3220
+ return;
3221
+ }
3222
+
3223
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
3224
+ if (!proceed) {
3225
+ console.log("Canceled.");
3226
+ return;
3227
+ }
3228
+
3229
+ console.log("Processing.");
3230
+ const sig = await program.methods
3231
+ .approveMilestone(new anchor.BN(escrowId.toString()), index)
3232
+ .accounts({
3233
+ client: walletKey,
3234
+ escrow: escrowPda,
3235
+ milestone: milestonePda,
3236
+ })
3237
+ .signers([keypair])
3238
+ .rpc();
3239
+ console.log(`Milestone confirmed. (tx: ${sig})`);
3240
+
3241
+ const milestones = Array.isArray(contract.milestones)
3242
+ ? contract.milestones.map((milestone) =>
3243
+ milestone.index === index
3244
+ ? { ...milestone, status: "approved" }
3245
+ : milestone
3246
+ )
3247
+ : [];
3248
+ const persisted = await updateContractMilestones(config, contract, milestones);
3249
+ if (!persisted) {
3250
+ console.log(
3251
+ "Warning: backend did not persist milestone status (check backend version)."
3252
+ );
3253
+ }
3254
+ await runSyncFlags(config, contract, { ...options, confirm: true });
3255
+ try {
3256
+ const refreshed = await getEscrowState(connection, programId, escrowPda);
3257
+ if (refreshed && refreshed.readyToClaim) {
3258
+ successMessage("Contract funds are now ready to claim by the service provider.");
3259
+ }
3260
+ } catch {}
3261
+ successMessage("Milestone confirmed.");
3262
+ return;
3263
+ }
3264
+
3265
+ console.log("Verify data and confirm actions please.");
3266
+ renderContractSummary(contract, wallet);
3267
+ console.log("-Action-");
3268
+ console.log("Confirm milestone");
3269
+ console.log(`Number : ${index + 1}`);
3270
+ console.log("");
3271
+
3272
+ let currentStatus = null;
3273
+ try {
3274
+ const milestone = await getPrivateMilestoneStatus(
3275
+ config,
3276
+ contract,
3277
+ keypair,
3278
+ walletKey,
3279
+ index
3280
+ );
3281
+ currentStatus = milestone ? milestone.status : null;
3282
+ } catch (error) {
3283
+ console.error("Unable to verify milestone status.");
3284
+ process.exit(1);
3285
+ }
3286
+ const statusValue = Number(currentStatus);
3287
+ if (!Number.isFinite(statusValue)) {
3288
+ console.error("Unable to determine milestone status.");
3289
+ return;
3290
+ }
3291
+ const statusLabel = formatPrivateMilestoneStatus(statusValue);
3292
+ if (statusValue !== 1) {
3293
+ console.error(`Milestone is not ready to confirm. Current status: ${statusLabel}`);
3294
+ return;
3295
+ }
3296
+
3297
+ const canProceed = await renderBalancesOrAbort(
3298
+ config,
3299
+ walletKey,
3300
+ contract.mint || config.usdcMint
3301
+ );
3302
+ if (!canProceed) {
3303
+ return;
3304
+ }
3305
+
3306
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
3307
+ if (!proceed) {
3308
+ console.log("Canceled.");
3309
+ return;
3310
+ }
3311
+
3312
+ console.log("Processing.");
3313
+ const sig = await updatePrivateMilestoneStatus(
3314
+ config,
3315
+ contract,
3316
+ keypair,
3317
+ walletKey,
3318
+ index,
3319
+ 3
3320
+ );
3321
+ console.log(`Milestone confirmed. (tx: ${sig})`);
3322
+
3323
+ const milestones = Array.isArray(contract.milestones)
3324
+ ? contract.milestones.map((milestone) =>
3325
+ milestone.index === index
3326
+ ? { ...milestone, status: "approved" }
3327
+ : milestone
3328
+ )
3329
+ : [];
3330
+ const persisted = await updateContractMilestones(config, contract, milestones);
3331
+ if (!persisted) {
3332
+ console.log(
3333
+ "Warning: backend did not persist milestone status (check backend version)."
3334
+ );
3335
+ }
3336
+ await runSyncFlags(config, contract, { ...options, confirm: true });
3337
+ try {
3338
+ const refreshed = await getEscrowState(connection, programId, escrowPda);
3339
+ if (refreshed && refreshed.readyToClaim) {
3340
+ successMessage("Contract funds are now ready to claim by the service provider.");
3341
+ }
3342
+ } catch {}
3343
+ successMessage("Milestone confirmed.");
3344
+ };
3345
+
3346
+ const runClaimFunds = async (config, contract, options) => {
3347
+ const { keypair, wallet, walletKey } = getWalletContext(config);
3348
+ if (!contract.escrow_pda) {
3349
+ console.error("Contract has no escrow.");
3350
+ process.exit(1);
3351
+ }
3352
+
3353
+ const { program, connection, programId } = getProgram(config, keypair);
3354
+ const escrowPda = new PublicKey(contract.escrow_pda);
3355
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
3356
+ if (!escrowState) {
3357
+ console.error("Escrow not found on-chain.");
3358
+ process.exit(1);
3359
+ }
3360
+ if (escrowState.paidOut) {
3361
+ console.log("Funds already claimed. Contract completed successfully.");
3362
+ return;
3363
+ }
3364
+ const escrowId = escrowState.escrowId;
3365
+ const readyToClaim = Boolean(escrowState.readyToClaim);
3366
+ const timeoutFundsReady = Boolean(escrowState.timeoutFundsReady);
3367
+ const timeoutRefundReady = Boolean(escrowState.timeoutRefundReady);
3368
+
3369
+ const mint = new PublicKey(contract.mint || config.usdcMint);
3370
+ const vaultToken = await getAssociatedTokenAddress(mint, escrowPda, true);
3371
+
3372
+ if (wallet === contract.contractor_wallet) {
3373
+ if (!readyToClaim && !timeoutFundsReady) {
3374
+ console.error("Contract is not ready to claim yet.");
3375
+ return;
3376
+ }
3377
+ const rawFunded =
3378
+ typeof escrowState.fundedAmount === "bigint"
3379
+ ? escrowState.fundedAmount
3380
+ : BigInt(escrowState.fundedAmount || 0);
3381
+ console.log("Verify data and confirm actions please.");
3382
+ renderContractSummary(contract, wallet);
3383
+ console.log("-Action-");
3384
+ console.log("Claim funds");
3385
+ console.log(`Amount : ${formatUsdc(rawFunded)} USDC`);
3386
+ console.log("");
3387
+
3388
+ const canProceed = await renderBalancesOrAbort(
3389
+ config,
3390
+ walletKey,
3391
+ contract.mint || config.usdcMint
3392
+ );
3393
+ if (!canProceed) {
3394
+ return;
3395
+ }
3396
+
3397
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
3398
+ if (!proceed) {
3399
+ console.log("Canceled.");
3400
+ return;
3401
+ }
3402
+
3403
+ const contractorToken = await ensureAta(connection, keypair, walletKey, mint);
3404
+ if (readyToClaim) {
3405
+ try {
3406
+ const sig = await program.methods
3407
+ .claimFunds(new anchor.BN(escrowId.toString()))
3408
+ .accounts({
3409
+ contractor: walletKey,
3410
+ creator: escrowState.creator,
3411
+ escrow: escrowPda,
3412
+ contractorToken,
3413
+ vaultToken,
3414
+ tokenProgram: TOKEN_PROGRAM_ID,
3415
+ })
3416
+ .signers([keypair])
3417
+ .rpc();
3418
+ console.log(`Funds claimed. (tx: ${sig})`);
3419
+ successMessage(
3420
+ `Funds claimed successfully. (+${formatUsdc(rawFunded)} USDC)`
3421
+ );
3422
+ successMessage("Escrow rent returned to the creator.");
3423
+ try {
3424
+ await refreshContract(config.backendUrl, config.auth.token, contract.id);
3425
+ } catch {
3426
+ console.log("Warning: backend status refresh failed.");
3427
+ }
3428
+ return;
3429
+ } catch (error) {
3430
+ errorMessage("Escrow rent was not returned.");
3431
+ throw error;
3432
+ }
3433
+ }
3434
+ if (timeoutFundsReady) {
3435
+ try {
3436
+ const sig = await program.methods
3437
+ .claimTimeoutFunds(new anchor.BN(escrowId.toString()))
3438
+ .accounts({
3439
+ contractor: walletKey,
3440
+ creator: escrowState.creator,
3441
+ escrow: escrowPda,
3442
+ contractorToken,
3443
+ vaultToken,
3444
+ tokenProgram: TOKEN_PROGRAM_ID,
3445
+ })
3446
+ .signers([keypair])
3447
+ .rpc();
3448
+ console.log(`Timeout funds claimed. (tx: ${sig})`);
3449
+ successMessage(
3450
+ `Funds claimed successfully. (+${formatUsdc(rawFunded)} USDC)`
3451
+ );
3452
+ successMessage("Escrow rent returned to the creator.");
3453
+ try {
3454
+ await refreshContract(config.backendUrl, config.auth.token, contract.id);
3455
+ } catch {
3456
+ console.log("Warning: backend status refresh failed.");
3457
+ }
3458
+ return;
3459
+ } catch (error) {
3460
+ errorMessage("Escrow rent was not returned.");
3461
+ throw error;
3462
+ }
3463
+ }
3464
+ return;
3465
+ }
3466
+
3467
+ if (wallet === contract.client_wallet) {
3468
+ if (!timeoutRefundReady) {
3469
+ console.error("Refund is not available for this contract.");
3470
+ return;
3471
+ }
3472
+ console.log("Verify data and confirm actions please.");
3473
+ renderContractSummary(contract, wallet);
3474
+ console.log("-Action-");
3475
+ console.log("Claim funds");
3476
+ console.log("");
3477
+
3478
+ const canProceed = await renderBalancesOrAbort(
3479
+ config,
3480
+ walletKey,
3481
+ contract.mint || config.usdcMint
3482
+ );
3483
+ if (!canProceed) {
3484
+ return;
3485
+ }
3486
+
3487
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
3488
+ if (!proceed) {
3489
+ console.log("Canceled.");
3490
+ return;
3491
+ }
3492
+
3493
+ const clientToken = await ensureAta(connection, keypair, walletKey, mint);
3494
+ try {
3495
+ const sig = await program.methods
3496
+ .claimTimeoutRefund(new anchor.BN(escrowId.toString()))
3497
+ .accounts({
3498
+ client: walletKey,
3499
+ creator: escrowState.creator,
3500
+ escrow: escrowPda,
3501
+ clientToken,
3502
+ vaultToken,
3503
+ tokenProgram: TOKEN_PROGRAM_ID,
3504
+ })
3505
+ .signers([keypair])
3506
+ .rpc();
3507
+ console.log(`Refund claimed. (tx: ${sig})`);
3508
+ successMessage("Refund claimed.");
3509
+ successMessage("Escrow rent returned to the creator.");
3510
+ try {
3511
+ await refreshContract(config.backendUrl, config.auth.token, contract.id);
3512
+ } catch {
3513
+ console.log("Warning: backend status refresh failed.");
3514
+ }
3515
+ return;
3516
+ } catch (error) {
3517
+ errorMessage("Escrow rent was not returned.");
3518
+ throw error;
3519
+ }
3520
+ }
3521
+
3522
+ console.error("Only contract participants can claim funds.");
3523
+ };
3524
+
3525
+
3526
+ const fetchPrivateMilestones = async (
3527
+ erProgram,
3528
+ escrowPda,
3529
+ programId,
3530
+ indices
3531
+ ) => {
3532
+ const milestones = [];
3533
+ for (const index of indices) {
3534
+ const pda = derivePrivateMilestonePda(escrowPda, index, programId);
3535
+ const state = await erProgram.account.privateMilestone.fetch(pda);
3536
+ milestones.push({
3537
+ index,
3538
+ status: Number(state.status),
3539
+ descriptionHash: state.descriptionHash || state.description_hash,
3540
+ pda,
3541
+ });
3542
+ }
3543
+ return milestones;
3544
+ };
3545
+
3546
+ const updatePrivateMilestoneStatus = async (
3547
+ config,
3548
+ contract,
3549
+ keypair,
3550
+ walletKey,
3551
+ index,
3552
+ status
3553
+ ) => {
3554
+ const { program, connection, programId } = getProgram(config, keypair);
3555
+ const escrowPda = new PublicKey(contract.escrow_pda);
3556
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
3557
+ if (!escrowState) {
3558
+ console.error("Escrow not found on-chain.");
3559
+ process.exit(1);
3560
+ }
3561
+
3562
+ const escrowId = escrowState.escrowId;
3563
+ const privateMilestonePda = derivePrivateMilestonePda(
3564
+ escrowPda,
3565
+ index,
3566
+ programId
3567
+ );
3568
+
3569
+ const members = buildMembers(contract);
3570
+ members.accountType = {
3571
+ privateMilestone: {
3572
+ escrow: escrowPda,
3573
+ index,
3574
+ },
3575
+ };
3576
+ await ensurePermission(config, program, keypair, privateMilestonePda, members);
3577
+ await ensureDelegatedPermission(
3578
+ config,
3579
+ program.provider,
3580
+ keypair,
3581
+ privateMilestonePda
3582
+ );
3583
+ await ensureDelegatedAccount(
3584
+ config,
3585
+ program,
3586
+ keypair,
3587
+ members.accountType,
3588
+ privateMilestonePda
3589
+ );
3590
+ await ensureDelegatedEscrow(
3591
+ config,
3592
+ program,
3593
+ keypair,
3594
+ escrowId,
3595
+ escrowPda,
3596
+ new PublicKey(contract.client_wallet)
3597
+ );
3598
+
3599
+ const { program: erProgram, sessionSigner, sessionPda } =
3600
+ await getPerProgramBundle(config, keypair, programId, program.provider);
3601
+
3602
+ const sig = await erProgram.methods
3603
+ .updatePrivateMilestoneStatus(
3604
+ new anchor.BN(escrowId.toString()),
3605
+ index,
3606
+ status
3607
+ )
3608
+ .accounts({
3609
+ user: walletKey,
3610
+ payer: sessionSigner.publicKey,
3611
+ sessionToken: sessionPda,
3612
+ escrow: escrowPda,
3613
+ privateMilestone: privateMilestonePda,
3614
+ })
3615
+ .signers([sessionSigner])
3616
+ .rpc({ skipPreflight: true });
3617
+
3618
+ return sig;
3619
+ };
3620
+
3621
+ const runSyncFlagsL1 = async (config, contract, options) => {
3622
+ const { keypair, walletKey } = getWalletContext(config);
3623
+ if (!contract.escrow_pda) {
3624
+ console.error("Contract has no escrow.");
3625
+ process.exit(1);
3626
+ }
3627
+
3628
+ const { program, connection, programId } = getProgram(config, keypair);
3629
+ const escrowPda = new PublicKey(contract.escrow_pda);
3630
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
3631
+ if (!escrowState) {
3632
+ console.error("Escrow not found on-chain.");
3633
+ process.exit(1);
3634
+ }
3635
+ const ok = await ensureEscrowOwnedByProgramL1(config, keypair, escrowPda);
3636
+ if (!ok) {
3637
+ return;
3638
+ }
3639
+
3640
+ const escrowId = escrowState.escrowId;
3641
+ const termsPda = deriveTermsPda(escrowPda, programId);
3642
+
3643
+ let terms;
3644
+ try {
3645
+ terms = await program.account.terms.fetch(termsPda);
3646
+ } catch (error) {
3647
+ console.error("Terms not available on-chain yet.");
3648
+ process.exit(1);
3649
+ }
3650
+
3651
+ const milestoneIndices = Array.isArray(contract.milestones)
3652
+ ? contract.milestones.map((milestone) => milestone.index)
3653
+ : [];
3654
+ milestoneIndices.sort((a, b) => a - b);
3655
+ const milestones = await fetchPublicMilestones(
3656
+ program,
3657
+ escrowPda,
3658
+ programId,
3659
+ milestoneIndices
3660
+ );
3661
+ const milestonesHash = buildMilestonesHash(milestones);
3662
+
3663
+ const fundedAmount =
3664
+ typeof escrowState.fundedAmount === "bigint"
3665
+ ? escrowState.fundedAmount
3666
+ : BigInt(escrowState.fundedAmount || 0);
3667
+ const canCommitMilestones = fundedAmount === 0n && !escrowState.fundingOk;
3668
+ const totalPayment = BigInt(terms.totalPayment.toString());
3669
+ const requiredNet = netFromGross(totalPayment);
3670
+ const fundingOk = fundedAmount >= requiredNet;
3671
+ const hasTermsHash = escrowState.termsHash
3672
+ ? escrowState.termsHash.some((byte) => byte !== 0)
3673
+ : false;
3674
+ if (!hasTermsHash) {
3675
+ console.log("Terms hash not committed on-chain yet.");
3676
+ }
3677
+
3678
+ const deadline = resolveTermsDeadline(
3679
+ Number(terms.deadline.toString()),
3680
+ escrowState.fundedAt
3681
+ );
3682
+ const now = Math.floor(Date.now() / 1000);
3683
+ const deadlinePassed = deadline ? now > deadline : false;
3684
+
3685
+ const statuses = milestones.map((milestone) =>
3686
+ Number.isFinite(milestone.status) ? milestone.status : 0
3687
+ );
3688
+ const allApproved =
3689
+ milestones.length === 0 ||
3690
+ statuses.every((status) => status === 3 || status === 4);
3691
+ const allSubmitted =
3692
+ milestones.length === 0 ||
3693
+ statuses.every((status) => status === 1 || status === 3 || status === 4);
3694
+ const hasUnsubmitted =
3695
+ milestones.length === 0 ||
3696
+ statuses.some((status) => status === 0 || status === 2);
3697
+
3698
+ const milestoneCount = milestones.length;
3699
+ if (milestoneCount > 255) {
3700
+ console.error("Too many milestones to sync.");
3701
+ process.exit(1);
3702
+ }
3703
+
3704
+ const proceed = await confirmAction(options.confirm, "Update escrow flags? yes/no");
3705
+ if (!proceed) {
3706
+ console.log("Canceled.");
3707
+ return;
3708
+ }
3709
+
3710
+ let didUpdate = false;
3711
+ const milestoneAccounts = milestones.map((milestone) => ({
3712
+ pubkey: milestone.pda,
3713
+ isWritable: false,
3714
+ isSigner: false,
3715
+ }));
3716
+
3717
+ if (canCommitMilestones && milestonesHash && escrowState.milestonesHash) {
3718
+ const currentHash = Buffer.from(escrowState.milestonesHash);
3719
+ if (!currentHash.equals(milestonesHash)) {
3720
+ const sig = await program.methods
3721
+ .commitPublicMilestones(
3722
+ new anchor.BN(escrowId.toString()),
3723
+ Array.from(milestonesHash)
3724
+ )
3725
+ .accounts({
3726
+ user: walletKey,
3727
+ escrow: escrowPda,
3728
+ })
3729
+ .signers([keypair])
3730
+ .rpc();
3731
+ console.log(`Milestones committed. (tx: ${sig})`);
3732
+ didUpdate = true;
3733
+ }
3734
+ }
3735
+
3736
+ if (hasTermsHash && fundingOk && !escrowState.fundingOk) {
3737
+ const sig = await program.methods
3738
+ .setFundingOkPublic(new anchor.BN(escrowId.toString()))
3739
+ .accounts({
3740
+ user: walletKey,
3741
+ escrow: escrowPda,
3742
+ terms: termsPda,
3743
+ })
3744
+ .signers([keypair])
3745
+ .rpc();
3746
+ console.log(`Funding verified. (tx: ${sig})`);
3747
+ didUpdate = true;
3748
+ }
3749
+
3750
+ let readyToClaimSet = false;
3751
+ if (hasTermsHash && fundingOk && allApproved && !escrowState.readyToClaim) {
3752
+ const sig = await program.methods
3753
+ .setReadyToClaimPublic(new anchor.BN(escrowId.toString()), milestoneCount)
3754
+ .accounts({
3755
+ user: walletKey,
3756
+ escrow: escrowPda,
3757
+ })
3758
+ .remainingAccounts(milestoneAccounts)
3759
+ .signers([keypair])
3760
+ .rpc();
3761
+ console.log(`Ready to claim set. (tx: ${sig})`);
3762
+ didUpdate = true;
3763
+ readyToClaimSet = true;
3764
+ }
3765
+
3766
+ if (!readyToClaimSet && deadlinePassed && fundingOk && hasUnsubmitted && !escrowState.timeoutRefundReady) {
3767
+ const sig = await program.methods
3768
+ .setTimeoutRefundReadyPublic(
3769
+ new anchor.BN(escrowId.toString()),
3770
+ milestoneCount
3771
+ )
3772
+ .accounts({
3773
+ user: walletKey,
3774
+ escrow: escrowPda,
3775
+ terms: termsPda,
3776
+ })
3777
+ .remainingAccounts(milestoneAccounts)
3778
+ .signers([keypair])
3779
+ .rpc();
3780
+ console.log(`Timeout refund ready set. (tx: ${sig})`);
3781
+ didUpdate = true;
3782
+ }
3783
+
3784
+ if (!readyToClaimSet && deadlinePassed && fundingOk && allSubmitted && !escrowState.timeoutFundsReady) {
3785
+ const sig = await program.methods
3786
+ .setTimeoutFundsReadyPublic(
3787
+ new anchor.BN(escrowId.toString()),
3788
+ milestoneCount
3789
+ )
3790
+ .accounts({
3791
+ user: walletKey,
3792
+ escrow: escrowPda,
3793
+ terms: termsPda,
3794
+ })
3795
+ .remainingAccounts(milestoneAccounts)
3796
+ .signers([keypair])
3797
+ .rpc();
3798
+ console.log(`Timeout funds ready set. (tx: ${sig})`);
3799
+ didUpdate = true;
3800
+ }
3801
+
3802
+ if (didUpdate) {
3803
+ successMessage("Sync completed.");
3804
+ return;
3805
+ }
3806
+ console.log("No updates were required.");
3807
+ };
3808
+
3809
+ const runSyncFlags = async (config, contract, options) => {
3810
+ const { keypair, walletKey } = getWalletContext(config);
3811
+ if (!contract.escrow_pda) {
3812
+ console.error("Contract has no escrow.");
3813
+ process.exit(1);
3814
+ }
3815
+ if (isL1Mode(contract)) {
3816
+ await runSyncFlagsL1(config, contract, options);
3817
+ return;
3818
+ }
3819
+
3820
+ const { program, connection, programId } = getProgram(config, keypair);
3821
+ const escrowPda = new PublicKey(contract.escrow_pda);
3822
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
3823
+ if (!escrowState) {
3824
+ console.error("Escrow not found on-chain.");
3825
+ process.exit(1);
3826
+ }
3827
+
3828
+ const escrowId = escrowState.escrowId;
3829
+ const { termsPda, program: l1Program } = await ensureTermsPrepared(
3830
+ config,
3831
+ contract,
3832
+ keypair,
3833
+ escrowPda,
3834
+ escrowId
3835
+ );
3836
+
3837
+ const milestoneIndices = Array.isArray(contract.milestones)
3838
+ ? contract.milestones.map((milestone) => milestone.index)
3839
+ : [];
3840
+ milestoneIndices.sort((a, b) => a - b);
3841
+ for (const index of milestoneIndices) {
3842
+ const privateMilestonePda = derivePrivateMilestonePda(
3843
+ escrowPda,
3844
+ index,
3845
+ programId
3846
+ );
3847
+ const members = buildMembers(contract);
3848
+ members.accountType = {
3849
+ privateMilestone: {
3850
+ escrow: escrowPda,
3851
+ index,
3852
+ },
3853
+ };
3854
+ await ensurePermission(config, l1Program, keypair, privateMilestonePda, members);
3855
+ await ensureDelegatedPermission(
3856
+ config,
3857
+ l1Program.provider,
3858
+ keypair,
3859
+ privateMilestonePda
3860
+ );
3861
+ await ensureDelegatedAccount(
3862
+ config,
3863
+ l1Program,
3864
+ keypair,
3865
+ members.accountType,
3866
+ privateMilestonePda
3867
+ );
3868
+ }
3869
+
3870
+ await ensureDelegatedEscrow(
3871
+ config,
3872
+ l1Program,
3873
+ keypair,
3874
+ escrowId,
3875
+ escrowPda,
3876
+ new PublicKey(contract.client_wallet)
3877
+ );
3878
+
3879
+ const { program: erProgram, sessionSigner, sessionPda } =
3880
+ await getPerProgramBundle(config, keypair, programId, l1Program.provider);
3881
+
3882
+ let terms;
3883
+ try {
3884
+ terms = await erProgram.account.terms.fetch(termsPda);
3885
+ } catch (error) {
3886
+ console.error("Terms not available in PER yet.");
3887
+ process.exit(1);
3888
+ }
3889
+
3890
+ const milestones = await fetchPrivateMilestones(
3891
+ erProgram,
3892
+ escrowPda,
3893
+ programId,
3894
+ milestoneIndices
3895
+ );
3896
+ const milestonesHash = buildMilestonesHash(milestones);
3897
+
3898
+ const fundedAmount =
3899
+ typeof escrowState.fundedAmount === "bigint"
3900
+ ? escrowState.fundedAmount
3901
+ : BigInt(escrowState.fundedAmount || 0);
3902
+ const canCommitMilestones = fundedAmount === 0n && !escrowState.fundingOk;
3903
+ const totalPayment = BigInt(terms.totalPayment.toString());
3904
+ const requiredNet = netFromGross(totalPayment);
3905
+ const fundingOk = fundedAmount === requiredNet;
3906
+ const hasTermsHash = escrowState.termsHash
3907
+ ? escrowState.termsHash.some((byte) => byte !== 0)
3908
+ : false;
3909
+ if (!hasTermsHash) {
3910
+ console.log("Terms hash not committed on-chain yet.");
3911
+ }
3912
+
3913
+ const deadline = resolveTermsDeadline(
3914
+ Number(terms.deadline.toString()),
3915
+ escrowState.fundedAt
3916
+ );
3917
+ const now = Math.floor(Date.now() / 1000);
3918
+ const deadlinePassed = deadline ? now > deadline : false;
3919
+
3920
+ const statuses = milestones.map((milestone) => milestone.status);
3921
+ const allApproved =
3922
+ milestones.length === 0 ||
3923
+ statuses.every((status) => status === 3 || status === 4);
3924
+ const allSubmitted =
3925
+ milestones.length === 0 ||
3926
+ statuses.every((status) => status === 1 || status === 3 || status === 4);
3927
+ const hasUnsubmitted =
3928
+ milestones.length === 0 ||
3929
+ statuses.some((status) => status === 0 || status === 2);
3930
+
3931
+ const milestoneCount = milestones.length;
3932
+ if (milestoneCount > 255) {
3933
+ console.error("Too many milestones to sync.");
3934
+ process.exit(1);
3935
+ }
3936
+
3937
+ const proceed = await confirmAction(options.confirm, "Update escrow flags? yes/no");
3938
+ if (!proceed) {
3939
+ console.log("Canceled.");
3940
+ return;
3941
+ }
3942
+
3943
+ let didUpdate = false;
3944
+ const milestoneAccounts = milestones.map((milestone) => ({
3945
+ pubkey: milestone.pda,
3946
+ isWritable: false,
3947
+ isSigner: false,
3948
+ }));
3949
+
3950
+ if (canCommitMilestones && milestonesHash && escrowState.milestonesHash) {
3951
+ const currentHash = Buffer.from(escrowState.milestonesHash);
3952
+ if (!currentHash.equals(milestonesHash)) {
3953
+ const sig = await erProgram.methods
3954
+ .commitMilestones(
3955
+ new anchor.BN(escrowId.toString()),
3956
+ Array.from(milestonesHash)
3957
+ )
3958
+ .accounts({
3959
+ user: keypair.publicKey,
3960
+ payer: sessionSigner.publicKey,
3961
+ sessionToken: sessionPda,
3962
+ escrow: escrowPda,
3963
+ })
3964
+ .signers([sessionSigner])
3965
+ .rpc({ skipPreflight: true });
3966
+ console.log(`Milestones committed. (tx: ${sig})`);
3967
+ didUpdate = true;
3968
+ }
3969
+ }
3970
+
3971
+ if (hasTermsHash && fundingOk && !escrowState.fundingOk) {
3972
+ const sig = await erProgram.methods
3973
+ .setFundingOk(new anchor.BN(escrowId.toString()))
3974
+ .accounts({
3975
+ user: keypair.publicKey,
3976
+ payer: sessionSigner.publicKey,
3977
+ sessionToken: sessionPda,
3978
+ escrow: escrowPda,
3979
+ terms: termsPda,
3980
+ })
3981
+ .signers([sessionSigner])
3982
+ .rpc({ skipPreflight: true });
3983
+ console.log(`Funding verified. (tx: ${sig})`);
3984
+ didUpdate = true;
3985
+ }
3986
+
3987
+ let readyToClaimSet = false;
3988
+ if (hasTermsHash && fundingOk && allApproved && !escrowState.readyToClaim) {
3989
+ const sig = await erProgram.methods
3990
+ .setReadyToClaim(
3991
+ new anchor.BN(escrowId.toString()),
3992
+ milestoneCount
3993
+ )
3994
+ .accounts({
3995
+ user: keypair.publicKey,
3996
+ payer: sessionSigner.publicKey,
3997
+ sessionToken: sessionPda,
3998
+ escrow: escrowPda,
3999
+ })
4000
+ .remainingAccounts(milestoneAccounts)
4001
+ .signers([sessionSigner])
4002
+ .rpc({ skipPreflight: true });
4003
+ console.log(`Ready to claim set. (tx: ${sig})`);
4004
+ didUpdate = true;
4005
+ readyToClaimSet = true;
4006
+ }
4007
+
4008
+ if (
4009
+ hasTermsHash &&
4010
+ fundingOk &&
4011
+ deadlinePassed &&
4012
+ hasUnsubmitted &&
4013
+ !escrowState.timeoutRefundReady
4014
+ ) {
4015
+ const sig = await erProgram.methods
4016
+ .setTimeoutRefundReady(
4017
+ new anchor.BN(escrowId.toString()),
4018
+ milestoneCount
4019
+ )
4020
+ .accounts({
4021
+ user: keypair.publicKey,
4022
+ payer: sessionSigner.publicKey,
4023
+ sessionToken: sessionPda,
4024
+ escrow: escrowPda,
4025
+ terms: termsPda,
4026
+ })
4027
+ .remainingAccounts(milestoneAccounts)
4028
+ .signers([sessionSigner])
4029
+ .rpc({ skipPreflight: true });
4030
+ console.log(`Timeout refund ready set. (tx: ${sig})`);
4031
+ didUpdate = true;
4032
+ }
4033
+
4034
+ if (
4035
+ hasTermsHash &&
4036
+ fundingOk &&
4037
+ deadlinePassed &&
4038
+ allSubmitted &&
4039
+ !escrowState.timeoutFundsReady
4040
+ ) {
4041
+ const sig = await erProgram.methods
4042
+ .setTimeoutFundsReady(
4043
+ new anchor.BN(escrowId.toString()),
4044
+ milestoneCount
4045
+ )
4046
+ .accounts({
4047
+ user: keypair.publicKey,
4048
+ payer: sessionSigner.publicKey,
4049
+ sessionToken: sessionPda,
4050
+ escrow: escrowPda,
4051
+ terms: termsPda,
4052
+ })
4053
+ .remainingAccounts(milestoneAccounts)
4054
+ .signers([sessionSigner])
4055
+ .rpc({ skipPreflight: true });
4056
+ console.log(`Timeout funds ready set. (tx: ${sig})`);
4057
+ didUpdate = true;
4058
+ }
4059
+
4060
+ const shouldUndelegate = didUpdate || escrowState._ownerIsDelegated;
4061
+ if (shouldUndelegate) {
4062
+ const sig = await erProgram.methods
4063
+ .undelegateEscrow()
4064
+ .accounts({
4065
+ payer: sessionSigner.publicKey,
4066
+ escrow: escrowPda,
4067
+ magicProgram: MAGIC_PROGRAM_ID,
4068
+ magicContext: MAGIC_CONTEXT_ID,
4069
+ })
4070
+ .signers([sessionSigner])
4071
+ .rpc({ skipPreflight: true });
4072
+ console.log(`Escrow committed. (tx: ${sig})`);
4073
+ } else {
4074
+ console.log("No escrow flag changes needed.");
4075
+ }
4076
+ successMessage("Sync completed.");
4077
+ if (readyToClaimSet) {
4078
+ successMessage("Contract funds are now ready to claim by the service provider.");
4079
+ }
4080
+ };
4081
+
4082
+ const runMilestoneList = async (config, contract) => {
4083
+ const { keypair, wallet, walletKey } = getWalletContext(config);
4084
+ if (!contract.escrow_pda) {
4085
+ console.error("Contract has no escrow.");
4086
+ process.exit(1);
4087
+ }
4088
+ const role = getRole(contract, wallet);
4089
+ if (role !== "client" && role !== "contractor") {
4090
+ console.error("Only contract participants can view milestone details.");
4091
+ process.exit(1);
4092
+ }
4093
+
4094
+ renderContractSummary(contract, wallet);
4095
+ console.log("");
4096
+ const phaseLabel = ACTIVE_STATUSES.has(contract.status)
4097
+ ? "Milestones"
4098
+ : `Milestones [${contract.status} phase]`;
4099
+ console.log(phaseLabel);
4100
+
4101
+ const { program, connection, programId } = getProgram(config, keypair);
4102
+ const escrowPda = new PublicKey(contract.escrow_pda);
4103
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4104
+ if (!escrowState) {
4105
+ console.error("Escrow not found on-chain.");
4106
+ process.exit(1);
4107
+ }
4108
+
4109
+ const milestoneIndices = Array.isArray(contract.milestones)
4110
+ ? contract.milestones.map((milestone) => milestone.index)
4111
+ : [];
4112
+ if (!milestoneIndices.length) {
4113
+ console.log("(No milestones)");
4114
+ return;
4115
+ }
4116
+ milestoneIndices.sort((a, b) => a - b);
4117
+
4118
+ let milestones = [];
4119
+ if (isL1Mode(contract)) {
4120
+ milestones = await fetchPublicMilestones(
4121
+ program,
4122
+ escrowPda,
4123
+ programId,
4124
+ milestoneIndices
4125
+ );
4126
+ } else {
4127
+ const { program: erProgram } = await getEphemeralProgram(
4128
+ config,
4129
+ keypair
4130
+ );
4131
+ try {
4132
+ milestones = await fetchPrivateMilestones(
4133
+ erProgram,
4134
+ escrowPda,
4135
+ programId,
4136
+ milestoneIndices
4137
+ );
4138
+ } catch (error) {
4139
+ console.error("Private milestones are not available yet.");
4140
+ process.exit(1);
4141
+ }
4142
+ }
4143
+
4144
+ const metadataByIndex = new Map(
4145
+ (contract.milestones || []).map((milestone) => [milestone.index, milestone])
4146
+ );
4147
+ const ordered = [...milestones].sort((a, b) => a.index - b.index);
4148
+ ordered.forEach((milestone) => {
4149
+ const meta = metadataByIndex.get(milestone.index) || {};
4150
+ const label = getMilestoneLabel(meta);
4151
+ const statusLabel = Number.isFinite(milestone.status)
4152
+ ? formatPrivateMilestoneStatus(milestone.status)
4153
+ : "unknown";
4154
+ const suffix = statusLabel === "disabled" ? ` ${chalk.gray("[disabled]")}` : "";
4155
+ console.log(`${milestone.index + 1}) ${label}${suffix}`);
4156
+ });
4157
+ };
4158
+
4159
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4160
+
4161
+ const runMilestoneStatus = async (config, contract, number) => {
4162
+ const { keypair, wallet, walletKey } = getWalletContext(config);
4163
+ if (!contract.escrow_pda) {
4164
+ console.error("Contract has no escrow.");
4165
+ process.exit(1);
4166
+ }
4167
+ if (!ACTIVE_STATUSES.has(contract.status)) {
4168
+ console.error(
4169
+ "Contract is not in progress. Use: nebulon contract <id> check milestone list"
4170
+ );
4171
+ process.exit(1);
4172
+ }
4173
+ const role = getRole(contract, wallet);
4174
+ if (role !== "client" && role !== "contractor") {
4175
+ console.error("Only contract participants can view milestone details.");
4176
+ process.exit(1);
4177
+ }
4178
+
4179
+ const index = Number(number) - 1;
4180
+ if (!Number.isFinite(index) || index < 0) {
4181
+ console.error("Invalid milestone number.");
4182
+ process.exit(1);
4183
+ }
4184
+
4185
+ const { program, connection, programId } = getProgram(config, keypair);
4186
+ const escrowPda = new PublicKey(contract.escrow_pda);
4187
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4188
+ if (!escrowState) {
4189
+ console.error("Escrow not found on-chain.");
4190
+ process.exit(1);
4191
+ }
4192
+
4193
+ let milestone;
4194
+ if (isL1Mode(contract)) {
4195
+ const items = await fetchPublicMilestones(
4196
+ program,
4197
+ escrowPda,
4198
+ programId,
4199
+ [index]
4200
+ );
4201
+ milestone = items[0];
4202
+ } else {
4203
+ const { program: erProgram } = await getEphemeralProgram(
4204
+ config,
4205
+ keypair
4206
+ );
4207
+ try {
4208
+ const items = await fetchPrivateMilestones(
4209
+ erProgram,
4210
+ escrowPda,
4211
+ programId,
4212
+ [index]
4213
+ );
4214
+ milestone = items[0];
4215
+ } catch (error) {
4216
+ console.error("Private milestone is not available yet.");
4217
+ process.exit(1);
4218
+ }
4219
+ }
4220
+
4221
+ const meta = (contract.milestones || []).find((m) => m.index === index) || {};
4222
+ const label = getMilestoneLabel(meta);
4223
+ const statusLabel = Number.isFinite(milestone.status)
4224
+ ? formatPrivateMilestoneStatus(milestone.status)
4225
+ : "unknown";
4226
+ console.log(`Milestone ${index + 1}: ${label}`);
4227
+ console.log(`Status: ${statusLabel}`);
4228
+ };
4229
+
4230
+ const runCheckMilestones = async (config, contract) => {
4231
+ const { wallet } = getWalletContext(config);
4232
+ const role = getRole(contract, wallet);
4233
+ if (role !== "client" && role !== "contractor") {
4234
+ console.error("Only contract participants can view milestone details.");
4235
+ process.exit(1);
4236
+ }
4237
+ const milestones = Array.isArray(contract.milestones)
4238
+ ? contract.milestones
4239
+ : [];
4240
+ if (!milestones.length) {
4241
+ console.log("Milestones: none");
4242
+ return;
4243
+ }
4244
+ console.log(`Milestones: ${milestones.length}`);
4245
+ milestones
4246
+ .slice()
4247
+ .sort((a, b) => a.index - b.index)
4248
+ .forEach((milestone) => {
4249
+ const label = getMilestoneLabel(milestone);
4250
+ const status = milestone.status ? ` (${milestone.status})` : "";
4251
+ console.log(`${milestone.index + 1}) ${label}${status}`);
4252
+ });
4253
+ };
4254
+
4255
+ const runContractDetails = async (config, contract) => {
4256
+ const { keypair, wallet } = getWalletContext(config);
4257
+ const role = getRole(contract, wallet);
4258
+ console.log("Contract details");
4259
+ console.log(`ID: ${contract.id}`);
4260
+ console.log(`Status: ${contract.status}`);
4261
+ console.log(`Execution mode: ${getExecutionMode(contract)}`);
4262
+ console.log(`Role: ${role}`);
4263
+ console.log(
4264
+ formatParticipant(
4265
+ "Customer",
4266
+ contract.client_handle,
4267
+ contract.client_wallet,
4268
+ wallet
4269
+ )
4270
+ );
4271
+ console.log(
4272
+ formatParticipant(
4273
+ "Service Provider",
4274
+ contract.contractor_handle,
4275
+ contract.contractor_wallet,
4276
+ wallet
4277
+ )
4278
+ );
4279
+ console.log(`Escrow: ${contract.escrow_pda || "n/a"}`);
4280
+ console.log(`Mint: ${contract.mint || "n/a"}`);
4281
+ console.log(`Vault: ${contract.vault_token || "n/a"}`);
4282
+ const privacyLabel = isL1Mode(contract)
4283
+ ? "l1"
4284
+ : contract.privacyReady
4285
+ ? "ready"
4286
+ : "pending";
4287
+ console.log(`Privacy: ${privacyLabel}`);
4288
+
4289
+ if (!isL1Mode(contract) && contract.terms_encrypted && !contract.privacyReady) {
4290
+ console.log("Terms: encrypted (waiting on privacy keys)");
4291
+ } else {
4292
+ console.log(`Deadline: ${contract.deadline ? formatDeadlineValue(contract.deadline) : "missing"}`);
4293
+ console.log(
4294
+ `Payment: ${contract.total_payment ? `${formatUsdc(contract.total_payment)} USDC` : "missing"}`
4295
+ );
4296
+ }
4297
+
4298
+ const milestones = Array.isArray(contract.milestones)
4299
+ ? contract.milestones
4300
+ : [];
4301
+ console.log(`Milestones: ${milestones.length}`);
4302
+
4303
+ if (!contract.escrow_pda) {
4304
+ return;
4305
+ }
4306
+
4307
+ const { connection, programId } = getProgram(config, keypair);
4308
+ const escrowPda = new PublicKey(contract.escrow_pda);
4309
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4310
+ if (!escrowState) {
4311
+ console.log("Escrow state: unavailable");
4312
+ return;
4313
+ }
4314
+ console.log("Escrow state:");
4315
+ console.log(` fundedAmount: ${formatUsdc(escrowState.fundedAmount)} USDC`);
4316
+ console.log(` releasedAmount: ${formatUsdc(escrowState.releasedAmount)} USDC`);
4317
+ console.log(` fundingOk: ${escrowState.fundingOk}`);
4318
+ console.log(` readyToClaim: ${escrowState.readyToClaim}`);
4319
+ console.log(` timeoutRefundReady: ${escrowState.timeoutRefundReady}`);
4320
+ console.log(` timeoutFundsReady: ${escrowState.timeoutFundsReady}`);
4321
+ console.log(` disputeOpen: ${escrowState.disputeOpen}`);
4322
+ console.log(` paidOut: ${escrowState.paidOut}`);
4323
+ };
4324
+
4325
+ const runContractStatus = async (config, contract) => {
4326
+ const { keypair, wallet } = getWalletContext(config);
4327
+ const role = getRole(contract, wallet);
4328
+ console.log(`Contract ID: ${contract.id}`);
4329
+ console.log(
4330
+ formatParticipant(
4331
+ "Customer",
4332
+ contract.client_handle,
4333
+ contract.client_wallet,
4334
+ wallet
4335
+ )
4336
+ );
4337
+ console.log(
4338
+ formatParticipant(
4339
+ "Service Provider",
4340
+ contract.contractor_handle,
4341
+ contract.contractor_wallet,
4342
+ wallet
4343
+ )
4344
+ );
4345
+ if (role && role !== "unknown") {
4346
+ console.log(`Role: ${role}`);
4347
+ }
4348
+ console.log(`Current status: ${contract.status}`);
4349
+ console.log(`Execution mode: ${getExecutionMode(contract)}`);
4350
+ if (contract.created_at) {
4351
+ console.log(`Created: ${formatCreatedAt(contract.created_at)}`);
4352
+ }
4353
+
4354
+ let fundedLabel = "unknown";
4355
+ if (contract.escrow_pda) {
4356
+ const { connection, programId } = getProgram(config, keypair);
4357
+ const escrowPda = new PublicKey(contract.escrow_pda);
4358
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4359
+ if (escrowState) {
4360
+ fundedLabel = escrowState.fundedAmount > 0 ? "yes" : "no";
4361
+ }
4362
+ }
4363
+ console.log(`Is funded: ${fundedLabel}`);
4364
+ };
4365
+
4366
+ const runContractWhoami = async (config, contract) => {
4367
+ const { wallet } = getWalletContext(config);
4368
+ const role = getRole(contract, wallet);
4369
+ renderContractSummary(contract, wallet);
4370
+ console.log(`Role: ${role}`);
4371
+ };
4372
+
4373
+ const runContractNowWhat = async (config, contract) => {
4374
+ const { wallet } = getWalletContext(config);
4375
+ const role = getRole(contract, wallet);
4376
+ if (role !== "client" && role !== "contractor") {
4377
+ console.error("Only contract participants can use this command.");
4378
+ process.exit(1);
4379
+ }
4380
+
4381
+ const status = contract.status;
4382
+ let message = "No next step available.";
4383
+
4384
+ switch (status) {
4385
+ case "waiting_for_init":
4386
+ message = "Initialize the escrow: `nebulon contract <id> init`";
4387
+ break;
4388
+ case "negotiating":
4389
+ if (role === "client") {
4390
+ message =
4391
+ "Set terms and milestones, then sign: `nebulon contract <id> add term ...`, `nebulon contract <id> add milestone ...`, `nebulon contract <id> sign`";
4392
+ } else {
4393
+ message =
4394
+ "Review terms/milestones, then sign: `nebulon contract <id> check terms`, `nebulon contract <id> check milestone list`, `nebulon contract <id> sign`";
4395
+ }
4396
+ break;
4397
+ case "awaiting_signatures":
4398
+ message =
4399
+ "Waiting on the other party to sign. You can check status with `nebulon contract <id> status`.";
4400
+ break;
4401
+ case "waiting_for_funding":
4402
+ if (role === "client") {
4403
+ message = "Fund the contract: `nebulon contract <id> fund`";
4404
+ } else {
4405
+ message =
4406
+ "Waiting for client to fund. You can check status with `nebulon contract <id> status`.";
4407
+ }
4408
+ break;
4409
+ case "waiting_for_milestones_report":
4410
+ if (role === "contractor") {
4411
+ message =
4412
+ "Submit the next milestone: `nebulon contract <id> update milestone <n> ready`";
4413
+ } else {
4414
+ message =
4415
+ "Waiting for contractor to submit a milestone. Use `nebulon contract <id> check milestone list`.";
4416
+ }
4417
+ break;
4418
+ case "in_progress":
4419
+ if (role === "contractor") {
4420
+ message =
4421
+ "Submit the next milestone: `nebulon contract <id> update milestone <n> ready`";
4422
+ } else {
4423
+ message =
4424
+ "Confirm the submitted milestone: `nebulon contract <id> confirm milestone <n>`";
4425
+ }
4426
+ break;
4427
+ case "ready_to_claim":
4428
+ if (role === "contractor") {
4429
+ message = "Claim funds: `nebulon contract <id> claim_funds`";
4430
+ } else {
4431
+ message = "Waiting for contractor to claim funds.";
4432
+ }
4433
+ break;
4434
+ case "completed":
4435
+ message = "Contract completed. No further actions.";
4436
+ break;
4437
+ case "dispute_open":
4438
+ message = "Dispute is open. Await resolution.";
4439
+ break;
4440
+ default:
4441
+ message = `No guidance for status: ${status}`;
4442
+ break;
4443
+ }
4444
+
4445
+ console.log(message);
4446
+ };
4447
+
4448
+ const runRateContract = async (config, contract, scoreValue) => {
4449
+ const { wallet } = getWalletContext(config);
4450
+ const role = getRole(contract, wallet);
4451
+ if (role !== "client" && role !== "contractor") {
4452
+ console.error("Only contract participants can rate a contract.");
4453
+ return;
4454
+ }
4455
+ if (contract.status !== "completed") {
4456
+ console.error("Ratings are only allowed once the contract is completed.");
4457
+ return;
4458
+ }
4459
+
4460
+ const score = Number.parseInt(scoreValue, 10);
4461
+ if (!Number.isInteger(score) || score < 1 || score > 5) {
4462
+ console.error("Usage: nebulon contract <id> rate <1-5>");
4463
+ return;
4464
+ }
4465
+
4466
+ await rateContract(config.backendUrl, config.auth.token, contract.id, score);
4467
+ console.log(`Rating submitted: ${score} star${score === 1 ? "" : "s"}.`);
4468
+ };
4469
+
4470
+ const runContractMode = async (config, contract, mode, options) => {
4471
+ const { wallet } = getWalletContext(config);
4472
+ const role = getRole(contract, wallet);
4473
+ if (role !== "client" && role !== "contractor") {
4474
+ console.error("Only contract participants can change execution mode.");
4475
+ process.exit(1);
4476
+ }
4477
+ const desired = (mode || "").toString().toLowerCase();
4478
+ if (desired !== "per" && desired !== "l1") {
4479
+ console.error("Usage: nebulon contract <id> mode per|l1");
4480
+ return;
4481
+ }
4482
+ const allowedStatuses = new Set(["waiting_for_init", "negotiating", "awaiting_signatures"]);
4483
+ if (!allowedStatuses.has(contract.status)) {
4484
+ console.error("Execution mode can only be changed before funding.");
4485
+ return;
4486
+ }
4487
+ if (desired === getExecutionMode(contract)) {
4488
+ console.log(`Execution mode is already ${desired.toUpperCase()}.`);
4489
+ return;
4490
+ }
4491
+ const proceed = await confirmAction(
4492
+ options.confirm,
4493
+ `Set execution mode to ${desired.toUpperCase()}? yes/no`
4494
+ );
4495
+ if (!proceed) {
4496
+ console.log("Canceled.");
4497
+ return;
4498
+ }
4499
+ await updateContract(config.backendUrl, config.auth.token, contract.id, {
4500
+ executionMode: desired,
4501
+ });
4502
+ contract.execution_mode = desired;
4503
+ if (desired === "l1") {
4504
+ console.log("L1 MODE enabled for this contract (no TEE privacy).");
4505
+ } else {
4506
+ console.log("PER mode enabled for this contract.");
4507
+ }
4508
+ };
4509
+
4510
+ const runCheckTerms = async (config, contract) => {
4511
+ const { wallet } = getWalletContext(config);
4512
+ const role = getRole(contract, wallet);
4513
+ if (role !== "client" && role !== "contractor") {
4514
+ console.error("Only contract participants can view terms.");
4515
+ process.exit(1);
4516
+ }
4517
+ if (!isL1Mode(contract) && contract.terms_encrypted && !contract.privacyReady) {
4518
+ console.error("Privacy key exchange pending. Unable to read terms.");
4519
+ process.exit(1);
4520
+ }
4521
+ const deadline = contract.deadline;
4522
+ const payment = contract.total_payment;
4523
+ console.log(`Deadline : ${deadline ? formatDeadlineValue(deadline) : "missing"}`);
4524
+ console.log(
4525
+ `Payment : ${payment ? `${formatUsdc(payment)} USDC` : "missing"}`
4526
+ );
4527
+ };
4528
+
4529
+ const runOpenDispute = async (config, contract, options) => {
4530
+ const { keypair, wallet, walletKey } = getWalletContext(config);
4531
+ if (!contract.escrow_pda) {
4532
+ console.error("Contract has no escrow.");
4533
+ process.exit(1);
4534
+ }
4535
+ if (contract.status !== "in_progress") {
4536
+ console.error("Disputes can only be opened when the contract is in progress.");
4537
+ return;
4538
+ }
4539
+
4540
+ console.log("Verify data and confirm actions please.");
4541
+ renderContractSummary(contract, wallet);
4542
+ console.log("-Action-");
4543
+ console.log("Dispute contract");
4544
+ console.log("");
4545
+
4546
+ const canProceed = await renderBalancesOrAbort(
4547
+ config,
4548
+ walletKey,
4549
+ contract.mint || config.usdcMint
4550
+ );
4551
+ if (!canProceed) {
4552
+ return;
4553
+ }
4554
+
4555
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
4556
+ if (!proceed) {
4557
+ console.log("Canceled.");
4558
+ return;
4559
+ }
4560
+
4561
+ const { program, connection, programId } = getProgram(config, keypair);
4562
+ const escrowPda = new PublicKey(contract.escrow_pda);
4563
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4564
+ if (!escrowState) {
4565
+ console.error("Escrow not found on-chain.");
4566
+ process.exit(1);
4567
+ }
4568
+ const escrowId = escrowState.escrowId;
4569
+ const disputePda = deriveDisputePda(escrowPda, programId);
4570
+
4571
+ console.log("Processing.");
4572
+ try {
4573
+ const sig = await program.methods
4574
+ .openDispute(new anchor.BN(escrowId.toString()))
4575
+ .accounts({
4576
+ actor: walletKey,
4577
+ escrow: escrowPda,
4578
+ dispute: disputePda,
4579
+ systemProgram: SystemProgram.programId,
4580
+ })
4581
+ .signers([keypair])
4582
+ .rpc();
4583
+ console.log(`Dispute opened. (tx: ${sig})`);
4584
+ console.log("Contact @nortbyt3 on Discord to discuss your case.");
4585
+ successMessage("Dispute opened.");
4586
+ } catch (error) {
4587
+ const message = (error && error.message ? error.message : "").toLowerCase();
4588
+ if (message.includes("invalidstate")) {
4589
+ console.error("Unable to open a dispute in the current contract state.");
4590
+ return;
4591
+ }
4592
+ throw error;
4593
+ }
4594
+ };
4595
+
4596
+ const runResolveDispute = async (
4597
+ config,
4598
+ contract,
4599
+ clientPercent,
4600
+ contractorPercent,
4601
+ options
4602
+ ) => {
4603
+ const { keypair, wallet, walletKey } = getWalletContext(config);
4604
+ if (!contract.escrow_pda) {
4605
+ console.error("Contract has no escrow.");
4606
+ process.exit(1);
4607
+ }
4608
+
4609
+ console.log("Verify data and confirm actions please.");
4610
+ renderContractSummary(contract, wallet);
4611
+ console.log("-Action-");
4612
+ console.log("Resolve dispute");
4613
+ console.log(`Customer : ${clientPercent}%`);
4614
+ console.log(`Service Provider : ${contractorPercent}%`);
4615
+ console.log("");
4616
+
4617
+ const canProceed = await renderBalancesOrAbort(
4618
+ config,
4619
+ walletKey,
4620
+ contract.mint || config.usdcMint
4621
+ );
4622
+ if (!canProceed) {
4623
+ return;
4624
+ }
4625
+
4626
+ const proceed = await confirmAction(options.confirm, "Proceed? yes/no");
4627
+ if (!proceed) {
4628
+ console.log("Canceled.");
4629
+ return;
4630
+ }
4631
+
4632
+ const { program, connection, programId } = getProgram(config, keypair);
4633
+ const escrowPda = new PublicKey(contract.escrow_pda);
4634
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4635
+ if (!escrowState) {
4636
+ console.error("Escrow not found on-chain.");
4637
+ process.exit(1);
4638
+ }
4639
+ const escrowId = escrowState.escrowId;
4640
+ const disputePda = deriveDisputePda(escrowPda, programId);
4641
+ const mint = new PublicKey(contract.mint || config.usdcMint);
4642
+
4643
+ const clientToken = await ensureAta(
4644
+ connection,
4645
+ keypair,
4646
+ new PublicKey(contract.client_wallet),
4647
+ mint
4648
+ );
4649
+ const contractorToken = await ensureAta(
4650
+ connection,
4651
+ keypair,
4652
+ new PublicKey(contract.contractor_wallet),
4653
+ mint
4654
+ );
4655
+ const vaultToken = await getAssociatedTokenAddress(mint, escrowPda, true);
4656
+
4657
+ console.log("Processing.");
4658
+ try {
4659
+ const sig = await program.methods
4660
+ .resolveDisputeSplit(
4661
+ new anchor.BN(escrowId.toString()),
4662
+ Number(clientPercent),
4663
+ Number(contractorPercent)
4664
+ )
4665
+ .accounts({
4666
+ judge: walletKey,
4667
+ escrow: escrowPda,
4668
+ dispute: disputePda,
4669
+ clientToken,
4670
+ contractorToken,
4671
+ vaultToken,
4672
+ tokenProgram: TOKEN_PROGRAM_ID,
4673
+ })
4674
+ .signers([keypair])
4675
+ .rpc();
4676
+ console.log(`Dispute resolved. (tx: ${sig})`);
4677
+ successMessage("Dispute resolved.");
4678
+ } catch (error) {
4679
+ const message = (error && error.message ? error.message : "").toLowerCase();
4680
+ if (message.includes("invalidstate")) {
4681
+ console.error("Unable to resolve a dispute in the current contract state.");
4682
+ return;
4683
+ }
4684
+ if (message.includes("nodispute")) {
4685
+ console.error("No open dispute to resolve.");
4686
+ return;
4687
+ }
4688
+ throw error;
4689
+ }
4690
+ };
4691
+
4692
+ const runContractCommand = async (args, options = {}) => {
4693
+ const config = loadConfig();
4694
+ await ensureHosted(config);
4695
+
4696
+ if (!args.length) {
4697
+ console.log("Usage: nebulon contract <action>");
4698
+ return;
4699
+ }
4700
+
4701
+ const [head, ...rest] = args;
4702
+ const normalized = head.toLowerCase();
4703
+
4704
+ if (normalized === "create") {
4705
+ await runContractsCreate();
4706
+ return;
4707
+ }
4708
+ if (normalized === "list") {
4709
+ await runContractsShow("all", options);
4710
+ return;
4711
+ }
4712
+ if (normalized === "show") {
4713
+ await runContractsShow(rest[0] || "all", options);
4714
+ return;
4715
+ }
4716
+ if (normalized === "active") {
4717
+ await runContractsShow("active", options);
4718
+ return;
4719
+ }
4720
+ if (normalized === "disputed") {
4721
+ await runContractsShow("disputed", options);
4722
+ return;
4723
+ }
4724
+ if (normalized === "isfunded") {
4725
+ const contract = await resolveContract(config, rest[0] || target);
4726
+ if (!contract) {
4727
+ console.error("Contract not found.");
4728
+ process.exit(1);
4729
+ }
4730
+ const { keypair } = getWalletContext(config);
4731
+ if (!contract.escrow_pda) {
4732
+ console.log("Is funded: no (escrow not initialized)");
4733
+ return;
4734
+ }
4735
+ const { connection, programId } = getProgram(config, keypair);
4736
+ const escrowPda = new PublicKey(contract.escrow_pda);
4737
+ const escrowState = await getEscrowState(connection, programId, escrowPda);
4738
+ if (!escrowState) {
4739
+ console.log("Is funded: unknown");
4740
+ return;
4741
+ }
4742
+ console.log(`Is funded: ${escrowState.fundedAmount > 0 ? "yes" : "no"}`);
4743
+ return;
4744
+ }
4745
+ if (normalized === "status") {
4746
+ const contract = await resolveContract(config, rest[0] || target);
4747
+ if (!contract) {
4748
+ console.error("Contract not found.");
4749
+ process.exit(1);
4750
+ }
4751
+ await runContractStatus(config, contract);
4752
+ return;
4753
+ }
4754
+
4755
+ const target = head;
4756
+ const contract = await resolveContract(config, target);
4757
+ if (!contract) {
4758
+ console.error("Contract not found.");
4759
+ process.exit(1);
4760
+ }
4761
+
4762
+ if (!rest.length) {
4763
+ console.log("Usage: nebulon contract <id|index> <action>");
4764
+ return;
4765
+ }
4766
+
4767
+ const action = rest[0].toLowerCase();
4768
+ if (action === "init") {
4769
+ await runInitContract(config, contract, options);
4770
+ return;
4771
+ }
4772
+ if (action === "details") {
4773
+ await runContractDetails(config, contract);
4774
+ return;
4775
+ }
4776
+ if (action === "status") {
4777
+ await runContractStatus(config, contract);
4778
+ return;
4779
+ }
4780
+ if (action === "add" && rest[1] === "milestone") {
4781
+ const title = rest.slice(2).join(" ").trim();
4782
+ if (!title) {
4783
+ console.error("Milestone description required.");
4784
+ return;
4785
+ }
4786
+ await runAddMilestone(config, contract, title, options);
4787
+ return;
4788
+ }
4789
+ if (action === "milestone" && rest[1] === "list") {
4790
+ await runMilestoneList(config, contract);
4791
+ return;
4792
+ }
4793
+ if (action === "term" && rest[1] === "list") {
4794
+ await runCheckTerms(config, contract);
4795
+ return;
4796
+ }
4797
+ if (action === "milestone" && rest[1] && rest[2] === "status") {
4798
+ await runMilestoneStatus(config, contract, rest[1]);
4799
+ return;
4800
+ }
4801
+ if (action === "whoami") {
4802
+ await runContractWhoami(config, contract);
4803
+ return;
4804
+ }
4805
+ if (action === "nowwhat") {
4806
+ await runContractNowWhat(config, contract);
4807
+ return;
4808
+ }
4809
+ if (action === "rate") {
4810
+ await runRateContract(config, contract, rest[1]);
4811
+ return;
4812
+ }
4813
+ if (action === "mode") {
4814
+ await runContractMode(config, contract, rest[1], options);
4815
+ return;
4816
+ }
4817
+
4818
+ if (action === "disable" && rest[1] === "milestone") {
4819
+ if (!rest[2]) {
4820
+ console.error("Usage: nebulon contract <id> disable milestone <n>");
4821
+ return;
4822
+ }
4823
+ await runDisableMilestone(config, contract, rest[2], options);
4824
+ return;
4825
+ }
4826
+
4827
+ if (action === "add" && rest[1] === "term") {
4828
+ const field = (rest[2] || "").toLowerCase();
4829
+ const value = rest.slice(3).join(" ").trim();
4830
+ if (!field || !value) {
4831
+ console.error(
4832
+ "Usage: nebulon contract <id> add term <deadline|payment> <value>"
4833
+ );
4834
+ return;
4835
+ }
4836
+ await runAddTerm(config, contract, field, value, options);
4837
+ return;
4838
+ }
4839
+
4840
+ if (action === "sign") {
4841
+ await runSignContract(config, contract, options);
4842
+ return;
4843
+ }
4844
+
4845
+ if (action === "fund") {
4846
+ await runFundContract(config, contract, options);
4847
+ return;
4848
+ }
4849
+
4850
+ if (
4851
+ action === "milestone" &&
4852
+ rest[1] &&
4853
+ (rest[2] === "ready" || rest[2] === "setready")
4854
+ ) {
4855
+ await runUpdateMilestone(config, contract, rest[1], options);
4856
+ return;
4857
+ }
4858
+
4859
+ if (action === "milestone" && rest[1] && rest[2] === "confirm") {
4860
+ await runConfirmMilestone(config, contract, rest[1], options);
4861
+ return;
4862
+ }
4863
+
4864
+ if (action === "claim_funds") {
4865
+ await runClaimFunds(config, contract, options);
4866
+ return;
4867
+ }
4868
+
4869
+ if (action === "sync") {
4870
+ await runSyncFlags(config, contract, options);
4871
+ return;
4872
+ }
4873
+
4874
+ if (action === "dispute") {
4875
+ await runOpenDispute(config, contract, options);
4876
+ return;
4877
+ }
4878
+
4879
+ if (action === "resolve_dispute") {
4880
+ if (rest.length < 3) {
4881
+ console.error(
4882
+ "Usage: nebulon contract <id> resolve_dispute <customer%> <provider%>"
4883
+ );
4884
+ return;
4885
+ }
4886
+ await runResolveDispute(config, contract, rest[1], rest[2], options);
4887
+ return;
4888
+ }
4889
+
4890
+ console.error("Unknown contract command.");
4891
+ };
4892
+
4893
+ module.exports = {
4894
+ runContractCommand,
4895
+ };