Skip to content

Commit b9044f0

Browse files
fix(deploy): persist session key only after map_account succeeds (closes #94) (#106)
1 parent 1babc16 commit b9044f0

5 files changed

Lines changed: 168 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"playground-cli": patch
3+
---
4+
5+
Fixed an intermittent `Revive::AccountUnmapped` failure during contract deploys. The per-deploy session key is now persisted to `~/.config/dot/accounts.json` only AFTER its `map_account` extrinsic is confirmed on chain. Previously the persist happened first, so a failing `map_account` (e.g. nonce race, transient chain error) left the on-disk state lying — the retry would find the existing key, skip the mapping step, and fail at the dry-run with AccountUnmapped. Fixes #94.

src/utils/deploy/run.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ vi.mock("./contracts.js", () => ({
106106
}));
107107
vi.mock("./session-account.js", () => ({
108108
getOrCreateSessionAccount: getOrCreateSessionAccountMock,
109+
persistSessionAccount: vi.fn(async () => {}),
109110
SESSION_MIN_BALANCE: 5_000_000_000n,
110111
SESSION_FUND_AMOUNT: 50_000_000_000n,
111112
}));

src/utils/deploy/run.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { publishToPlayground, normalizeDomain } from "./playground.js";
2020
import { runContractsPhase, type ContractsPhaseEvent } from "./contracts.js";
2121
import {
2222
getOrCreateSessionAccount,
23+
persistSessionAccount,
2324
SESSION_FUND_AMOUNT,
2425
SESSION_MIN_BALANCE,
2526
} from "./session-account.js";
@@ -278,6 +279,7 @@ async function maybeRunContracts(
278279
client.assetHub.tx.Revive.map_account(),
279280
session.account.signer,
280281
);
282+
await persistSessionAccount(session);
281283
}
282284

