bulletin-deploy 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pool.js ADDED
@@ -0,0 +1,165 @@
1
+ import { sr25519CreateDerive } from "@polkadot-labs/hdkd";
2
+ import { DEV_PHRASE, entropyToMiniSecret, mnemonicToEntropy } from "@polkadot-labs/hdkd-helpers";
3
+ import { createClient, Enum } from "polkadot-api";
4
+ import { getPolkadotSigner } from "polkadot-api/signer";
5
+ import { getWsProvider } from "polkadot-api/ws-provider";
6
+ import { withPolkadotSdkCompat } from "polkadot-api/polkadot-sdk-compat";
7
+ import { Keyring } from "@polkadot/keyring";
8
+ import { cryptoWaitReady } from "@polkadot/util-crypto";
9
+
10
+ const DEPLOY_PATH_PREFIX = "//deploy";
11
+ const TOPUP_TRANSACTIONS = 1000;
12
+ const TOPUP_BYTES = 100_000_000n; // 100MB
13
+ const TOPUP_THRESHOLD_TXS = 50n;
14
+ const TOPUP_THRESHOLD_BYTES = 50_000_000n; // 50MB
15
+
16
+ export function derivePoolAccounts(poolSize = 10, mnemonic = DEV_PHRASE) {
17
+ const entropy = mnemonicToEntropy(mnemonic);
18
+ const miniSecret = entropyToMiniSecret(entropy);
19
+ const derive = sr25519CreateDerive(miniSecret);
20
+ const keyring = new Keyring({ type: "sr25519" });
21
+
22
+ const accounts = [];
23
+ for (let i = 0; i < poolSize; i++) {
24
+ const path = `${DEPLOY_PATH_PREFIX}/${i}`;
25
+ const keyPair = derive(path);
26
+ const signer = getPolkadotSigner(keyPair.publicKey, "Sr25519", keyPair.sign);
27
+ const address = keyring.encodeAddress(keyPair.publicKey);
28
+ accounts.push({ index: i, path, publicKey: keyPair.publicKey, signer, address });
29
+ }
30
+ return accounts;
31
+ }
32
+
33
+ const MIN_TRANSACTIONS = 30n;
34
+ const MIN_BYTES = 20_000_000n; // 20MB
35
+
36
+ export function selectAccount(authorizations) {
37
+ const eligible = authorizations.filter(
38
+ a => a.transactions > MIN_TRANSACTIONS && a.bytes > MIN_BYTES
39
+ );
40
+ if (eligible.length === 0) return null;
41
+ return eligible.reduce((best, a) => a.transactions > best.transactions ? a : best);
42
+ }
43
+
44
+ export async function fetchPoolAuthorizations(api, accounts) {
45
+ const results = await Promise.all(
46
+ accounts.map(async (account) => {
47
+ try {
48
+ const auth = await api.query.TransactionStorage.Authorizations.getValue(
49
+ Enum("Account", account.address)
50
+ );
51
+ return {
52
+ ...account,
53
+ transactions: auth ? BigInt(auth.extent.transactions) : 0n,
54
+ bytes: auth ? auth.extent.bytes : 0n,
55
+ };
56
+ } catch {
57
+ return { ...account, transactions: 0n, bytes: 0n };
58
+ }
59
+ })
60
+ );
61
+ return results;
62
+ }
63
+
64
+ export async function ensureAuthorized(api, poolAccount, bulletinRpc) {
65
+ const auth = await api.query.TransactionStorage.Authorizations.getValue(
66
+ Enum("Account", poolAccount.address)
67
+ );
68
+ const txsRemaining = auth ? BigInt(auth.extent.transactions) : 0n;
69
+ const bytesRemaining = auth ? auth.extent.bytes : 0n;
70
+
71
+ if (txsRemaining >= TOPUP_THRESHOLD_TXS && bytesRemaining >= TOPUP_THRESHOLD_BYTES) {
72
+ return;
73
+ }
74
+
75
+ console.log(` Auto-authorizing pool account ${poolAccount.index} (${poolAccount.address.slice(0, 8)}...)...`);
76
+
77
+ // Separate client to avoid congesting the caller's WebSocket with Alice's tx
78
+ const aliceClient = createClient(withPolkadotSdkCompat(getWsProvider(bulletinRpc)));
79
+ const aliceApi = aliceClient.getUnsafeApi();
80
+
81
+ try {
82
+ const keyring = new Keyring({ type: "sr25519" });
83
+ const alice = keyring.addFromUri("//Alice");
84
+
85
+ const aliceAccount = await aliceApi.query.System.Account.getValue(alice.address);
86
+ const aliceBalance = BigInt(aliceAccount.data.free);
87
+ if (aliceBalance < 10_000_000_000n) {
88
+ throw new Error(`Alice has insufficient balance on Bulletin for authorization tx fees. Current: ${(Number(aliceBalance) / 1e12).toFixed(4)} PAS`);
89
+ }
90
+
91
+ const aliceSigner = getPolkadotSigner(alice.publicKey, "Sr25519", (data) => alice.sign(data));
92
+
93
+ const tx = aliceApi.tx.TransactionStorage.authorize_account({
94
+ who: poolAccount.address,
95
+ transactions: TOPUP_TRANSACTIONS,
96
+ bytes: TOPUP_BYTES,
97
+ });
98
+
99
+ const result = await tx.signAndSubmit(aliceSigner);
100
+ if (!result.ok) throw new Error("authorize_account dispatch error");
101
+
102
+ console.log(` Authorized: ${TOPUP_TRANSACTIONS} txs, ${TOPUP_BYTES / 1_000_000n}MB`);
103
+ } finally {
104
+ aliceClient.destroy();
105
+ }
106
+ }
107
+
108
+ export async function bootstrapPool(bulletinRpc, poolSize = 10, mnemonic) {
109
+ console.log(`Bootstrapping ${poolSize} pool accounts on ${bulletinRpc}...\n`);
110
+
111
+ await cryptoWaitReady();
112
+ const accounts = derivePoolAccounts(poolSize, mnemonic);
113
+
114
+ const client = createClient(withPolkadotSdkCompat(getWsProvider(bulletinRpc)));
115
+ const api = client.getUnsafeApi();
116
+
117
+ const keyring = new Keyring({ type: "sr25519" });
118
+ const alice = keyring.addFromUri("//Alice");
119
+ const aliceSigner = getPolkadotSigner(alice.publicKey, "Sr25519", (data) => alice.sign(data));
120
+
121
+ const aliceAccount = await api.query.System.Account.getValue(alice.address);
122
+ const aliceBalance = BigInt(aliceAccount.data.free);
123
+ console.log(`Alice balance: ${(Number(aliceBalance) / 1e12).toFixed(4)} PAS\n`);
124
+
125
+ for (const account of accounts) {
126
+ console.log(`Account ${account.index}: ${account.address}`);
127
+
128
+ try {
129
+ const tx = api.tx.TransactionStorage.authorize_account({
130
+ who: account.address,
131
+ transactions: TOPUP_TRANSACTIONS,
132
+ bytes: TOPUP_BYTES,
133
+ });
134
+ const result = await tx.signAndSubmit(aliceSigner);
135
+ if (!result.ok) throw new Error("dispatch failed");
136
+ console.log(` Authorized: ${TOPUP_TRANSACTIONS} txs, ${Number(TOPUP_BYTES) / 1_000_000}MB`);
137
+ } catch (e) {
138
+ console.log(` Authorization failed: ${e.message?.slice(0, 80)}`);
139
+ }
140
+
141
+ try {
142
+ const transfer = api.tx.Balances.transfer_allow_death({
143
+ dest: Enum("Id", account.address),
144
+ value: 100_000_000_000n, // 0.1 PAS
145
+ });
146
+ const result = await transfer.signAndSubmit(aliceSigner);
147
+ if (!result.ok) throw new Error("dispatch failed");
148
+ console.log(` Transferred 0.1 PAS`);
149
+ } catch (e) {
150
+ console.log(` Transfer failed: ${e.message?.slice(0, 80)}`);
151
+ }
152
+
153
+ console.log("");
154
+ }
155
+
156
+ console.log("=".repeat(60));
157
+ console.log("Pool Summary");
158
+ console.log("=".repeat(60));
159
+ const auths = await fetchPoolAuthorizations(api, accounts);
160
+ for (const a of auths) {
161
+ console.log(` ${a.index}: ${a.address} | txs: ${a.transactions} | bytes: ${Number(a.bytes) / 1_000_000}MB`);
162
+ }
163
+
164
+ client.destroy();
165
+ }
@@ -0,0 +1,73 @@
1
+ // Sentry telemetry. Enabled by default (DSN is write-only, safe to embed).
2
+ // Set BULLETIN_DEPLOY_TELEMETRY=0 to disable.
3
+
4
+ import { execSync } from "node:child_process";
5
+
6
+ const DEFAULT_DSN = "https://e021c025d79c4c3ade2862a11f13c40b@o4509440811401216.ingest.de.sentry.io/4511093597405264";
7
+ const DISABLED = process.env.BULLETIN_DEPLOY_TELEMETRY === "0";
8
+
9
+ let Sentry = null;
10
+
11
+ if (!DISABLED) {
12
+ try {
13
+ Sentry = await import("@sentry/node");
14
+ } catch {
15
+ // @sentry/node not installed — telemetry disabled
16
+ }
17
+ }
18
+
19
+ export function initTelemetry() {
20
+ if (!Sentry) return;
21
+ Sentry.init({
22
+ dsn: process.env.SENTRY_DSN || DEFAULT_DSN,
23
+ tracesSampleRate: 1.0,
24
+ environment: process.env.CI ? "ci" : "local",
25
+ });
26
+ }
27
+
28
+ function tryGitRemote() {
29
+ try {
30
+ return execSync("git remote get-url origin", { encoding: "utf-8" }).trim().replace(/.*github\.com[:/]/, "").replace(/\.git$/, "");
31
+ } catch { return "unknown"; }
32
+ }
33
+
34
+ function tryGitBranch() {
35
+ try {
36
+ return execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8" }).trim();
37
+ } catch { return "unknown"; }
38
+ }
39
+
40
+ function getDeployAttributes() {
41
+ return {
42
+ "deploy.repo": process.env.GITHUB_REPOSITORY || tryGitRemote(),
43
+ "deploy.branch": process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || tryGitBranch(),
44
+ "deploy.source": process.env.CI ? "ci" : "local",
45
+ "deploy.pr": process.env.GITHUB_PR_NUMBER || undefined,
46
+ };
47
+ }
48
+
49
+ export async function withSpan(op, description, attributes, fn) {
50
+ if (!Sentry) return fn();
51
+ return Sentry.startSpan({ op, name: description, attributes }, fn);
52
+ }
53
+
54
+ export async function withDeploySpan(domain, fn) {
55
+ if (!Sentry) return fn();
56
+ const attrs = { ...getDeployAttributes(), "deploy.domain": domain };
57
+ try {
58
+ return await Sentry.startSpan({ op: "deploy", name: `deploy ${domain}`, attributes: attrs }, fn);
59
+ } finally {
60
+ await Sentry.flush(5000);
61
+ }
62
+ }
63
+
64
+ export function setDeployAttribute(key, value) {
65
+ if (!Sentry) return;
66
+ const span = Sentry.getActiveSpan();
67
+ if (span) span.setAttribute(key, value);
68
+ }
69
+
70
+ export async function flush() {
71
+ if (!Sentry) return;
72
+ await Sentry.flush(5000);
73
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert";
3
+ import { derivePoolAccounts, selectAccount } from "../src/pool.js";
4
+
5
+ describe("derivePoolAccounts", () => {
6
+ it("derives the expected number of unique accounts", () => {
7
+ const accounts = derivePoolAccounts(5);
8
+ assert.strictEqual(accounts.length, 5);
9
+ const addresses = new Set(accounts.map(a => a.address));
10
+ assert.strictEqual(addresses.size, 5, "all addresses should be unique");
11
+ });
12
+
13
+ it("produces deterministic results", () => {
14
+ const a = derivePoolAccounts(3);
15
+ const b = derivePoolAccounts(3);
16
+ assert.deepStrictEqual(
17
+ a.map(x => x.address),
18
+ b.map(x => x.address)
19
+ );
20
+ });
21
+
22
+ it("does not include Alice address", () => {
23
+ const accounts = derivePoolAccounts(10);
24
+ const alice = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
25
+ for (const a of accounts) {
26
+ assert.notStrictEqual(a.address, alice);
27
+ }
28
+ });
29
+ });
30
+
31
+ describe("selectAccount", () => {
32
+ it("selects the account with the most remaining transactions", () => {
33
+ const mockAuthorizations = [
34
+ { index: 0, address: "addr0", transactions: 10n, bytes: 50_000_000n },
35
+ { index: 1, address: "addr1", transactions: 500n, bytes: 90_000_000n },
36
+ { index: 2, address: "addr2", transactions: 200n, bytes: 30_000_000n },
37
+ ];
38
+ const selected = selectAccount(mockAuthorizations);
39
+ assert.strictEqual(selected.index, 1);
40
+ });
41
+
42
+ it("filters out accounts below transaction threshold", () => {
43
+ const mockAuthorizations = [
44
+ { index: 0, address: "addr0", transactions: 5n, bytes: 50_000_000n },
45
+ { index: 1, address: "addr1", transactions: 100n, bytes: 90_000_000n },
46
+ ];
47
+ const selected = selectAccount(mockAuthorizations);
48
+ assert.strictEqual(selected.index, 1);
49
+ });
50
+
51
+ it("filters out accounts below bytes threshold", () => {
52
+ const mockAuthorizations = [
53
+ { index: 0, address: "addr0", transactions: 500n, bytes: 10_000_000n },
54
+ { index: 1, address: "addr1", transactions: 100n, bytes: 90_000_000n },
55
+ ];
56
+ const selected = selectAccount(mockAuthorizations);
57
+ assert.strictEqual(selected.index, 1);
58
+ });
59
+
60
+ it("returns null when all accounts are exhausted", () => {
61
+ const mockAuthorizations = [
62
+ { index: 0, address: "addr0", transactions: 5n, bytes: 1_000_000n },
63
+ ];
64
+ const selected = selectAccount(mockAuthorizations);
65
+ assert.strictEqual(selected, null);
66
+ });
67
+ });
package/test/test.js ADDED
@@ -0,0 +1,114 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createCID, encodeContenthash } from "../src/deploy.js";
4
+ import { validateDomainLabel, fetchNonce, TX_TIMEOUT_MS } from "../src/dotns.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // 1. createCID
8
+ // ---------------------------------------------------------------------------
9
+ describe("createCID", () => {
10
+ test("produces a valid CIDv1 for known input", () => {
11
+ const data = new TextEncoder().encode("hello world");
12
+ const cid = createCID(data);
13
+ // toString() should return a base32-encoded CIDv1 string (starts with 'b')
14
+ const cidStr = cid.toString();
15
+ assert.ok(cidStr.length > 0, "CID string should not be empty");
16
+ assert.ok(typeof cidStr === "string");
17
+ });
18
+
19
+ test("version is 1", () => {
20
+ const data = new TextEncoder().encode("hello world");
21
+ const cid = createCID(data);
22
+ assert.strictEqual(cid.version, 1);
23
+ });
24
+ });
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // 2. encodeContenthash
28
+ // ---------------------------------------------------------------------------
29
+ describe("encodeContenthash", () => {
30
+ test("output starts with 'e301' (IPFS prefix)", () => {
31
+ const data = new TextEncoder().encode("hello world");
32
+ const cid = createCID(data);
33
+ const hex = encodeContenthash(cid.toString());
34
+ assert.ok(hex.startsWith("e301"), `Expected hex to start with 'e301', got: ${hex.slice(0, 8)}`);
35
+ });
36
+
37
+ test("roundtrip: encode a CID then decode the first bytes", () => {
38
+ const data = new TextEncoder().encode("roundtrip test");
39
+ const cid = createCID(data);
40
+ const hex = encodeContenthash(cid.toString());
41
+ // First byte 0xe3 = IPFS namespace, second byte 0x01 = CIDv1 marker
42
+ const bytes = Buffer.from(hex, "hex");
43
+ assert.strictEqual(bytes[0], 0xe3, "First byte should be 0xe3 (IPFS namespace)");
44
+ assert.strictEqual(bytes[1], 0x01, "Second byte should be 0x01 (CIDv1 marker)");
45
+ // The remaining bytes should be the CID bytes
46
+ const cidBytesFromHex = bytes.slice(2);
47
+ const cidBytes = Buffer.from(cid.bytes);
48
+ assert.deepStrictEqual(cidBytesFromHex, cidBytes, "CID bytes after prefix should match original CID bytes");
49
+ });
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // 3. validateDomainLabel
54
+ // ---------------------------------------------------------------------------
55
+ describe("validateDomainLabel", () => {
56
+ test("accepts valid label: 'my-domain'", () => {
57
+ assert.doesNotThrow(() => validateDomainLabel("my-domain"));
58
+ });
59
+
60
+ test("accepts valid label: 'test12'", () => {
61
+ assert.doesNotThrow(() => validateDomainLabel("test12"));
62
+ });
63
+
64
+ test("accepts valid label: 'abc'", () => {
65
+ assert.doesNotThrow(() => validateDomainLabel("abc"));
66
+ });
67
+
68
+ test("rejects too-short labels (less than 3 chars)", () => {
69
+ assert.throws(() => validateDomainLabel("ab"), /min 3 chars/);
70
+ assert.throws(() => validateDomainLabel("a"), /min 3 chars/);
71
+ assert.throws(() => validateDomainLabel(""), /min 3 chars/);
72
+ });
73
+
74
+ test("rejects labels starting with hyphen", () => {
75
+ assert.throws(() => validateDomainLabel("-abc"), /cannot start or end with hyphen/);
76
+ });
77
+
78
+ test("rejects labels ending with hyphen", () => {
79
+ assert.throws(() => validateDomainLabel("abc-"), /cannot start or end with hyphen/);
80
+ });
81
+
82
+ test("rejects labels with more than 2 trailing digits", () => {
83
+ assert.throws(() => validateDomainLabel("abc123"), /max 2 trailing digits/);
84
+ assert.throws(() => validateDomainLabel("test1234"), /max 2 trailing digits/);
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // 4. fetchNonce timeout
90
+ // ---------------------------------------------------------------------------
91
+ describe("fetchNonce", () => {
92
+ test(
93
+ "rejects after timeout for an unreachable endpoint",
94
+ { timeout: 15_000 },
95
+ async () => {
96
+ await assert.rejects(
97
+ () => fetchNonce("ws://192.0.2.1:9944", "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"),
98
+ (err) => {
99
+ assert.ok(err instanceof Error, "Should reject with an Error");
100
+ return true;
101
+ }
102
+ );
103
+ }
104
+ );
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // 5. TX_TIMEOUT_MS
109
+ // ---------------------------------------------------------------------------
110
+ describe("TX_TIMEOUT_MS", () => {
111
+ test("is exported and equals 90000", () => {
112
+ assert.strictEqual(TX_TIMEOUT_MS, 90_000);
113
+ });
114
+ });
@@ -0,0 +1,82 @@
1
+ # Copy this file to your repo's .github/workflows/ directory.
2
+ # Customize the Build step and build output path for your framework.
3
+
4
+ name: Deploy to Polkadot Triangle
5
+
6
+ on:
7
+ push:
8
+ branches: [main, master]
9
+ pull_request:
10
+ branches: [main, master]
11
+ workflow_dispatch:
12
+ inputs:
13
+ skip-cache:
14
+ description: 'Force redeploy (skip cache)'
15
+ type: boolean
16
+ default: false
17
+
18
+ permissions:
19
+ contents: read
20
+ pull-requests: write
21
+
22
+ concurrency:
23
+ group: deploy-${{ github.event.pull_request.number || github.ref }}
24
+ cancel-in-progress: true
25
+
26
+ jobs:
27
+ deploy:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+
32
+ - uses: actions/setup-node@v4
33
+ with:
34
+ node-version: '22'
35
+
36
+ # ----------------------------------------------------------------
37
+ # Customize this step for your framework:
38
+ # Next.js: npx next build
39
+ # Vite: npx vite build
40
+ # Plain: skip this step (deploy from repo root or a subdirectory)
41
+ # ----------------------------------------------------------------
42
+ - name: Build
43
+ run: npm run build
44
+
45
+ - name: Setup IPFS
46
+ uses: ipfs/download-ipfs-distribution-action@v1
47
+ with:
48
+ name: kubo
49
+ version: v0.33.0
50
+ - run: ipfs init
51
+
52
+ - name: Install deploy tool
53
+ run: npm install -g github:paritytech/bulletin-deploy
54
+
55
+ - name: Deploy
56
+ uses: nick-fields/retry@v3
57
+ with:
58
+ timeout_minutes: 15
59
+ max_attempts: 3
60
+ retry_wait_seconds: 30
61
+ command: |
62
+ # Domain: <repo-name>-<branch>00.dot
63
+ DOMAIN="${{ github.event.repository.name }}-${{ github.head_ref || github.ref_name }}00.dot"
64
+ bulletin-deploy './dist' "$DOMAIN"
65
+ env:
66
+ MNEMONIC: ${{ secrets.DOTNS_MNEMONIC }}
67
+ SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
68
+ NODE_OPTIONS: '--max-old-space-size=8192'
69
+
70
+ - name: Comment on PR
71
+ if: github.event.pull_request
72
+ uses: actions/github-script@v7
73
+ with:
74
+ script: |
75
+ const domain = '${{ github.event.repository.name }}-${{ github.head_ref }}00';
76
+ const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
77
+ await github.rest.issues.createComment({
78
+ owner: context.repo.owner,
79
+ repo: context.repo.repo,
80
+ issue_number: context.issue.number,
81
+ body: `**${domain}.dot** · [logs](${runUrl})\n\nhttps://${domain}.dot.li`
82
+ });