@hsuite/smart-engines-cli 1.2.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +272 -58
  2. package/dist/_lib/attestation.d.ts +45 -0
  3. package/dist/_lib/attestation.d.ts.map +1 -0
  4. package/dist/_lib/attestation.js +76 -0
  5. package/dist/_lib/attestation.js.map +1 -0
  6. package/dist/_lib/baas.d.ts +147 -0
  7. package/dist/_lib/baas.d.ts.map +1 -0
  8. package/dist/_lib/baas.js +260 -0
  9. package/dist/_lib/baas.js.map +1 -0
  10. package/dist/_lib/cluster.d.ts +6 -6
  11. package/dist/_lib/cluster.js +6 -6
  12. package/dist/_lib/deploy-manifest.d.ts +69 -0
  13. package/dist/_lib/deploy-manifest.d.ts.map +1 -0
  14. package/dist/_lib/deploy-manifest.js +87 -0
  15. package/dist/_lib/deploy-manifest.js.map +1 -0
  16. package/dist/_lib/docker.d.ts +82 -0
  17. package/dist/_lib/docker.d.ts.map +1 -0
  18. package/dist/_lib/docker.js +164 -0
  19. package/dist/_lib/docker.js.map +1 -0
  20. package/dist/_lib/faucet-client.d.ts +64 -0
  21. package/dist/_lib/faucet-client.d.ts.map +1 -0
  22. package/dist/_lib/faucet-client.js +92 -0
  23. package/dist/_lib/faucet-client.js.map +1 -0
  24. package/dist/_lib/framework.d.ts +59 -0
  25. package/dist/_lib/framework.d.ts.map +1 -0
  26. package/dist/_lib/framework.js +149 -0
  27. package/dist/_lib/framework.js.map +1 -0
  28. package/dist/_lib/frontend-guard.d.ts +13 -0
  29. package/dist/_lib/frontend-guard.d.ts.map +1 -0
  30. package/dist/_lib/frontend-guard.js +41 -0
  31. package/dist/_lib/frontend-guard.js.map +1 -0
  32. package/dist/_lib/registry-fanout.d.ts +178 -0
  33. package/dist/_lib/registry-fanout.d.ts.map +1 -0
  34. package/dist/_lib/registry-fanout.js +545 -0
  35. package/dist/_lib/registry-fanout.js.map +1 -0
  36. package/dist/_lib/runtime-env.d.ts +126 -0
  37. package/dist/_lib/runtime-env.d.ts.map +1 -0
  38. package/dist/_lib/runtime-env.js +172 -0
  39. package/dist/_lib/runtime-env.js.map +1 -0
  40. package/dist/_lib/subscription-client.d.ts +129 -18
  41. package/dist/_lib/subscription-client.d.ts.map +1 -1
  42. package/dist/_lib/subscription-client.js +268 -40
  43. package/dist/_lib/subscription-client.js.map +1 -1
  44. package/dist/_lib/subscription-env.d.ts +35 -0
  45. package/dist/_lib/subscription-env.d.ts.map +1 -1
  46. package/dist/_lib/subscription-env.js +46 -2
  47. package/dist/_lib/subscription-env.js.map +1 -1
  48. package/dist/commands/app-lifecycle.d.ts +36 -0
  49. package/dist/commands/app-lifecycle.d.ts.map +1 -0
  50. package/dist/commands/app-lifecycle.js +145 -0
  51. package/dist/commands/app-lifecycle.js.map +1 -0
  52. package/dist/commands/ci.d.ts +45 -0
  53. package/dist/commands/ci.d.ts.map +1 -0
  54. package/dist/commands/ci.js +174 -0
  55. package/dist/commands/ci.js.map +1 -0
  56. package/dist/commands/deploy-manifest.d.ts +11 -0
  57. package/dist/commands/deploy-manifest.d.ts.map +1 -0
  58. package/dist/commands/deploy-manifest.js +17 -0
  59. package/dist/commands/deploy-manifest.js.map +1 -0
  60. package/dist/commands/deploy.d.ts +70 -11
  61. package/dist/commands/deploy.d.ts.map +1 -1
  62. package/dist/commands/deploy.js +273 -150
  63. package/dist/commands/deploy.js.map +1 -1
  64. package/dist/commands/downgrade.js +1 -1
  65. package/dist/commands/downgrade.js.map +1 -1
  66. package/dist/commands/faucet.d.ts +78 -0
  67. package/dist/commands/faucet.d.ts.map +1 -0
  68. package/dist/commands/faucet.js +184 -0
  69. package/dist/commands/faucet.js.map +1 -0
  70. package/dist/commands/governance.js +1 -1
  71. package/dist/commands/governance.js.map +1 -1
  72. package/dist/commands/hist-balance.d.ts +1 -1
  73. package/dist/commands/hist-balance.js +1 -1
  74. package/dist/commands/init.js +1 -1
  75. package/dist/commands/list.d.ts +22 -0
  76. package/dist/commands/list.d.ts.map +1 -0
  77. package/dist/commands/list.js +81 -0
  78. package/dist/commands/list.js.map +1 -0
  79. package/dist/commands/personhood.js +2 -2
  80. package/dist/commands/personhood.js.map +1 -1
  81. package/dist/commands/redeploy.d.ts +62 -0
  82. package/dist/commands/redeploy.d.ts.map +1 -0
  83. package/dist/commands/redeploy.js +322 -0
  84. package/dist/commands/redeploy.js.map +1 -0
  85. package/dist/commands/status.d.ts +51 -0
  86. package/dist/commands/status.d.ts.map +1 -0
  87. package/dist/commands/status.js +138 -0
  88. package/dist/commands/status.js.map +1 -0
  89. package/dist/commands/subscribe.d.ts +94 -17
  90. package/dist/commands/subscribe.d.ts.map +1 -1
  91. package/dist/commands/subscribe.js +487 -81
  92. package/dist/commands/subscribe.js.map +1 -1
  93. package/dist/commands/transactions.d.ts +60 -0
  94. package/dist/commands/transactions.d.ts.map +1 -0
  95. package/dist/commands/transactions.js +101 -0
  96. package/dist/commands/transactions.js.map +1 -0
  97. package/dist/commands/update.d.ts +40 -0
  98. package/dist/commands/update.d.ts.map +1 -0
  99. package/dist/commands/update.js +114 -0
  100. package/dist/commands/update.js.map +1 -0
  101. package/dist/commands/verify.d.ts +10 -19
  102. package/dist/commands/verify.d.ts.map +1 -1
  103. package/dist/commands/verify.js +14 -23
  104. package/dist/commands/verify.js.map +1 -1
  105. package/dist/index.d.ts +12 -3
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +32 -3
  108. package/dist/index.js.map +1 -1
  109. package/package.json +11 -4
