Skip to content

Commit fc1ddd3

Browse files
committed
chore(funder): source dedicated funder seed from env secret
The dedicated testnet funder seed was hardcoded in funder.ts, shipping in every published binary. Read it from the MASTER_FUNDER_SEED environment variable instead (CI injects it from the repository secret of the same name); when unset, the funder chain degrades to Alice-only. The chain is exercised only by the E2E suite and operator tooling, so no end-user command is affected. Also reorder the chain so the dedicated funder is primary and public Alice is the fallback — draw down our own controlled account first rather than the shared dev account others drain unpredictably. - funder.ts: derive dedicated funder from MASTER_FUNDER_SEED?.trim() at //0; drop the literal mnemonic; dedicated entry leads FUNDER_CHAIN - check-funder-balance.ts: exit 2 with a clear message when the seed is unset - CI: inject MASTER_FUNDER_SEED from the secret in the e2e + balance-check workflows - docs/e2e-bootstrap.md: MASTER_FUNDER_SEED is now actually consumed (was documented but unwired) - add funder.test.ts; update funding.test.ts for the new order
1 parent 90ad096 commit fc1ddd3

11 files changed

Lines changed: 201 additions & 55 deletions

File tree

.changeset/funder-seed-env.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"playground-cli": patch
3+
---
4+
5+
The dedicated testnet funder seed is no longer hardcoded in the binary. It is
6+
read from the `MASTER_FUNDER_SEED` environment variable (CI injects it from a
7+
repository secret); when unset, funding falls back to Alice alone. This affects
8+
only the E2E suite and operator tooling — no end-user command funds from this
9+
chain.

.github/workflows/e2e-post-release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ jobs:
9090
env:
9191
DOT_TAG: e2e-ci-post-release
9292
DOT_TELEMETRY: "1"
93+
# Dedicated-funder seed for globalSetup's SIGNER top-up
94+
# fallback. Absent → Alice only.
95+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
9396

9497
- name: Upload forensic artefacts
9598
if: always()

.github/workflows/e2e-release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ jobs:
7979
DOT_E2E_BINARY: ${{ github.workspace }}/bin/dot-linux-x64
8080
DOT_TAG: e2e-ci-release
8181
DOT_TELEMETRY: "1"
82+
# Dedicated-funder seed for globalSetup's SIGNER top-up
83+
# fallback. Absent → Alice only.
84+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
8285

8386
- name: Upload forensic artefacts
8487
if: always()

.github/workflows/e2e.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ jobs:
7070
DOT_DEPLOY_VERBOSE: "1"
7171
DOT_TAG: ${{ steps.setup.outputs.tag }}
7272
DOT_TELEMETRY: "1"
73+
# Seed for the dedicated funder, the fallback globalSetup uses
74+
# to top up SIGNER once Alice drains. Absent → Alice only.
75+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
7376

7477
- name: Surface failure detail
7578
if: failure()
@@ -165,6 +168,9 @@ jobs:
165168
DOT_DEPLOY_VERBOSE: "1"
166169
DOT_TAG: ${{ steps.setup.outputs.tag }}
167170
DOT_TELEMETRY: "1"
171+
# Seed for the dedicated funder, the fallback globalSetup uses
172+
# to top up SIGNER once Alice drains. Absent → Alice only.
173+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
168174

169175
- name: Surface failure detail
170176
if: failure()
@@ -225,6 +231,9 @@ jobs:
225231
DOT_DEPLOY_VERBOSE: "1"
226232
DOT_TAG: ${{ steps.setup.outputs.tag }}
227233
DOT_TELEMETRY: "1"
234+
# Seed for the dedicated funder, the fallback globalSetup uses
235+
# to top up SIGNER once Alice drains. Absent → Alice only.
236+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
228237
# nightly-deploy-moddable creates a fresh public repo in
229238
# paritytech/ each run + pushes the fixture into it. Other
230239
# cells in this matrix don't consume GH_TOKEN so setting it
@@ -306,6 +315,9 @@ jobs:
306315
DOT_DEPLOY_VERBOSE: "1"
307316
DOT_TAG: ${{ steps.setup.outputs.tag }}
308317
DOT_TELEMETRY: "1"
318+
# Seed for the dedicated funder, the fallback globalSetup uses
319+
# to top up SIGNER once Alice drains. Absent → Alice only.
320+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
309321