283285
const result = await runContractsPhase({

src/utils/deploy/session-account.test.ts

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { getOrCreateSessionAccount, readSessionAccount } from "./session-account.js";
5+
import {
6+
getOrCreateSessionAccount,
7+
persistSessionAccount,
8+
readSessionAccount,
9+
} from "./session-account.js";
10+
11+
// ── getOrCreateSessionAccount ────────────────────────────────────────────────
612

713
describe("getOrCreateSessionAccount", () => {
814
let tmp: string;
@@ -23,46 +29,132 @@ describe("getOrCreateSessionAccount", () => {
2329
rmSync(tmp, { recursive: true, force: true });
2430
});
2531

26-
it("generates a new key on first call and persists the mnemonic", async () => {
32+
it("returns created=true and a valid key on first call", async () => {
2733
const { info, created } = await getOrCreateSessionAccount();
2834

2935
expect(created).toBe(true);
3036
expect(info.mnemonic.split(" ").length).toBeGreaterThanOrEqual(12);
3137
expect(info.account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);
3238
expect(info.account.h160Address).toMatch(/^0x[0-9a-fA-F]{40}$/);
3339
expect(typeof info.account.signer.signTx).toBe("function");
40+
});
41+
42+
it("does NOT write accounts.json before persistSessionAccount is called", async () => {
43+
await getOrCreateSessionAccount();
3444

3545
const path = join(tmp, "accounts.json");
36-
expect(existsSync(path)).toBe(true);
37-
const stored = JSON.parse(readFileSync(path, "utf8"));
38-
expect(stored.default).toBe(info.mnemonic);
46+
expect(existsSync(path)).toBe(false);
3947
});
4048

41-
it("returns the same key on subsequent calls with created=false", async () => {
49+
it("returns created=false and existing key when file already has a key", async () => {
50+
// Seed the file via persist on the first creation.
4251
const first = await getOrCreateSessionAccount();
52+
await persistSessionAccount(first.info);
53+
4354
const second = await getOrCreateSessionAccount();
4455

45-
expect(first.created).toBe(true);
4656
expect(second.created).toBe(false);
4757
expect(second.info.mnemonic).toBe(first.info.mnemonic);
4858
expect(second.info.account.ss58Address).toBe(first.info.account.ss58Address);
4959
});
5060

51-
it("ignores garbage in the store and regenerates a valid key", async () => {
61+
it("returns created=true (new key) on retry when file was never written (map_account failed)", async () => {
62+
// Simulate: first create, map_account throws → no persist → retry.
63+
const first = await getOrCreateSessionAccount();
64+
expect(first.created).toBe(true);
65+
66+
// map_account failed; we never called persistSessionAccount.
67+
// On retry getOrCreateSessionAccount should mint a fresh key.
68+
const retry = await getOrCreateSessionAccount();
69+
expect(retry.created).toBe(true);
70+
// A fresh mnemonic is generated each time.
71+
expect(retry.info.mnemonic).not.toBe(first.info.mnemonic);
72+
});
73+
74+
it("ignores garbage in the store and returns a fresh key", async () => {
5275
const path = join(tmp, "accounts.json");
53-
await getOrCreateSessionAccount();
54-
const { writeFileSync } = await import("node:fs");
76+
// Write garbage into the file to simulate corruption.
77+
const { writeFileSync, mkdirSync } = await import("node:fs");
78+
const { dirname } = await import("node:path");
79+
mkdirSync(dirname(path), { recursive: true });
5580
writeFileSync(path, JSON.stringify({ default: { not: "a string" } }));
5681

5782
const { info, created } = await getOrCreateSessionAccount();
5883
expect(created).toBe(true);
5984
expect(info.mnemonic.split(" ").length).toBeGreaterThanOrEqual(12);
6085

86+
// The garbage file is still there; it should not have been overwritten.
6187
const stored = JSON.parse(readFileSync(path, "utf8"));
62-
expect(typeof stored.default).toBe("string");
88+
expect(typeof stored.default).not.toBe("string");
6389
});
6490
});
6591

92+
// ── persistSessionAccount ────────────────────────────────────────────────────
93+
94+
describe("persistSessionAccount", () => {
95+
let tmp: string;
96+
let originalRoot: string | undefined;
97+
98+
beforeEach(() => {
99+
tmp = mkdtempSync(join(tmpdir(), "pg-session-persist-"));
100+
originalRoot = process.env.POLKADOT_ROOT;
101+
process.env.POLKADOT_ROOT = tmp;
102+
});
103+
104+
afterEach(() => {
105+
if (originalRoot === undefined) {
106+
delete process.env.POLKADOT_ROOT;
107+
} else {
108+
process.env.POLKADOT_ROOT = originalRoot;
109+
}
110+
rmSync(tmp, { recursive: true, force: true });
111+
});
112+
113+
it("writes accounts.json with the mnemonic under the 'default' key", async () => {
114+
const { info } = await getOrCreateSessionAccount();
115+
await persistSessionAccount(info);
116+
117+
const path = join(tmp, "accounts.json");
118+
expect(existsSync(path)).toBe(true);
119+
const stored = JSON.parse(readFileSync(path, "utf8"));
120+
expect(stored.default).toBe(info.mnemonic);
121+
});
122+
123+
it("after persist, getOrCreateSessionAccount returns created=false", async () => {
124+
const { info } = await getOrCreateSessionAccount();
125+
await persistSessionAccount(info);
126+
127+
const second = await getOrCreateSessionAccount();
128+
expect(second.created).toBe(false);
129+
expect(second.info.mnemonic).toBe(info.mnemonic);
130+
});
131+
132+
it("map_account-fails scenario: no persist → retry mints new key and re-maps", async () => {
133+
// Step 1: create key in memory (map_account would be attempted here).
134+
const attempt1 = await getOrCreateSessionAccount();
135+
expect(attempt1.created).toBe(true);
136+
137+
// Step 2: map_account throws — do NOT call persistSessionAccount.
138+
// (No action needed — file is untouched.)
139+
140+
// Step 3: retry — should produce a fresh key with created=true.
141+
const attempt2 = await getOrCreateSessionAccount();
142+
expect(attempt2.created).toBe(true);
143+
// It's a different key, so the retry path would re-attempt map_account.
144+
expect(attempt2.info.mnemonic).not.toBe(attempt1.info.mnemonic);
145+
146+
// Step 4: map_account succeeds — now persist.
147+
await persistSessionAccount(attempt2.info);
148+
149+
// Step 5: subsequent call loads from disk.
150+
const loaded = await getOrCreateSessionAccount();
151+
expect(loaded.created).toBe(false);
152+
expect(loaded.info.mnemonic).toBe(attempt2.info.mnemonic);
153+
});
154+
});
155+
156+
// ── readSessionAccount ────────────────────────────────────────────────────────
157+
66158
describe("readSessionAccount", () => {
67159
let tmp: string;
68160
let originalRoot: string | undefined;
@@ -87,10 +179,12 @@ describe("readSessionAccount", () => {
87179
});
88180

89181
it("returns the persisted key without creating a new one", async () => {
90-
const { info: created } = await getOrCreateSessionAccount();
182+
const { info } = await getOrCreateSessionAccount();
183+
await persistSessionAccount(info);
184+
91185
const loaded = await readSessionAccount();
92-
expect(loaded?.mnemonic).toBe(created.mnemonic);
93-
expect(loaded?.account.ss58Address).toBe(created.account.ss58Address);
186+
expect(loaded?.mnemonic).toBe(info.mnemonic);
187+
expect(loaded?.account.ss58Address).toBe(info.account.ss58Address);
94188
});
95189

96190
it("does not create a file when the store is empty (read-only)", async () => {

src/utils/deploy/session-account.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,32 @@ class FileKvStore implements KvStore {
7777
}
7878
}
7979

80+
/** In-memory KvStore — used to mint a session key without touching disk. */
81+
class InMemoryKvStore implements KvStore {
82+
private readonly data: Record<string, string> = {};
83+
84+
async get(key: string): Promise<string | null> {
85+
return this.data[key] ?? null;
86+
}
87+
88+
async set(key: string, value: string): Promise<void> {
89+
this.data[key] = value;
90+
}
91+
92+
async remove(key: string): Promise<void> {
93+
delete this.data[key];
94+
}
95+
96+
async getJSON<T>(key: string): Promise<T | null> {
97+
const raw = await this.get(key);
98+
return raw === null ? null : (JSON.parse(raw) as T);
99+
}
100+
101+
async setJSON(key: string, value: unknown): Promise<void> {
102+
await this.set(key, JSON.stringify(value));
103+
}
104+
}
105+
80106
/** Read the persisted session key; returns null on a miss (does not mint). */
81107
export async function readSessionAccount(): Promise<SessionKeyInfo | null> {
82108
const store = new FileKvStore(accountsPath());
@@ -85,17 +111,36 @@ export async function readSessionAccount(): Promise<SessionKeyInfo | null> {
85111
}
86112

87113
/**
88-
* Load the persisted session key, or mint + save a fresh one on first call.
89-
* `created` is true only on the minting call — callers use it to gate the
90-
* one-time `Revive.map_account` bootstrap.
114+
* Load the persisted session key, or mint a fresh one in memory on first call.
115+
*
116+
* `created` is true only on the minting call — callers MUST:
117+
* 1. Submit `Revive.map_account` on-chain (gated by `created === true`).
118+
* 2. Call `persistSessionAccount(info)` ONLY AFTER the extrinsic is confirmed.
119+
*
120+
* Keeping persist separate from create prevents the file from recording a key
121+
* whose on-chain mapping was never established. If `map_account` fails, the
122+
* file is untouched so the next retry mints a fresh key and re-attempts mapping.
91123
*/
92124
export async function getOrCreateSessionAccount(): Promise<{
93125
info: SessionKeyInfo;
94126
created: boolean;
95127
}> {
96-
const store = new FileKvStore(accountsPath());
97-
const manager = new SessionKeyManager({ store });
98-
const existing = await manager.get();
128+
const fileStore = new FileKvStore(accountsPath());
129+
const fileManager = new SessionKeyManager({ store: fileStore });
130+
const existing = await fileManager.get();
99131
if (existing) return { info: existing, created: false };
100-
return { info: await manager.create(), created: true };
132+
133+
// Mint in memory — do NOT write to disk yet. The caller must call
134+
// `persistSessionAccount` after `map_account` succeeds.
135+
const memManager = new SessionKeyManager({ store: new InMemoryKvStore() });
136+
return { info: await memManager.create(), created: true };
137+
}
138+
139+
/**
140+
* Write a session key to disk. Call this ONLY after the on-chain
141+
* `Revive.map_account` extrinsic for `info` has been confirmed.
142+
*/
143+
export async function persistSessionAccount(info: SessionKeyInfo): Promise<void> {
144+
const store = new FileKvStore(accountsPath());
145+
await store.set("default", info.mnemonic);
101146
}

0 commit comments

Comments
 (0)