@@ -6,160 +6,548 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.subscribeCommand = void 0;
7
7
  exports.runSubscribe = runSubscribe;
8
8
  /**
9
- * `hsuite subscribe` — Mint a subscription NFT against the validator's
10
- * `/api/v3/subscription/*` surface.
9
+ * `hsuite subscribe` — Onboard a smart-app the CANONICAL, MINT-FIRST way:
10
+ * create the smart-app's DKG ENTITY first, mint the builder subscription NFT
11
+ * ON THAT ENTITY's id, and land it on the dev wallet — so `1 smart-app =
12
+ * 1 DKG entity = 1 NFT`, ACTIVE from mint.
11
13
  *
12
- * Flow (per docs/superpowers/specs/2026-05-27-developer-subscription-gating.md §3.5):
13
- * 1. Read XRPL_ADDRESS + XRPL_SEED from env (written by `hsuite init`).
14
- * 2. POST /api/v3/subscription/request → server creates subscription doc.
15
- * - `free_testnet`: server auto-activates + mints. CLI polls /status
16
- * until NFT serial is set.
17
- * - paid tier: server returns depositInstructions. CLI displays them,
18
- * polls /status every 5s until status: 'PENDING_MINT' (deposit
19
- * confirmed), then POSTs /mint to flip ACTIVE.
20
- * 3. Verify NFT serial lands on the wallet via GET /status.
21
- * 4. Write SUBSCRIPTION_APP_ID + SUBSCRIPTION_NFT_SERIAL to .env.
14
+ * ## Why this creates the entity (the mint-first re-architecture)
15
+ *
16
+ * `subscribe` used to mint the subscription NFT on a throwaway random
17
+ * `app-${uuid}` that was NEVER a DKG entity, while `hsuite deploy` separately
18
+ * minted a FRESH entity (+ a SECOND subscription) at `init` time. Paid id ≠
19
+ * deployed id → the deployed app stayed PENDING, redeploys forked, and two
20
+ * NFTs were minted. Now `subscribe` creates the durable DKG entity up front and
21
+ * uses its `entityId` as the subscription appId end-to-end; `deploy` reuses
22
+ * that same id (it no longer creates anything). Output:
23
+ * `SUBSCRIPTION_APP_ID == DEPLOYED_APP_ID == DKG entityId`, ACTIVE, ONE NFT.
24
+ *
25
+ * Full flow:
26
+ * 1. Read XRPL_ADDRESS + XRPL_SEED from env (written by `hsuite init`) and the
27
+ * validator URL.
28
+ * 2. Web3-auth against the validator (challenge → sign → bearer). The
29
+ * subscription + create routes are session-guarded. (Auth routes have NO
30
+ * /api prefix.)
31
+ * 3. POST /api/v3/smart-apps (createApp) — run the per-entity DKG ceremony
32
+ * to derive the canonical `entityId` AND create the (paid, pending-deposit)
33
+ * subscription in the same in-process flow. The response carries
34
+ * `{ appId: entityId, depositInstructions, subscriptionId }`. THIS replaces
35
+ * the old random-id + standalone `subscription/request`. From here the
36
+ * `entityId` is THE appId for everything downstream.
37
+ * 4. Ensure deposit instructions: the createApp response normally carries them.
38
+ * If a node deferred deposit-account creation, fall back to the idempotent
39
+ * POST /api/v3/subscription/request { appId: entityId, … } (same retry/503
40
+ * semantics, returns the SAME subscription's deposit instructions — no
41
+ * second subscription, no second deposit wallet).
42
+ * 5. Render the deposit instructions.
43
+ * 6. AUTO-PAY the deposit from the dev wallet over the XRPL testnet (HST
44
+ * Payment with a SendMax covering the 0.25% transfer fee). `--no-autopay`
45
+ * skips this and prints instructions for manual (XUMM) payment.
46
+ * 7. Poll GET /api/v3/subscription/status/:entityId until the deposit is
47
+ * confirmed and ready to mint (status in { deposit_confirmed, pending_mint,
48
+ * minting, active }; the real confirmed status is `deposit_confirmed`).
49
+ * 8. POST /api/v3/subscription/mint/:entityId — activates the subscription +
50
+ * flips the SmartApp doc PENDING_SUBSCRIPTION → ACTIVE (via `activateApp`,
51
+ * gated on the deposit-backed NFT mint) and kicks off the async NFT mint.
52
+ * 9. Poll /status until `subscriptionNftSerial` is set OR `chainNfts.xrpl.nftId`
53
+ * is present (the NFT mints asynchronously via the mint subscriber).
54
+ * 10. Accept the brokered NFT sell offer so it lands on the dev wallet.
55
+ * 11. Verify account_nfts, then write SUBSCRIPTION_APP_ID + APP_ID +
56
+ * SUBSCRIPTION_NFT_SERIAL (= entityId) to the env file.
22
57
  */