310322
- name: Surface failure detail
311323
if: failure()

.github/workflows/funder-balance-check.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ jobs:
3838
# `continue-on-error` so the follow-up step can react to low /
3939
# error exits instead of the job being marked failed and skipping.
4040
continue-on-error: true
41+
env:
42+
# Seed for the dedicated funder. Without it the probe can't
43+
# derive the address and exits 2 (treated as "low").
44+
MASTER_FUNDER_SEED: ${{ secrets.MASTER_FUNDER_SEED }}
4145
run: |
4246
bun run scripts/check-funder-balance.ts | tee balance.txt
4347
echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"

docs/e2e-bootstrap.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,16 @@ After a registry-contract redeploy, the next CI run restores them automatically.
5757

5858
| Secret | Required for | Status |
5959
|---|---|---|
60-
| `MASTER_FUNDER_SEED` | SIGNER top-up in globalSetup | Required — must be set |
60+
| `MASTER_FUNDER_SEED` | Dedicated-funder seed — SIGNER top-up in globalSetup + the Funder Balance Check job | Required — must be set |
6161
| `SENTRY_AUTH_TOKEN` | Telemetry verification (not yet implemented) | Optional |
6262

63+
`MASTER_FUNDER_SEED` is a BIP-39 mnemonic read at runtime by
64+
`src/utils/account/funder.ts` (env var, never hardcoded). It derives the
65+
dedicated funder at `//0`, which is the **primary** funder — the chain draws it
66+
down first and falls back to public Alice only when it's empty. When unset the
67+
chain is just Alice — fine for offline/local runs, but CI must set it. Provide
68+
it to local runs with `export MASTER_FUNDER_SEED=…`.
69+
6370
`GITHUB_TOKEN` (auto-provided) is used by the report job and cleanup cron.
6471

6572
---
@@ -103,11 +110,14 @@ bun tools/register-e2e-fixtures.ts --suri "//MyStagingSigner"
103110

104111
Nothing to do manually. `globalSetup` (`e2e/cli/setup/fund.ts`) and
105112
`tools/register-e2e-fixtures.ts` both auto-top-up when free balance is below
106-
500 PAS. The top-up source is `MASTER_FUNDER_SEED` (GitHub secret) or the
107-
CLI's built-in funder derivation locally.
108-
109-
If `MASTER_FUNDER_SEED` itself is empty, re-fund the parent funder account on
110-
Paseo and update the secret.
113+
500 PAS — first from the dedicated funder derived from `MASTER_FUNDER_SEED`
114+
(GitHub secret in CI; `export MASTER_FUNDER_SEED=…` locally), then from public
115+
Alice as the fallback.
116+
117+
If both the dedicated funder and Alice are empty, re-fund the dedicated funder
118+
account (the `//0`-derived address — run `bun run scripts/check-funder-balance.ts`
119+
with the seed exported to print it) on Paseo, and update the secret if the seed
120+
itself changed.
111121

112122
### Registry contract redeployed (all domains wiped)
113123

scripts/check-funder-balance.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,21 @@ function formatPas(planck: bigint): string {
4141
}
4242

