-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathrun.ts
More file actions
435 lines (396 loc) · 17.4 KB
/
Copy pathrun.ts
File metadata and controls
435 lines (396 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
/**
* Orchestrator for the full `dot deploy` flow.
*
* The function is deliberately pure-ish: it takes an already-resolved signer,
* emits a typed event stream, and leaves UI concerns (Ink, spinners) to the
* caller. RevX can import this module in a WebContainer and drive its own UI
* off the same events.
*/
import {
runBuild,
loadDetectInput,
detectBuildConfig,
detectContractsType,
type BuildConfig,
type ContractsType,
} from "../build/index.js";
import { runStorageDeploy } from "./storage.js";
import { publishToPlayground, normalizeDomain } from "./playground.js";
import { runContractsPhase, type ContractsPhaseEvent } from "./contracts.js";
import {
getOrCreateSessionAccount,
SESSION_FUND_AMOUNT,
SESSION_MIN_BALANCE,
} from "./session-account.js";
import { resolveSignerSetup, type SignerMode, type DeployApproval } from "./signerMode.js";
import {
wrapSignerWithEvents,
createSigningCounter,
type SigningCounter,
type SigningEvent,
} from "./signingProxy.js";
import type { DeployLogEvent } from "./progress.js";
import { checkBalance, pickFunder, FUNDER_FEE_BUFFER } from "../account/funding.js";
import { FAUCET_URL } from "../account/funder.js";
import { Enum, type PolkadotSigner } from "polkadot-api";
import { submitAndWatch } from "@polkadot-apps/tx";
import { withDeployPhase } from "./phase.js";
import type { ResolvedSigner } from "../signer.js";
import { getConnection } from "../connection.js";
import type { Env } from "../../config.js";
import type { DeployPlan } from "./availability.js";
import type { HexString } from "polkadot-api";
// ── Events ───────────────────────────────────────────────────────────────────
export type DeployPhase = "build" | "contracts" | "storage-and-dotns" | "playground" | "done";
export type DeployEvent =
| { kind: "plan"; approvals: DeployApproval[] }
| { kind: "phase-start"; phase: DeployPhase }
| { kind: "phase-complete"; phase: DeployPhase }
| { kind: "phase-skipped"; phase: DeployPhase; reason: string }
| { kind: "build-log"; line: string }
| { kind: "build-detected"; config: BuildConfig }
| { kind: "contracts-event"; event: ContractsPhaseEvent }
| { kind: "storage-event"; event: DeployLogEvent }
| { kind: "signing"; event: SigningEvent }
| { kind: "error"; phase: DeployPhase; message: string };
// ── Inputs & outputs ─────────────────────────────────────────────────────────
export interface RunDeployOptions {
/** Project root — where the build runs. */
projectDir: string;
/** Relative path inside `projectDir` that holds the built artifacts. */
buildDir: string;
/** Skip the build step (e.g. if the caller already built). */
skipBuild?: boolean;
/** DotNS label (with or without `.dot`). */
domain: string;
/** Signer mode — `dev` uses bulletin-deploy defaults, `phone` uses the user's session. */
mode: SignerMode;
/** Whether to publish to the playground registry after DotNS succeeds. */
publishToPlayground: boolean;
/** Publish to the playground with private visibility (owner-only). Ignored when `publishToPlayground` is false. */
playgroundPrivate?: boolean;
/** Whether the deploy should publish source as modable. */
modable?: boolean;
/** Resolved public repository URL to record in metadata (modable=true) or `null` (modable=false). */
repositoryUrl?: string | null;
/** Compile + deploy foundry/hardhat/cdm contracts alongside the frontend. */
deployContracts?: boolean;
/**
* Skip the contract compile step (forge/hardhat/cargo-contract) and use
* pre-existing artifacts on disk. CI-friendly for environments without the
* contract toolchain installed. Throws if no artifacts are found.
*/
skipContractBuild?: boolean;
/** The logged-in phone signer. Required for `mode === "phone"` or `publishToPlayground`. */
userSigner: ResolvedSigner | null;
/** Event sink — consumed by the TUI / RevX. */
onEvent: (event: DeployEvent) => void;
/** Target environment. Defaults to `testnet`. */
env?: Env;
/**
* DotNS plan from the availability check — shapes the approvals list.
* Optional; the signing counter falls back to "register, no PoP upgrade"
* (3 DotNS taps) if absent and auto-corrects at runtime.
*/
plan?: DeployPlan;
/** Whether the contracts phase needs a phone tap to top up its session key. */
contractsFundingNeeded?: boolean;
}
export interface DeployOutcome {
/** Canonical `<label>.dot` string. */
fullDomain: string;
/** Bulletin storage CID of the app bundle. */
appCid: string;
/** IPFS CID of the directory root, if bulletin-deploy computed one. */
ipfsCid?: string;
/** Metadata CID when `publishToPlayground` was true. */
metadataCid?: string;
/** Approvals the user actually went through, useful for final summary. */
approvalsRequested: DeployApproval[];
/** URL the user can visit to view their deployed app. */
appUrl: string;
/** Contract addresses deployed this run (empty when contracts phase was skipped). */
contracts: Array<{ name: string; address: HexString }>;
}
// ── Orchestrator ─────────────────────────────────────────────────────────────
export async function runDeploy(options: RunDeployOptions): Promise<DeployOutcome> {
const { label, fullDomain } = normalizeDomain(options.domain);
const setup = resolveSignerSetup({
mode: options.mode,
userSigner: options.userSigner,
publishToPlayground: options.publishToPlayground,
plan: options.plan,
contractsFundingNeeded: options.contractsFundingNeeded,
});
options.onEvent({ kind: "plan", approvals: setup.approvals });
const counter = createSigningCounter(setup.approvals.length);
// Contracts and frontend build+upload run concurrently; both must finish
// before playground publish.
const buildAbs = options.buildDir;
const contractsPromise = maybeRunContracts(options, counter);
const frontendPromise = (async () => {
if (!options.skipBuild) {
await withDeployPhase("build", "cli.deploy.build", {}, options.onEvent, async () => {
const config = detectBuildConfig(loadDetectInput(options.projectDir));
options.onEvent({ kind: "build-detected", config });
await runBuild({
cwd: options.projectDir,
config,
onData: (line) => options.onEvent({ kind: "build-log", line }),
});
});
}
const storageAuth = maybeWrapAuthForSigning(
setup.bulletinDeployAuthOptions,
options,
counter,
setup.approvals,
);
return await withDeployPhase(
"storage-and-dotns",
"cli.deploy.storage-dotns",
{ "cli.deploy.domain": label },
options.onEvent,
() =>
runStorageDeploy({
content: buildAbs,
domainName: label,
auth: storageAuth,
onLogEvent: (event) => options.onEvent({ kind: "storage-event", event }),
env: options.env,
}),
);
})();
const [contractsDeployed, storageResult] = await Promise.all([
contractsPromise,
frontendPromise,
]);
// ── Playground publish ───────────────────────────────────────────────
let metadataCid: string | undefined;
if (setup.publishSigner) {
const wrappedPublishSigner = wrapResolvedSigner(
setup.publishSigner,
"Publish to Playground registry",
counter,
(event) => options.onEvent({ kind: "signing", event }),
);
const pub = await withDeployPhase(
"playground",
"cli.deploy.playground",
{ "cli.deploy.domain": fullDomain },
options.onEvent,
() =>
publishToPlayground({
domain: fullDomain,
publishSigner: wrappedPublishSigner,
repositoryUrl: options.repositoryUrl ?? null,
cwd: options.projectDir,
onLogEvent: (event) => options.onEvent({ kind: "storage-event", event }),
env: options.env,
isPrivate: options.playgroundPrivate,
}),
);
metadataCid = pub.metadataCid;
}
const appUrl = buildAppUrl(fullDomain, options.env);
const outcome: DeployOutcome = {
fullDomain,
appCid: storageResult.cid,
ipfsCid: storageResult.ipfsCid,
metadataCid,
approvalsRequested: setup.approvals,
appUrl,
contracts: contractsDeployed,
};
options.onEvent({ kind: "phase-complete", phase: "done" });
return outcome;
}
// ── Contracts orchestration ──────────────────────────────────────────────────
/**
* Compile + deploy contracts using the on-disk session key. Fires
* `phase-skipped` when disabled or no contract project is detected.
*/
async function maybeRunContracts(
options: RunDeployOptions,
counter: SigningCounter,
): Promise<DeployOutcome["contracts"]> {
if (!options.deployContracts) {
options.onEvent({
kind: "phase-skipped",
phase: "contracts",
reason: "contracts deploy not requested",
});
return [];
}
const contractsType: ContractsType | null = detectContractsType(
loadDetectInput(options.projectDir),
);
if (contractsType === null) {
options.onEvent({
kind: "phase-skipped",
phase: "contracts",
reason: "no foundry/hardhat/cdm project detected at the root",
});
return [];
}
return await withDeployPhase(
"contracts",
"cli.deploy.contracts",
{ "cli.deploy.contracts_type": contractsType },
options.onEvent,
async () => {
const { info: session, created } = await getOrCreateSessionAccount();
const client = await getConnection();
await ensureSessionFunded({
client,
sessionAddress: session.account.ss58Address,
userSigner: options.userSigner,
counter,
onEvent: options.onEvent,
});
if (created) {
await submitAndWatch(
client.assetHub.tx.Revive.map_account(),
session.account.signer,
);
}
const result = await runContractsPhase({
projectDir: options.projectDir,
contractsType,
skipBuild: options.skipContractBuild,
// cdm's PipelineChainClient is a structural subset of our
// ChainClient — cast keeps the extra `individuality` field out
// of the SDK-surface type without affecting runtime behaviour.
client: client as unknown as Parameters<typeof runContractsPhase>[0]["client"],
signer: session.account.signer,
origin: session.account.ss58Address,
onEvent: (event) => options.onEvent({ kind: "contracts-event", event }),
});
return result.deployed;
},
);
}
/** Top up the contracts session key if it's below `SESSION_MIN_BALANCE`. */
async function ensureSessionFunded(opts: {
client: Awaited<ReturnType<typeof getConnection>>;
sessionAddress: string;
userSigner: ResolvedSigner | null;
counter: SigningCounter;
onEvent: RunDeployOptions["onEvent"];
}): Promise<void> {
const emitInfo = (message: string) =>
opts.onEvent({ kind: "contracts-event", event: { kind: "info", message } });
const balance = await checkBalance(opts.client, opts.sessionAddress, SESSION_MIN_BALANCE);
if (balance.sufficient) {
emitInfo(`session key funded (${opts.sessionAddress})`);
return;
}
emitInfo(`funding session key ${opts.sessionAddress}…`);
// Three-way branch based on who's funding the session key top-up:
//
// source === "session" Phone signer: user pays on-device. Wrap with
// lifecycle events so the TUI can number the tap
// and show "📱 Approve on your phone".
//
// source === "dev" Dev-with-SURI: a local keypair (--suri //Alice
// or a BIP-39 mnemonic) pretending to be the user.
// Signs immediately in-process — no human in the
// loop — so wrapping with phone-tap events would
// be misleading. Sign directly.
//
// null Pure dev mode (no --suri, no session): pick the
// first funder in the chain that has enough PAS.
// If every dev funder is drained, tell the user to
// switch to a mobile signer rather than silently
// falling back to anything that might race the drainer.
let funder: PolkadotSigner;
if (opts.userSigner?.source === "session") {
funder = wrapSignerWithEvents(opts.userSigner.signer, {
label: "Fund contract deploy session key",
counter: opts.counter,
onEvent: (event) => opts.onEvent({ kind: "signing", event }),
});
} else if (opts.userSigner) {
// Dev-with-SURI: sign directly, no lifecycle events.
funder = opts.userSigner.signer;
} else {
const picked = await pickFunder(opts.client, SESSION_FUND_AMOUNT + FUNDER_FEE_BUFFER);
if (!picked) {
throw new Error(
`Dev account balance low. Please deploy with mobile signer. To top up funds in your mobile signer, go to the faucet at: ${FAUCET_URL}`,
);
}
funder = picked.signer;
}
await submitAndWatch(
opts.client.assetHub.tx.Balances.transfer_keep_alive({
dest: Enum("Id", opts.sessionAddress),
value: SESSION_FUND_AMOUNT,
}),
funder,
);
emitInfo("session key funded");
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* When bulletin-deploy is about to use the user's phone signer for DotNS, wrap
* it so each `signTx` call surfaces a lifecycle event with the right label.
*
* Labels are pulled from the DotNS-phase entries of `setup.approvals`, in
* order. `resolveSignerSetup` built that list to match bulletin-deploy's
* actual on-chain call sequence (including the optional `setUserPopStatus`
* at the start when a PoP upgrade is needed), so `seen === N` → phone shows
* the Nth entry. If bulletin-deploy ever fires *more* sigs than approvals
* anticipated, we fall back to the last known label — better than emitting
* a bogus index — and `createSigningCounter` simultaneously extends `total`
* so the TUI shows "step N of N" instead of "N of N-1".
*/
function maybeWrapAuthForSigning(
auth: ReturnType<typeof resolveSignerSetup>["bulletinDeployAuthOptions"],
options: RunDeployOptions,
counter: SigningCounter,
approvals: DeployApproval[],
) {
if (!auth.signer || !auth.signerAddress) return auth;
const labels = approvals.filter((a) => a.phase === "dotns").map((a) => a.label);
const fallbackLabel = labels[labels.length - 1] ?? "DotNS step";
let seen = 0;
const wrapped = {
publicKey: auth.signer.publicKey,
signTx: (...args: Parameters<typeof auth.signer.signTx>) => {
const label = labels[seen] ?? fallbackLabel;
seen += 1;
const proxy = wrapSignerWithEvents(auth.signer!, {
label,
counter,
onEvent: (event) => options.onEvent({ kind: "signing", event }),
});
return proxy.signTx(...args);
},
signBytes: (...args: Parameters<typeof auth.signer.signBytes>) => {
const proxy = wrapSignerWithEvents(auth.signer!, {
label: "DotNS signBytes",
counter,
onEvent: (event) => options.onEvent({ kind: "signing", event }),
});
return proxy.signBytes(...args);
},
};
return { ...auth, signer: wrapped };
}
function wrapResolvedSigner(
resolved: ResolvedSigner,
label: string,
counter: SigningCounter,
onEvent: (event: SigningEvent) => void,
): ResolvedSigner {
return {
...resolved,
signer: wrapSignerWithEvents(resolved.signer, { label, counter, onEvent }),
};
}
function buildAppUrl(fullDomain: string, _env: Env | undefined): string {
// Today's dot.li viewer handles both testnet and mainnet; revisit once a
// dedicated mainnet viewer domain is announced.
return `https://${fullDomain}.li`;
}