23
58
  const commander_1 = require("commander");
24
59
  const chalk_1 = __importDefault(require("chalk"));
25
60
  const ora_1 = __importDefault(require("ora"));
26
- const crypto_1 = require("crypto");
27
61
  const env_1 = require("../_lib/env");
28
62
  const subscription_client_1 = require("../_lib/subscription-client");
29
63
  const subscription_env_1 = require("../_lib/subscription-env");
30
- const VALID_TIERS = ['free_testnet', 'starter', 'professional', 'enterprise'];
64
+ /** Public XRPL testnet websocket endpoint (matches `hsuite init` + faucet). */
65
+ const XRPL_TESTNET_WSS = 'wss://s.altnet.rippletest.net:51233';
66
+ /**
67
+ * Multiplier applied to the deposit amount for the Payment `SendMax` so the
68
+ * 0.25% HST transfer fee is covered (0.25% → ×1.0025; we use ×1.004 for a small
69
+ * safety margin against rounding).
70
+ */
71
+ const HST_TRANSFER_FEE_MULTIPLIER = 1.004;
72
+ // Every subscription is paid + NFT-backed (the validator's createSubscription
73
+ // rejects any unknown tier on the public path). The four-tier ladder is
74
+ // `builder → growth → scale → enterprise`; `builder` ($49/mo) is the entry tier.
75
+ const VALID_TIERS = ['builder', 'growth', 'scale', 'enterprise'];
31
76
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
77
+ function defaultXrplClientFactory(wss) {
78
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
79
+ const { Client } = require('xrpl');
80
+ return new Client(wss);
81
+ }
82
+ function defaultWalletSignFactory(seed) {
83
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
84
+ const { Wallet } = require('xrpl');
85
+ const wallet = Wallet.fromSeed(seed);
86
+ return (tx) => wallet.sign(tx).tx_blob;
87
+ }
32
88
  /**
33
89
  * Pure orchestration body — exported for unit tests.
34
90
  *
35
- * Returns the appId + NFT serial (when minted) so callers can persist them.
36
- * Throws on every error path; the command-level action wraps this and
37
- * exits the process.
91
+ * Returns the appId + NFT serial (once minted + accepted) so callers can
92
+ * persist them. Throws on every hard-error path; the command-level action wraps
93
+ * this and exits the process.
38
94
  */