4343
async function main(): Promise<number> {
44+
const address = DEDICATED_FUNDER_ADDRESS;
45+
if (!address) {
46+
console.error(
47+
"MASTER_FUNDER_SEED is not set — cannot derive the dedicated funder address. " +
48+
"Export the funder seed (the MASTER_FUNDER_SEED repository secret) and re-run.",
49+
);
50+
return 2;
51+
}
4452
const client = await getConnection();
4553
try {
46-
const account = await client.assetHub.query.System.Account.getValue(
47-
DEDICATED_FUNDER_ADDRESS,
48-
{ at: "best" },
49-
);
54+
const account = await client.assetHub.query.System.Account.getValue(address, {
55+
at: "best",
56+
});
5057
const free = account.data.free;
51-
console.log(`address=${DEDICATED_FUNDER_ADDRESS}`);
58+
console.log(`address=${address}`);
5259
console.log(`balance=${formatPas(free)}`);
5360
console.log(`balance_planck=${free}`);
5461
console.log(`threshold=${formatPas(THRESHOLD_PLANCK)}`);

src/utils/account/funder.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
/**
17+
* Pins the `MASTER_FUNDER_SEED` gating of the dedicated funder. The seed is
18+
* read at module load, so each case sets/clears the env var, resets the module
19+
* registry, and re-imports `./funder.js` to re-derive the chain.
20+
*
21+
* Any valid BIP-39 phrase works; we reuse the well-known substrate dev phrase
22+
* (also used in other test files) purely because it is a guaranteed-valid
23+
* mnemonic — it is NOT the real funder seed, which only ever comes from the
24+
* secret.
25+
*/
26+
27+
import { describe, it, expect, afterEach, vi } from "vitest";
28+
29+
const VALID_MNEMONIC = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
30+
31+
afterEach(() => {
32+
delete process.env.MASTER_FUNDER_SEED;
33+
vi.resetModules();
34+
});
35+
36+
describe("FUNDER_CHAIN dedicated-funder gating", () => {
37+
it("omits the dedicated funder when MASTER_FUNDER_SEED is unset", async () => {
38+
delete process.env.MASTER_FUNDER_SEED;
39+
vi.resetModules();
40+
const { FUNDER_CHAIN, DEDICATED_FUNDER_ADDRESS } = await import("./funder.js");
41+
42+
expect(FUNDER_CHAIN.map((f) => f.name)).toEqual(["Alice"]);
43+
expect(DEDICATED_FUNDER_ADDRESS).toBeNull();
44+
});
45+
46+
it("prepends the dedicated funder (primary) ahead of Alice when MASTER_FUNDER_SEED is set", async () => {
47+
process.env.MASTER_FUNDER_SEED = VALID_MNEMONIC;
48+
vi.resetModules();
49+
const { FUNDER_CHAIN, DEDICATED_FUNDER_ADDRESS } = await import("./funder.js");
50+
51+
// Dedicated funder is tried first; Alice is the fallback.
52+
expect(FUNDER_CHAIN.map((f) => f.name)).toEqual(["dedicated", "Alice"]);
53+
// Address is derived from the seed and matches the leading chain entry.
54+
expect(DEDICATED_FUNDER_ADDRESS).toMatch(/^5/);
55+
expect(FUNDER_CHAIN[0]?.address).toBe(DEDICATED_FUNDER_ADDRESS);
56+
});
57+
58+
it("trims surrounding whitespace before deriving", async () => {
59+
process.env.MASTER_FUNDER_SEED = ` ${VALID_MNEMONIC} `;
60+
vi.resetModules();
61+
const { DEDICATED_FUNDER_ADDRESS } = await import("./funder.js");
62+
63+
expect(DEDICATED_FUNDER_ADDRESS).not.toBeNull();
64+
});
65+
66+
it("treats a blank MASTER_FUNDER_SEED as unset", async () => {
67+
process.env.MASTER_FUNDER_SEED = " ";
68+
vi.resetModules();
69+
const { FUNDER_CHAIN, DEDICATED_FUNDER_ADDRESS } = await import("./funder.js");
70+
71+
expect(FUNDER_CHAIN.map((f) => f.name)).toEqual(["Alice"]);
72+
expect(DEDICATED_FUNDER_ADDRESS).toBeNull();
73+
});
74+
});

src/utils/account/funder.ts

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@
1515

1616
/**
1717
* Testnet funder accounts used to top up users and session keys on Paseo
18-
* Asset Hub. We try Alice first (public dev account — free tokens while she
19-
* lasts) and fall back to a dedicated account whose seed is embedded here
20-
* (obscure, not one of the well-known dev dropdowns in polkadot.js Apps, so
21-
* random drainers don't target it the way they target Alice).
18+
* Asset Hub. We try the dedicated account first — our own controlled funder
19+
* whose seed is supplied at runtime via the `MASTER_FUNDER_SEED` environment
20+
* variable (NOT one of the well-known dev dropdowns in polkadot.js Apps, so
21+
* random drainers don't target it the way they target Alice) — and fall back
22+
* to the public Alice dev account (free tokens while she lasts).
2223
*
23-
* Goes away on mainnet: users fund themselves and this module is retired.
24+
* The seed is deliberately NOT hardcoded so it can never be extracted from the
25+
* published binary. CI injects it from the `MASTER_FUNDER_SEED` repository
26+
* secret; locally an operator exports it before running the E2E suite or the
27+
* funder tooling. The funder chain is exercised ONLY by the E2E suite and
28+
* operator tooling — no end-user command funds from it — so when the env var
29+
* is absent the dedicated funder is simply dropped from the chain and
30+
* everything still works off Alice.
31+
*
32+
* Even when set this is not a hard security boundary: anyone holding the seed
33+
* can spend it. Acceptable on testnet; the whole module is retired on mainnet
34+
* where users fund themselves.
2435
*/
2536

2637
import type { PolkadotSigner } from "polkadot-api";
@@ -30,14 +41,14 @@ import { ss58Encode } from "@parity/product-sdk-address";
3041
import { type Env, getChainConfig } from "../../config.js";
3142

3243
/**
33-
* Dedicated testnet funder mnemonic. Obscure-by-convention (not in any
34-
* public dropdown), not a security boundary — anyone with this CLI binary
35-
* can extract it. Acceptable on testnet; replace for mainnet.
44+
* Dedicated testnet funder, derived at `//0` from the `MASTER_FUNDER_SEED`
45+
* env var (a BIP-39 mnemonic). `null` when the var is unset/blank — callers
46+
* then fall back to Alice alone. Derived once at module load so we don't
47+
* re-run BIP-39 + sr25519 on every access.
3648
*/
37-
const DEDICATED_FUNDER_MNEMONIC =
38-
"bargain obey warm sing goose glimpse kind repeat grape orchard reason rely";
39-
40-
const dedicated = seedToAccount(DEDICATED_FUNDER_MNEMONIC, "//0");
49+
const dedicatedSeed = process.env.MASTER_FUNDER_SEED?.trim();
50+
const dedicated = dedicatedSeed ? seedToAccount(dedicatedSeed, "//0") : null;
51+
const dedicatedAddress = dedicated ? ss58Encode(dedicated.publicKey) : null;
4152

4253
export interface Funder {
4354
/** Log-friendly name — included in `AllFundersExhaustedError.tried`. */
@@ -50,24 +61,35 @@ export interface Funder {
5061

5162
/**
5263
* Ordered chain of funders. Callers walk this list and pick the first funder
53-
* whose free balance ≥ required amount. Public Alice comes first so the
54-
* dedicated account only gets drawn down once she's drained.
64+
* whose free balance ≥ required amount. The dedicated funder (our own
65+
* controlled account) comes first so we draw it down before touching public
66+
* Alice — she's a shared dev account others drain unpredictably, so she's the
67+
* fallback, not the primary. The dedicated funder is present only when
68+
* `MASTER_FUNDER_SEED` is configured; without it the chain is Alice-only.
5569
*/
5670
export const FUNDER_CHAIN: readonly Funder[] = [
71+
...(dedicated
72+
? [
73+
{
74+
name: "dedicated",
75+
address: dedicatedAddress as string,
76+
signer: dedicated.signer,
77+
},
78+
]
79+
: []),
5780
{
5881
name: "Alice",
5982
address: ss58Encode(getDevPublicKey("Alice")),
6083
signer: createDevSigner("Alice"),
6184
},
62-
{
63-
name: "dedicated",
64-
address: ss58Encode(dedicated.publicKey),
65-
signer: dedicated.signer,
66-
},
6785
];
6886

69-
/** Convenience: public address of the dedicated funder. Used by the balance-check CI job. */
70-
export const DEDICATED_FUNDER_ADDRESS = FUNDER_CHAIN[1].address;
87+
/**
88+
* Public address of the dedicated funder, or `null` when `MASTER_FUNDER_SEED`
89+
* is unset. Used by the balance-check CI job, which always runs with the
90+
* secret configured.
91+
*/
92+
export const DEDICATED_FUNDER_ADDRESS: string | null = dedicatedAddress;
7193

7294
/**
7395
* Faucet URL for the env, pre-filled with the user's address — one click to

0 commit comments

Comments
 (0)