39
95
  async function runSubscribe(opts) {
40
96
  const env = (0, env_1.readEnv)(opts.env);
41
- const { address } = (0, subscription_env_1.requireXrplCreds)(env);
97
+ const { address, seed } = (0, subscription_env_1.requireXrplCreds)(env);
42
98
  const tier = opts.tier;
43
99
  if (!VALID_TIERS.includes(tier)) {
44
100
  throw new Error(`unknown tier "${opts.tier}". Valid: ${VALID_TIERS.join(', ')}`);
45
101
  }
46
- const selectedNetworks = opts.networks
47
- .split(',')
48
- .map((s) => s.trim())
49
- .filter(Boolean);
50
- if (selectedNetworks.length === 0) {
51
- throw new Error('At least one network required (e.g. --networks xrpl)');
52
- }
53
102
  const baseUrl = (0, subscription_env_1.requireValidatorUrl)(env, { gateway: opts.gateway });
54
- const client = (opts.clientFactory ?? ((b) => new subscription_client_1.SubscriptionClient({ baseUrl: b })))(baseUrl);
103
+ const client = (opts.clientFactory ??
104
+ ((b) => new subscription_client_1.SubscriptionClient({ baseUrl: b, auth: { chain: 'xrpl', address, seed } })))(baseUrl);
105
+ // The subscription routes are session-guarded: obtain a validator bearer
106
+ // session by signing a challenge before any call.
107
+ await client.authenticate();
55
108
  const sleep = opts.sleep ?? defaultSleep;
56
- const appId = opts.appId ??
57
- env.get('SUBSCRIPTION_APP_ID') ??
58
- `app-${(0, crypto_1.randomUUID)().slice(0, 8)}`;
59
109
  const appName = opts.appName ?? env.get('APP_NAME') ?? 'Smart App Showcase';
60
- // Step 1: POST /request
61
- let requestResp;
62
- try {
63
- requestResp = await client.request({
64
- appId,
65
- developerAccountId: address,
66
- chain: 'xrpl',
67
- appName,
68
- selectedTier: tier,
69
- selectedNetworks,
70
- });
71
- }
72
- catch (err) {
73
- throw new Error(`subscription request failed: ${err.message}`);
110
+ // A DNS-safe name is required by the create DTO (the entityId is derived by
111
+ // the DKG ceremony, not from this name; the name is the display label).
112
+ const dnsName = toDnsSafeName(appName);
113
+ // Step 1: CREATE THE DKG ENTITY (mint-first). POST /api/v3/smart-apps runs
114
+ // the per-entity DKG ceremony → canonical `entityId`, AND creates the (paid,
115
+ // pending-deposit) subscription on that same id in one in-process flow. The
116
+ // `entityId` it returns is THE appId for the subscription + deposit + mint +
117
+ // every later deploy. This REPLACES the old throwaway `app-${uuid}` + a
118
+ // standalone `subscription/request`.
119
+ //
120
+ // An explicit --app-id / a previously-written SUBSCRIPTION_APP_ID short-
121
+ // circuits create (re-running `subscribe` to finish paying/minting an
122
+ // already-created entity), so a resumed run does not fork a second entity.
123
+ const preexistingAppId = opts.appId ?? env.get('SUBSCRIPTION_APP_ID');
124
+ let appId;
125
+ let created;
126
+ if (preexistingAppId) {
127
+ appId = preexistingAppId;
74
128
  }
75
- if (!requestResp.success) {
76
- throw new Error(`subscription request rejected: ${requestResp.message ?? 'unknown error'}`);
129
+ else {
130
+ try {
131
+ created = await client.createApp({
132
+ name: appName,
133
+ services: ['database'],
134
+ // `config.appId` is a placeholder — SmartAppService.create overwrites it
135
+ // with the DKG `entityId`. acceptedLoginChains must be non-empty.
136
+ config: {
137
+ appId: dnsName,
138
+ acceptedLoginChains: ['xrpl'],
139
+ acceptedPaymentTokens: [],
140
+ },
141
+ tier,
142
+ networks: ['testnet'],
143
+ // validatorRules omitted (optional) — the factory synthesizes the
144
+ // canonical security-first default for the standard deployment path.
145
+ });
146
+ }
147
+ catch (err) {
148
+ throw new Error(`smart-app entity creation failed: ${err.message}`);
149
+ }
150
+ if (!created.appId) {
151
+ throw new Error('create returned no appId (DKG entityId) — server contract violation');
152
+ }
153
+ appId = created.appId;
77
154
  }
78
- const status = await (async () => {
79
- // Step 2a: free_testnet path — server auto-activates. Poll until NFT serial set.
80
- if (tier === 'free_testnet') {
81
- return pollStatus(client, appId, sleep, opts, (s) => Boolean(s.subscriptionNftSerial));
155
+ // Step 2: obtain the HST deposit instructions for THIS entityId. createApp
156
+ // normally returns them (the subscription was created alongside the entity).
157
+ // If a node deferred deposit-account creation, fall back to the idempotent
158
+ // POST /api/v3/subscription/request { appId: entityId } — it carries the
159
+ // retry/503 semantics and returns the SAME subscription's deposit
160
+ // instructions (no second subscription, no second deposit wallet).
161
+ let requested;
162
+ let deposit = created?.depositInstructions;
163
+ let subscriptionId = created?.subscriptionId;
164
+ if (!deposit) {
165
+ try {
166
+ requested = await client.request({
167
+ appId,
168
+ developerAccountId: address,
169
+ chain: 'xrpl',
170
+ appName,
171
+ selectedTier: tier,
172
+ selectedNetworks: ['xrpl'],
173
+ });
174
+ }
175
+ catch (err) {
176
+ throw new Error(`subscription request failed: ${err.message}`);
82
177
  }
83
- // Step 2b: paid tier — display deposit instructions + poll until PENDING_MINT.
84
- if (!requestResp.depositInstructions) {
85
- throw new Error('paid tier returned no depositInstructions — server contract violation');
178
+ deposit = requested.depositInstructions;
179
+ subscriptionId = subscriptionId ?? requested.subscriptionId;
180
+ }
181
+ if (!deposit) {
182
+ // No deposit instructions. On a RESUME of an already-active subscription
183
+ // (--app-id / SUBSCRIPTION_APP_ID, deposit long paid, NFT possibly minted),
184
+ // this is EXPECTED — not a contract violation. Check /status: if the
185
+ // subscription is already provisioned, skip deposit+pay+mint and go straight
186
+ // to accepting the brokered sell offer so the NFT lands on the dev wallet.
187
+ if (preexistingAppId) {
188
+ const existing = await client.status(appId);
189
+ const alreadyProvisioned = Boolean(existing.subscriptionNftSerial) ||
190
+ Boolean(existing.chainNfts?.xrpl?.nftId) ||
191
+ (existing.status ?? '').toLowerCase() === 'active';
192
+ if (alreadyProvisioned) {
193
+ // NFT coordinates can lag the 'active' status by a few ledgers.
194
+ const status = existing.subscriptionNftSerial || existing.chainNfts?.xrpl?.nftId
195
+ ? existing
196
+ : await pollStatus(client, appId, sleep, opts, (s) => Boolean(s.subscriptionNftSerial) || Boolean(s.chainNfts?.xrpl?.nftId), opts.nftPollAttempts ?? 24);
197
+ const nftAccepted = await acceptSubscriptionNft(opts, address, seed, sleep, status);
198
+ return {
199
+ appId,
200
+ subscriptionId: status.subscriptionId ?? subscriptionId,
201
+ nftSerial: status.subscriptionNftSerial,
202
+ tier,
203
+ status: status.status,
204
+ depositPaid: true,
205
+ nftAccepted,
206
+ };
207
+ }
86
208
  }
87
- renderDepositInstructions(requestResp.depositInstructions, tier);
88
- await pollStatus(client, appId, sleep, opts, (s) => isPendingMint(s.status));
89
- // Step 3: POST /mint
209
+ throw new Error('no depositInstructions for the created entity — server contract violation');
210
+ }
211
+ renderDepositInstructions(deposit, tier, appId);
212
+ // Step 3: auto-pay the deposit from the dev wallet (unless --no-autopay).
213
+ if (opts.noAutopay) {
214
+ renderManualPaymentHint(deposit);
215
+ return {
216
+ appId,
217
+ subscriptionId,
218
+ tier,
219
+ status: requested?.status ?? 'pending_deposit',
220
+ depositPaid: false,
221
+ nftAccepted: false,
222
+ };
223
+ }
224
+ await payDeposit(opts, address, seed, deposit);
225
+ // Step 3: poll /status until the deposit is confirmed and ready to mint.
226
+ await pollStatus(client, appId, sleep, opts, (s) => isReadyToMint(s.status));
227
+ // Step 4: POST /mint — kicks off the async NFT mint. Tolerate a non-2xx here
228
+ // (the server may still be finalizing); the status poll below is the source of
229
+ // truth for whether the NFT actually minted.
230
+ try {
90
231
  const mintResp = await client.mint(appId);
91
232
  if (!mintResp.success) {
92
- throw new Error('mint failed (server returned success=false)');
233
+ console.log(chalk_1.default.yellow(' mint reported success=false — continuing to poll status'));
93
234
  }
94
- // Step 4: poll until NFT serial is set.
95
- return pollStatus(client, appId, sleep, opts, (s) => Boolean(s.subscriptionNftSerial));
96
- })();
235
+ }
236
+ catch (err) {
237
+ console.log(chalk_1.default.yellow(` mint HTTP error (tolerated, will poll status): ${err.message}`));
238
+ }
239
+ // Step 5: poll until the NFT serial is set OR the minted NFT coordinates
240
+ // appear on /status (the NFT mints asynchronously via the mint subscriber).
241
+ // The 2-of-3 issuer MPC mint + offer creation take ~15-25s, so poll ~24×5s
242
+ // (~120s) before giving up. Bounded by attempt count (not wall-clock) so the
243
+ // injected-`sleep` unit tests stay fast.
244
+ const status = await pollStatus(client, appId, sleep, opts, (s) => Boolean(s.subscriptionNftSerial) ||
245
+ Boolean(s.chainNfts?.xrpl?.nftId) ||
246
+ (s.status ?? '').toLowerCase() === 'active', opts.nftPollAttempts ?? 24);
247
+ // Step 6: accept the brokered sell offer so the NFT lands on the dev wallet.
248
+ const nftAccepted = await acceptSubscriptionNft(opts, address, seed, sleep, status);
97
249
  return {
98
250
  appId,
99
- subscriptionId: status.subscriptionId,
251
+ subscriptionId: status.subscriptionId ?? subscriptionId,
100
252
  nftSerial: status.subscriptionNftSerial,
101
253
  tier,
102
254
  status: status.status,
255
+ depositPaid: true,
256
+ nftAccepted,
103
257
  };
104
258
  }
105
- function isPendingMint(status) {
259
+ /**
260
+ * Coerce a display name into the DNS-safe shape the validator's create DTO
261
+ * requires (lowercase alphanumeric + hyphens, no leading/trailing hyphen, ≤63
262
+ * chars). The DKG `entityId` is derived by the ceremony — this only sanitises
263
+ * the placeholder `config.appId` + name field so a human-readable APP_NAME like
264
+ * "Smart App Showcase" doesn't 400 the create.
265
+ */
266
+ function toDnsSafeName(name) {
267
+ const slug = name
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/g, '-')
270
+ .replace(/^-+|-+$/g, '')
271
+ .slice(0, 63)
272
+ .replace(/-+$/g, '');
273
+ return slug.length > 0 ? slug : 'smart-app';
274
+ }
275
+ /**
276
+ * Pay the HST deposit from the dev wallet over the XRPL testnet.
277
+ *
278
+ * The HST issuer is resolved from the deposit account's HST trust line: query
279
+ * `account_lines` on the deposit `walletAddress`, find the line with currency
280
+ * 'HST', and use its `account` (the trust-line peer) as the issuer. We then
281
+ * submit a `Payment` of `amount` whole HST with a `SendMax` of
282
+ * `amount × 1.004` to cover the 0.25% HST transfer fee.
283
+ *
284
+ * Requires tesSUCCESS. Always disconnects.
285
+ */
286
+ async function payDeposit(opts, address, seed, deposit) {
287
+ if (deposit.chain !== 'xrpl') {
288
+ throw new Error(`auto-pay only supports XRPL deposits (got chain=${deposit.chain}); ` +
289
+ 'use --no-autopay and pay manually');
290
+ }
291
+ const walletSign = (opts.walletSignFactory ?? defaultWalletSignFactory)(seed);
292
+ const client = (opts.xrplClientFactory ?? defaultXrplClientFactory)(XRPL_TESTNET_WSS);
293
+ await client.connect();
294
+ try {
295
+ const issuer = await resolveHstIssuer(client, deposit.walletAddress);
296
+ const value = deposit.amount;
297
+ const sendMax = (Number(value) * HST_TRANSFER_FEE_MULTIPLIER).toFixed(6);
298
+ const tx = {
299
+ TransactionType: 'Payment',
300
+ Account: address,
301
+ Destination: deposit.walletAddress,
302
+ Amount: { currency: 'HST', issuer, value },
303
+ SendMax: { currency: 'HST', issuer, value: sendMax },
304
+ };
305
+ const prepared = await client.autofill(tx);
306
+ const txBlob = walletSign(prepared);
307
+ const result = await client.submitAndWait(txBlob);
308
+ const code = result.result?.meta?.TransactionResult;
309
+ if (code !== 'tesSUCCESS') {
310
+ throw new Error(`deposit Payment failed: ${code ?? 'unknown result'}`);
311
+ }
312
+ }
313
+ finally {
314
+ await client.disconnect();
315
+ }
316
+ }
317
+ /**
318
+ * Resolve the HST issuer from the deposit account's trust lines.
319
+ *
320
+ * The deposit account holds an HST trust line whose peer (`account`) is the HST
321
+ * issuer. We query `account_lines` on the deposit wallet and return the peer of
322
+ * the line whose `currency` is 'HST'.
323
+ */
324
+ async function resolveHstIssuer(client, depositWallet) {
325
+ const res = await client.request({ command: 'account_lines', account: depositWallet });
326
+ const lines = res.result?.lines ?? [];
327
+ const hst = lines.find((l) => l?.currency === 'HST');
328
+ if (!hst?.account) {
329
+ throw new Error(`could not resolve HST issuer: deposit account ${depositWallet} has no HST trust line`);
330
+ }
331
+ return hst.account;
332
+ }
333
+ /**
334
+ * Ready-to-mint check.
335
+ *
336
+ * The server's ready-to-mint state is `deposit_confirmed`
337
+ * (`activateSubscription` refuses to mint unless status === DEPOSIT_CONFIRMED).
338
+ * We treat any of {deposit_confirmed, pending_mint, minting, active} as ready so
339
+ * the flow is robust to a server that already advanced past the gate by the time
340
+ * we poll. NOTE: `pending_mint` is NOT the confirmed status — `deposit_confirmed`
341
+ * is.
342
+ */
343
+ function isReadyToMint(status) {
106
344
  if (!status)
107
345
  return false;
108
- // SubscriptionStatus enum values from libs/multi-chain-core. Accept both
109
- // canonical `pending_mint` and an already-active state (server may skip
110
- // straight to ACTIVE if it's already minted by the time we poll).
111
346
  const s = status.toLowerCase();
112
- return s === 'pending_mint' || s === 'active' || s === 'minting';
347
+ return (s === 'deposit_confirmed' ||
348
+ s === 'pending_mint' ||
349
+ s === 'minting' ||
350
+ s === 'active');
113
351
  }
114
- async function pollStatus(client, appId, sleep, opts, done) {
352
+ async function pollStatus(client, appId, sleep, opts, done, maxAttempts) {
115
353
  const interval = opts.pollIntervalMs ?? 5000;
116
354
  const timeout = opts.pollTimeoutMs ?? 5 * 60 * 1000;
117
355
  const deadline = Date.now() + timeout;
118
356
  // First tick immediately so tests that prime a single response don't have
119
- // to wait the full 5s.
357
+ // to wait the full 5s. Stops on the `done` predicate, an attempt cap, or the
358
+ // wall-clock deadline — whichever comes first.
359
+ let attempt = 0;
120
360
  while (true) {
121
361
  const status = await client.status(appId);
122
362
  if (done(status))
123
363
  return status;
364
+ attempt += 1;
365
+ if (maxAttempts !== undefined && attempt >= maxAttempts) {
366
+ throw new Error(`timed out waiting for subscription status for ${appId} after ${attempt} polls`);
367
+ }
124
368
  if (Date.now() > deadline) {
125
369
  throw new Error(`timed out waiting for subscription status for ${appId} after ${timeout}ms`);
126
370
  }
127
371
  await sleep(interval);
128
372
  }
129
373
  }
130
- function renderDepositInstructions(d, tier) {
131
- const xummDeeplink = buildXummDeeplink(d);
374
+ /**
375
+ * Accept the subscription NFT sell offer on the dev wallet.
376
+ *
377
+ * The mint creates an XRPL `NFTokenCreateOffer` (SELL, Amount '0',
378
+ * Destination = dev wallet) owned by the issuer — so the NFT does NOT
379
+ * auto-land. The dev wallet must submit `NFTokenAcceptOffer`.
380
+ *
381
+ * Strategy:
382
+ * a. Poll the dev wallet's `account_nfts` first — if the NFT is already there
383
+ * (already accepted on a prior run, or auto-delivered), we're done.
384
+ * b. Else, if `chainNfts.xrpl.nftId` is known, query `nft_sell_offers` for
385
+ * that NFTokenID and accept the offer whose `destination` is the dev wallet
386
+ * (else the first offer). Exact + issuer-free.
387
+ * c. Else fall back to the issuer-side scan: enumerate the issuer's
388
+ * `account_objects` (type `nft_offer`) and accept the sell offer whose
389
+ * `Destination` is the dev wallet. Issuer comes from
390
+ * `chainNfts.xrpl.issuerAddress`, with --nft-issuer / SUBSCRIPTION_NFT_ISSUER
391
+ * as an optional override.
392
+ * d. With no nftId, no issuer, and no NFT yet, return false (the mint still
393
+ * succeeded; only the on-wallet delivery is deferred).
394
+ *
395
+ * Retries up to 10× (≈50s at the 5s default) because the brokered sell offer
396
+ * needs a few ledgers to appear on-ledger after the 2-of-3 issuer MPC mint —
397
+ * re-querying BOTH `nft_sell_offers{nft_id}` and the issuer-side
398
+ * `account_objects` fallback on each attempt. Returns true when the NFT is
399
+ * confirmed on the dev wallet.
400
+ */
401
+ async function acceptSubscriptionNft(opts, address, seed, sleep, status) {
402
+ const xrplNft = status?.chainNfts?.xrpl;
403
+ const nftId = xrplNft?.nftId;
404
+ // Explicit override wins; otherwise use the issuer the status now exposes.
405
+ const issuer = opts.nftIssuer ?? xrplNft?.issuerAddress;
406
+ const client = (opts.xrplClientFactory ?? defaultXrplClientFactory)(XRPL_TESTNET_WSS);
407
+ const walletSign = (opts.walletSignFactory ?? defaultWalletSignFactory)(seed);
408
+ const attempts = opts.acceptAttempts ?? 10;
409
+ const retryMs = opts.acceptRetryMs ?? opts.pollIntervalMs ?? 5000;
410
+ await client.connect();
411
+ try {
412
+ for (let i = 0; i < attempts; i++) {
413
+ // (a) Is the SPECIFIC subscription NFT already on the wallet? Only a
414
+ // positive NFTokenID match counts — never "holds some NFT". With no
415
+ // nftId known yet, fall through to resolve + accept the offer.
416
+ if (nftId && (await walletHasNft(client, address, nftId)))
417
+ return true;
418
+ // (b)/(c) Resolve the sell-offer index: prefer the NFTokenID-keyed lookup
419
+ // (exact, issuer-free), fall back to the issuer-side account_objects scan.
420
+ let offerIndex;
421
+ if (nftId) {
422
+ offerIndex = await findSellOfferIndexByNftId(client, nftId, address);
423
+ }
424
+ if (!offerIndex && issuer) {
425
+ offerIndex = await findSellOfferIndex(client, issuer, address);
426
+ }
427
+ if (offerIndex) {
428
+ const tx = {
429
+ TransactionType: 'NFTokenAcceptOffer',
430
+ Account: address,
431
+ NFTokenSellOffer: offerIndex,
432
+ };
433
+ const prepared = await client.autofill(tx);
434
+ const txBlob = walletSign(prepared);
435
+ const result = await client.submitAndWait(txBlob);
436
+ const code = result.result?.meta?.TransactionResult;
437
+ if (code !== 'tesSUCCESS') {
438
+ throw new Error(`NFTokenAcceptOffer failed: ${code ?? 'unknown result'}`);
439
+ }
440
+ // The tesSUCCESS above already proves the accept; confirm the specific
441
+ // NFT landed when its id is known, else trust the successful submit.
442
+ if (!nftId || (await walletHasNft(client, address, nftId)))
443
+ return true;
444
+ }
445
+ if (i < attempts - 1)
446
+ await sleep(retryMs);
447
+ }
448
+ // Final check after the loop: only the specific NFT counts.
449
+ return nftId ? await walletHasNft(client, address, nftId) : false;
450
+ }
451
+ finally {
452
+ await client.disconnect();
453
+ }
454
+ }
455
+ /**
456
+ * True if the account holds the SPECIFIC subscription NFT (`nftId`).
457
+ *
458
+ * Must be NFTokenID-specific: the dev wallet routinely holds other NFTs (prior
459
+ * or retired subscriptions, unrelated collectibles), so a "holds ANY NFT" check
460
+ * would falsely report the current subscription NFT as already accepted and
461
+ * skip the `NFTokenAcceptOffer` entirely — the offer would sit open forever.
462
+ */
463
+ async function walletHasNft(client, account, nftId) {
464
+ const res = await client.request({ command: 'account_nfts', account });
465
+ const nfts = res.result?.account_nfts ?? [];
466
+ return nfts.some((n) => n?.NFTokenID === nftId);
467
+ }
468
+ /**
469
+ * Find the ledger index of the issuer's NFT SELL offer brokered to `recipient`.
470
+ *
471
+ * Enumerates the issuer's `account_objects` of type `nft_offer` and returns the
472
+ * `index` of the offer whose `Destination` matches the recipient (else the first
473
+ * offer). (The mint sets `Destination = dev wallet` + `Flags = tfSellNFToken`.)
474
+ */
475
+ async function findSellOfferIndex(client, issuer, recipient) {
476
+ const res = await client.request({
477
+ command: 'account_objects',
478
+ account: issuer,
479
+ type: 'nft_offer',
480
+ });
481
+ const objects = res.result?.account_objects ?? [];
482
+ const match = objects.find((o) => o?.Destination === recipient) ?? objects[0];
483
+ return match?.index;
484
+ }
485
+ /**
486
+ * Find the ledger index of the SELL offer for a specific `NFTokenID` brokered to
487
+ * `recipient`.
488
+ *
489
+ * Queries `nft_sell_offers` keyed by the NFTokenID — the exact, issuer-free path
490
+ * the validator's `chainNfts.xrpl.nftId` enables. Returns the `nft_offer_index`
491
+ * of the offer whose `destination` matches the dev wallet (else the first
492
+ * offer). Returns undefined when the offer isn't on-ledger yet.
493
+ */
494
+ async function findSellOfferIndexByNftId(client, nftId, recipient) {
495
+ let res;
496
+ try {
497
+ res = await client.request({ command: 'nft_sell_offers', nft_id: nftId });
498
+ }
499
+ catch {
500
+ // `objectNotFound` is returned while no sell offer exists yet — treat as
501
+ // "not on-ledger yet" so the retry loop can fall back / wait.
502
+ return undefined;
503
+ }
504
+ const offers = res.result?.offers ?? [];
505
+ const match = offers.find((o) => o?.destination === recipient) ?? offers[0];
506
+ return match?.nft_offer_index;
507
+ }
508
+ function renderDepositInstructions(d, tier, appId) {
509
+ console.log(chalk_1.default.bold.green(`\n Subscription requested: ${appId}`));
132
510
  console.log(chalk_1.default.bold.yellow('\n Deposit required to mint your subscription NFT:'));
133
511
  console.log(chalk_1.default.gray(` tier: ${tier}`));
134
512
  console.log(chalk_1.default.gray(` chain: ${d.chain}`));
135
- console.log(chalk_1.default.gray(` amount: ${d.amount} (token ${d.tokenId})`));
513
+ console.log(chalk_1.default.gray(` amount: ${d.amount} HST (token ${d.tokenId})`));
136
514
  console.log(chalk_1.default.gray(` to: ${d.walletAddress}\n`));
137
- console.log(chalk_1.default.cyan(` XUMM deeplink:`));
515
+ }
516
+ function renderManualPaymentHint(d) {
517
+ const xummDeeplink = buildXummDeeplink(d);
518
+ console.log(chalk_1.default.cyan(' --no-autopay: pay the deposit manually, then re-run `hsuite subscribe`.'));
519
+ console.log(chalk_1.default.cyan(' XUMM deeplink:'));
138
520
  console.log(chalk_1.default.cyan(` ${xummDeeplink}\n`));
139
- console.log(chalk_1.default.gray(' Waiting for deposit confirmation...\n'));
140
521
  }
141
522
  function buildXummDeeplink(d) {
142
523
  if (d.chain !== 'xrpl') {
143
524
  return `(no deeplink: chain=${d.chain})`;
144
525
  }
145
- // Minimal Xumm deeplink shape; full IssuedCurrency requires JSON payload
146
- // pushed through https://xumm.app/sign — for the CLI we surface the raw
147
- // tx params so the user can paste them into their wallet.
526
+ // Minimal Xumm deeplink shape; full IssuedCurrency requires a JSON payload
527
+ // pushed through https://xumm.app/sign — for the CLI we surface the raw tx
528
+ // params so the user can paste them into their wallet.
148
529
  return `xumm://xrpl/send?to=${encodeURIComponent(d.walletAddress)}&amount=${encodeURIComponent(d.amount)}&token=${encodeURIComponent(d.tokenId)}`;
149
530
  }
150
531
  exports.subscribeCommand = new commander_1.Command('subscribe')
151
- .description('Mint a subscription NFT against the cluster')
532
+ .description('Subscribe a smart-app: create DKG entity → auto-pay deposit → mint → accept subscription NFT (mint-first)')
152
533
  .option('--env <file>', 'Env file with XRPL creds', '.env.local')
153
534
  .option('--gateway <url>', 'Validator/gateway URL (defaults to VALIDATOR_URL in env)')
154
- .option('--tier <tier>', 'Subscription tier (free_testnet|starter|professional|enterprise)', 'free_testnet')
155
- .option('--networks <list>', 'Comma-separated networks (hedera,xrpl,etc.)', 'xrpl')
535
+ .option('--tier <tier>', 'Subscription tier (builder|growth|scale|enterprise)', 'builder')
536
+ .option('--app-id <id>', 'Resume an already-created entity by its appId (defaults to SUBSCRIPTION_APP_ID env). When unset, subscribe CREATES the DKG entity and uses its entityId.')
156
537
  .option('--app-name <name>', 'Smart-app display name')
157
- .option('--app-id <id>', 'Reuse an existing app id (default: generate uuid)')
538
+ .option('--no-autopay', 'Skip auto-paying the HST deposit; print instructions for manual (XUMM) payment')
539
+ .option('--nft-issuer <address>', 'Optional override for the XRPL subscription-NFT issuer. No longer required: the /status response now exposes chainNfts.xrpl (nftId + issuerAddress) and the offer is resolved from it automatically. Defaults to SUBSCRIPTION_NFT_ISSUER env.')
158
540
  .action(async (opts) => {
159
541
  console.log(chalk_1.default.bold.cyan('\n hsuite subscribe\n'));
160
542
  console.log(chalk_1.default.gray(` env: ${opts.env}`));
161
- console.log(chalk_1.default.gray(` tier: ${opts.tier}`));
162
- console.log(chalk_1.default.gray(` nets: ${opts.networks}\n`));
543
+ console.log(chalk_1.default.gray(` tier: ${opts.tier}\n`));
544
+ // Resolve the NFT issuer from env when not passed on the CLI.
545
+ if (!opts.nftIssuer) {
546
+ const env = (0, env_1.readEnv)(opts.env);
547
+ const fromEnv = env.get('SUBSCRIPTION_NFT_ISSUER');
548
+ if (fromEnv)
549
+ opts.nftIssuer = fromEnv;
550
+ }
163
551
  const spinner = (0, ora_1.default)(`requesting ${opts.tier} subscription...`).start();
164
552
  let result;
165
553
  try {
@@ -169,6 +557,17 @@ exports.subscribeCommand = new commander_1.Command('subscribe')
169
557
  spinner.fail(err.message);
170
558
  process.exit(1);
171
559
  }
560
+ if (!result.depositPaid) {
561
+ spinner.info(`subscription requested (appId=${result.appId}) — deposit not paid (--no-autopay)`);
562
+ const updates = {
563
+ SUBSCRIPTION_APP_ID: result.appId,
564
+ APP_ID: result.appId,
565
+ };
566
+ (0, env_1.upsertEnv)(opts.env, updates);
567
+ console.log(chalk_1.default.gray(`\n wrote SUBSCRIPTION_APP_ID to ${opts.env}`));
568
+ console.log(chalk_1.default.yellow('\n Pay the deposit, then re-run `hsuite subscribe` to mint + accept the NFT.\n'));
569
+ return;
570
+ }
172
571
  spinner.succeed(`subscription active (appId=${result.appId})`);
173
572
  if (result.nftSerial !== undefined) {
174
573
  console.log(chalk_1.default.gray(` NFT serial: ${result.nftSerial}`));
@@ -176,6 +575,13 @@ exports.subscribeCommand = new commander_1.Command('subscribe')
176
575
  if (result.subscriptionId) {
177
576
  console.log(chalk_1.default.gray(` subscriptionId: ${result.subscriptionId}`));
178
577
  }
578
+ if (result.nftAccepted) {
579
+ console.log(chalk_1.default.gray(' NFT accepted + confirmed on the dev wallet'));
580
+ }
581
+ else {
582
+ console.log(chalk_1.default.yellow(' NFT not yet on the dev wallet (sell offer may not be on-ledger yet). ' +
583
+ 'Re-run `hsuite subscribe` to retry accepting it.'));
584
+ }
179
585
  const updates = {
180
586
  SUBSCRIPTION_APP_ID: result.appId,
181
587
  APP_ID: result.appId,