epistery 2.0.4 → 2.2.0

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 (45) hide show
  1. package/.test.env +25 -0
  2. package/cli/epistery.mjs +9 -10
  3. package/dist/chains/registry.d.ts +2 -2
  4. package/dist/chains/registry.d.ts.map +1 -1
  5. package/dist/chains/registry.js +4 -4
  6. package/dist/chains/registry.js.map +1 -1
  7. package/dist/epistery.d.ts +1 -1
  8. package/dist/epistery.d.ts.map +1 -1
  9. package/dist/epistery.js +4 -4
  10. package/dist/epistery.js.map +1 -1
  11. package/dist/utils/CliWallet.d.ts +9 -5
  12. package/dist/utils/CliWallet.d.ts.map +1 -1
  13. package/dist/utils/CliWallet.js +18 -13
  14. package/dist/utils/CliWallet.js.map +1 -1
  15. package/dist/utils/Config.d.ts +104 -48
  16. package/dist/utils/Config.d.ts.map +1 -1
  17. package/dist/utils/Config.js +273 -116
  18. package/dist/utils/Config.js.map +1 -1
  19. package/dist/utils/Utils.d.ts +10 -2
  20. package/dist/utils/Utils.d.ts.map +1 -1
  21. package/dist/utils/Utils.js +18 -9
  22. package/dist/utils/Utils.js.map +1 -1
  23. package/docs/RivetSignerConfigAuthority.md +219 -0
  24. package/index.mjs +10 -9
  25. package/package.json +1 -1
  26. package/routes/auth.mjs +6 -6
  27. package/routes/domain.mjs +2 -2
  28. package/routes/fido.mjs +4 -4
  29. package/src/chains/registry.ts +4 -4
  30. package/src/epistery.ts +4 -4
  31. package/src/utils/CliWallet.ts +18 -13
  32. package/src/utils/Config.ts +313 -106
  33. package/src/utils/Utils.ts +19 -9
  34. package/test/config/.epistery/127.0.0.1/config.ini +15 -0
  35. package/test/config/.epistery/config.ini +14 -0
  36. package/test/config/.epistery/localhost/config.ini +16 -0
  37. package/test/config/.epistery/no-pending-claim.local/config.ini +15 -0
  38. package/test/config/.epistery/test-claim-1782325887560.local/config.ini +20 -0
  39. package/test/config/.epistery/test-idempotent-1782325887610.local/config.ini +20 -0
  40. package/test/config/.epistery/test-init-1782325888110.local/config.ini +16 -0
  41. package/test/routes/auth.test.ts +7 -3
  42. package/test/routes/identity.test.ts +0 -41
  43. package/test/routes/status.test.ts +5 -39
  44. package/test/setup.ts +9 -9
  45. package/test/utils.ts +9 -3
@@ -0,0 +1,219 @@
1
+ # Rivet Signer & Config Authority — Interface Spec
2
+
3
+ Status: draft for review · 2026-06-23 · feeds RootzOracleTenancy · repo: `epistery-authority` (instance `epistery-authority-1`)
4
+
5
+ This specifies the **interfaces** for turning `~/.epistery` local state into a shared
6
+ configuration/key authority, so pool members become stateless and interchangeable.
7
+ Interfaces first — no implementation here. It is the contract the authority, the
8
+ `Config` rewrite, and the client/server signers all build against.
9
+
10
+ ## 0. The one idea
11
+
12
+ A **rivet** is *an unextractable-but-usable signing identity*. The private key lives
13
+ with a **custodian** that never hands it out; callers get **signatures**, not keys.
14
+
15
+ This is already true client-side. `client/wallet.js` `RivetWallet` keeps the secp256k1
16
+ key encrypted under a non-extractable WebCrypto AES key in IndexedDB and only ever
17
+ decrypts it inside a function closure that signs and returns (`wallet.js:399-434`,
18
+ `446-490`); `FidoWallet` wraps the key with a WebAuthn-PRF-derived key in the Secure
19
+ Enclave (`wallet.js:1272-1316`). The key never escapes the custodian.
20
+
21
+ The **server rivet** is the same object with a different custodian: the domain wallet's
22
+ key lives in `epistery-authority-1` and is used via a signing RPC. Today that key is the
23
+ plaintext mnemonic in `~/.epistery/<domain>/config.ini`, and **six call sites
24
+ independently re-derive a wallet from it** — `Utils.ts:80`, `epistery.ts:109` & `:170`,
25
+ host `DomainChain.mjs:78` & `index.mjs:239`, `relay/index.mjs:141`
26
+ (plus `message-board` and `chat/` deploy scripts). That scatter is the parallel-channel
27
+ we are collapsing into one owner.
28
+
29
+ ```
30
+ custodian (holds key, never releases) rivet (used via signatures)
31
+ client WebCrypto/IndexedDB | FIDO Secure Enclave RivetWallet | FidoWallet
32
+ server epistery-config-1 (HSM/TPM) RemoteSigner (domain wallet)
33
+ ```
34
+
35
+ The custodian/rivet split is the shared abstraction. The authority is to the server
36
+ rivet what the FIDO authenticator is to the client rivet.
37
+
38
+ ## 1. `Rivet` — the capability interface (shared)
39
+
40
+ The narrow async contract every rivet satisfies, regardless of custodian. All methods
41
+ are async because a remote/HSM/FIDO custodian cannot answer synchronously.
42
+
43
+ ```ts
44
+ export interface Rivet {
45
+ readonly address: string; // the rivet (signer) address — always present
46
+ readonly publicKey: string; // uncompressed secp256k1, 0x04…
47
+ getAddress(): Promise<string>;
48
+ signMessage(message: string | Uint8Array): Promise<string>;
49
+ signTransaction(tx: UnsignedTransaction): Promise<string>; // returns raw signed tx hex
50
+ // Optional ECDH peer crypto — implemented by custodians that can compute a
51
+ // shared secret without exposing the key (RivetWallet/FidoWallet do this today,
52
+ // wallet.js:495-530, 1321-1331). The authority MAY offer it as an RPC.
53
+ encryptForPeer?(peerPublicKey: string, plaintext: Uint8Array): Promise<EciesBlob>;
54
+ decryptFromPeer?(peerPublicKey: string, blob: EciesBlob): Promise<Uint8Array>;
55
+ }
56
+ ```
57
+
58
+ Notes:
59
+ - This intentionally matches ethers v5 `Signer`'s `getAddress`/`signMessage`/
60
+ `signTransaction`. The **server realization of `Rivet` is an `ethers.Signer`**
61
+ (`RemoteSigner extends ethers.Signer`) because every server caller already consumes
62
+ a `Signer` (`Utils.InitServerWallet` returns `ethers.Wallet` today). So adopting
63
+ `Rivet` on the server is a no-op at the call sites — only the construction changes.
64
+ - The **client `Wallet` hierarchy is adapted to `Rivet`**: drop the `ethers` argument
65
+ threaded through `sign(message, ethers)`/`signTransaction(tx, ethers)` (the browser
66
+ no-bundler quirk) by binding `ethers` at construction; expose `getAddress`. The
67
+ identity getters (`signerAddress`, `identityAddress`, `wallet.js:36-44`) stay — they
68
+ are an identity concern layered above signing, not part of `Rivet`.
69
+
70
+ ## 2. Two server rivets, layered
71
+
72
+ | Rivet | Custodian | Unextractable | Purpose |
73
+ |---|---|---|---|
74
+ | **machine/device rivet** | the box's TPM (self-minted per instance, per the wiki) | yes (hardware) | authenticates *this host* to the authority |
75
+ | **domain server rivet** | `epistery-authority-1` | Phase 2: yes | the on-chain identity the authority wields *on behalf of* authorized machines |
76
+
77
+ A pool member never holds the domain key. It proves itself with its machine rivet, and
78
+ borrows the domain rivet's signature from the authority. **N stateless machines share
79
+ one on-chain domain identity without sharing its key** — the wiki's statelessness and
80
+ its security story in one sentence.
81
+
82
+ ## 3. `Config` becomes async (breaking)
83
+
84
+ Decided: all clients upgrade; no sync-snapshot shim. `Config` keeps its path-based shape
85
+ (`src/utils/Config.ts`) but `load`/`save`/`setPath`/`read` become async fetch/push points;
86
+ `.data` reflects the last `await`ed load.
87
+
88
+ ```ts
89
+ export interface ConfigStore {
90
+ setPath(path: string): Promise<void>; // was sync; now awaits a fetch in remote mode
91
+ getPath(): string;
92
+ load(): Promise<void>;
93
+ read(path: string): Promise<any>; // read without moving current path
94
+ save(): Promise<void>;
95
+ readFile(name: string): Promise<Buffer>;
96
+ writeFile(name: string, data: string | Buffer): Promise<void>;
97
+ exists(): Promise<boolean>;
98
+ listPaths(): Promise<string[]>;
99
+ data: any; // snapshot from the last load()
100
+ }
101
+ ```
102
+
103
+ **Backend selection** happens in the `Config` constructor:
104
+ - If local `~/.epistery/config.ini` has `[authority] url=…`, or `EPISTERY_CONFIG_URL`
105
+ is set → `RemoteConfig` (talks to `epistery-config-1`).
106
+ - Else → `LocalConfig` (today's filesystem behavior, unchanged).
107
+
108
+ The local file never disappears — in remote mode it shrinks to **bootstrap state**: the
109
+ authority URL and this machine's credential (its machine-rivet handle). That is the
110
+ "machine data" the wiki carves out and the only per-box state a pool member keeps.
111
+
112
+ **Public/secret split in the data model.** Today `[wallet]` co-mingles public
113
+ (`address`, `publicKey`) and secret (`mnemonic`, `privateKey`) — `types.ts:18-23`. The
114
+ authority must serve the public fields freely and treat the secret as custodied:
115
+
116
+ | Class | Examples | Phase 1 | Phase 2 |
117
+ |---|---|---|---|
118
+ | public config | provider, contract addresses, claim state, prefs | served R/W | served R/W |
119
+ | signing key | wallet mnemonic / privateKey | released | **never released — authority signs** |
120
+ | opaque secret blob | TLS cert+key, storj/S3, mongo password | released to authorized machines | released (governed safe; cannot be HSM'd) |
121
+
122
+ **The authority is also a safe.** `readFile`/`writeFile` already persist arbitrary
123
+ blobs (`Config.ts:151-163`), and `Config`'s `.ssl/<domain>` path is already in the path
124
+ model — so the authority is two things at once: an **HSM** for signing keys (never
125
+ released) and a **safe** for opaque blobs (governed release via `/file/*path/:name`).
126
+ Anything that is a *bearer* secret — a key the host must itself present to a third party
127
+ or to a TLS handshake — lives in the safe, because the authority can't wield it remotely
128
+ the way it wields an Ethereum signing key.
129
+
130
+ ## 4. Authority HTTP API
131
+
132
+ Auth is the **rivet key-exchange already in the codebase** (`Epistery.handleKeyExchange`,
133
+ `epistery.ts:82-137`; wire shapes `KeyExchangeRequest`/`Response`, `types.ts:98-117`),
134
+ pointed at the authority. One mechanism, not a bespoke machine-auth.
135
+
136
+ ```
137
+ POST /auth/challenge { machineAddress } -> { challenge }
138
+ POST /auth/verify { machineAddress, message, signature, publicKey }
139
+ message = "Epistery Key Exchange - <machineAddress> - <challenge>"
140
+ -> { token } # session/bearer
141
+
142
+ # Config data (token-gated; ACL decides which paths this machine may see)
143
+ GET /config/*path -> { data } # public fields only
144
+ PUT /config/*path { data } -> { ok }
145
+ GET /config/*path/_paths -> { paths: string[] }
146
+ GET /file/*path/:name -> bytes
147
+ PUT /file/*path/:name <- bytes
148
+
149
+ # Server rivet — public info always; key material governed
150
+ GET /wallet/:domain -> { address, publicKey } # never the key
151
+ POST /sign/message { domain, message } -> { signature } # Phase 2
152
+ POST /sign/transaction { domain, tx } -> { signedTransaction } # Phase 2
153
+ GET /secret/:domain/:name -> { value } # governed release (storj/mongo)
154
+ ```
155
+
156
+ Governance ("like a CA"): the authority's ACL — keyed on the **machine rivet** — decides
157
+ which domains a host may read and which signing ops it may request. That is the
158
+ authority's reason to exist over a network share.
159
+
160
+ ## 5. `RemoteSigner` — server-side rivet realization (Phase 2)
161
+
162
+ ```ts
163
+ class RemoteSigner extends ethers.Signer implements Rivet {
164
+ constructor(domain, authorityClient, provider) { super(); /* … */ }
165
+ async getAddress() { return (await this.authority.wallet(this.domain)).address; }
166
+ async signMessage(message) { return this.authority.signMessage(this.domain, message); }
167
+ async signTransaction(tx) { return this.authority.signTransaction(this.domain, tx); }
168
+ connect(provider) { return new RemoteSigner(this.domain, this.authority, provider); }
169
+ // sendTransaction is inherited: signTransaction (remote) then provider.broadcast (local) —
170
+ // so RPC/broadcast stay on the host; only the signature is remote.
171
+ }
172
+ ```
173
+
174
+ `Utils.InitServerWallet(domain)` is the **single owner** of "get a signer for this
175
+ domain." It returns a `LocalWallet` (today / Phase 1) or a `RemoteSigner` (Phase 2)
176
+ behind the same `ethers.Signer` type. The six scattered `fromMnemonic` sites all route
177
+ through it — that consolidation is Phase 0 and is what makes Phase 2 a transport swap
178
+ rather than a client rewrite.
179
+
180
+ ## 6. How each consumer adapts
181
+
182
+ - **epistery-host** — `await` the new `Config`; reads domain config/claim state from the
183
+ authority; gets its domain signer from `Utils.InitServerWallet` (already does, via
184
+ `app.locals.epistery.signer`). `DomainChain.mjs:78` & `index.mjs:239` stop calling
185
+ `fromMnemonic` and ask `Utils`. TLS certs are a separate workstream (see §8).
186
+ - **epistery-app** — minimal: `await` the root config reads (`relayUrl`, `helpOwner`);
187
+ manifest signing already goes through `epistery.signer`, so no signer change.
188
+ - **relay** — **subordinate, not merged.** Keeps credit metering, broadcast, and on-chain
189
+ identity *reads*. Its pool wallet (`index.mjs:141`) becomes a domain rivet custodied by
190
+ the authority: drop `fromMnemonic`, fetch a signer from the authority. Relay then pools
191
+ too.
192
+ - **cli** (`CliWallet.ts`) — `await` Config; `CliWallet.create/load` obtain signers via
193
+ the same path instead of `fromMnemonic`/`new Wallet(privateKey)` (`CliWallet.ts:153-156`).
194
+
195
+ ## 7. Invariants
196
+
197
+ 1. One owner for "get a signer": `Utils.InitServerWallet`. No other site derives a wallet
198
+ from key material.
199
+ 2. The custodian never emits a signing key in Phase 2. `GET /wallet/:domain` returns
200
+ address/publicKey only.
201
+ 3. The rivet key-exchange is the only host↔authority auth mechanism.
202
+ 4. `Rivet` is the shared capability; `ethers.Signer` is its server realization; the client
203
+ `Wallet` hierarchy is adapted to it. No second signing abstraction.
204
+
205
+ ## 8. Open gaps (call out, don't assume)
206
+
207
+ - **TLS certs — not a blocker.** Certs persist as-is through the safe (`writeFile`/
208
+ `readFile` + `/file/.ssl/<domain>/…`), so the wiki's "certs live in the shared authority"
209
+ goal needs no special machinery: a pool member fetches its cert+key blob and self-
210
+ terminates TLS, NLB stays dumb L4. Certify/ACME (`@metric-im/administrate`) can keep
211
+ issuing and just write the result into the safe. A more elegant SSL flow (authority-
212
+ driven issuance/rotation) is possible later but is **not important now**.
213
+ - **Nonce/ordering.** Once one domain rivet signs for N hosts, transaction nonce management
214
+ must centralize (at the authority or a per-domain sequencer) or concurrent hosts collide.
215
+ - **Availability.** The authority becomes a hard dependency for any signing host. Needs a
216
+ bootstrap cache / degraded-mode policy decision.
217
+ - **rootz-v6 alignment.** This mirrors the identity-owned Secret pattern; align at the
218
+ Solidity/interface level only, per standing guidance — no shared library yet.
219
+ ```
package/index.mjs CHANGED
@@ -11,13 +11,12 @@ const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
12
 
13
13
  // Helper function to get or create domain configurations src/utils/Config.ts system
14
- function getDomainConfig(domain) {
15
- let domainConfig = Utils.GetDomainInfo(domain);
16
- if (!domainConfig?.wallet) {
17
- Utils.InitServerWallet(domain);
18
- domainConfig = Utils.GetDomainInfo(domain);
19
- }
20
- return domainConfig;
14
+ async function getDomainConfig(domain) {
15
+ // InitServerWallet warms the per-domain wallet cache (and creates+persists a
16
+ // wallet on first touch). Awaiting it here keeps the synchronous `get signer()`
17
+ // getter able to read the cache via Utils.GetServerWalletFor.
18
+ await Utils.InitServerWallet(domain);
19
+ return await Utils.GetDomainInfo(domain);
21
20
  }
22
21
 
23
22
  class EpisteryAttach {
@@ -36,7 +35,7 @@ class EpisteryAttach {
36
35
 
37
36
  async setDomain(domain) {
38
37
  this.domainName = domain;
39
- this.domain = getDomainConfig(domain);
38
+ this.domain = await getDomainConfig(domain);
40
39
  }
41
40
 
42
41
  /**
@@ -45,7 +44,9 @@ class EpisteryAttach {
45
44
  */
46
45
  get signer() {
47
46
  if (!this.domainName) return null;
48
- return Utils.InitServerWallet(this.domainName) || null;
47
+ // Synchronous read of the cache warmed by setDomain()→getDomainConfig()→
48
+ // InitServerWallet. Keeps `.signer` a sync getter despite the async config.
49
+ return Utils.GetServerWalletFor(this.domainName) || null;
49
50
  }
50
51
 
51
52
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epistery",
3
- "version": "2.0.4",
3
+ "version": "2.2.0",
4
4
  "description": "Epistery brings blockchain capabilities to mundane web tasks like engagement metrics, authentication and commerce of all sorts.",
5
5
  "author": "Rootz Corp.",
6
6
  "license": "MIT",
package/routes/auth.mjs CHANGED
@@ -25,7 +25,7 @@ export default function authRoutes(epistery) {
25
25
  .json({ status: "error", message: "Domain not found" });
26
26
  }
27
27
 
28
- epistery.config.setPath(domain);
28
+ await epistery.config.setPath(domain);
29
29
 
30
30
  if (epistery.config.data && epistery.config.data.verified) {
31
31
  return res
@@ -97,7 +97,7 @@ export default function authRoutes(epistery) {
97
97
  });
98
98
  }
99
99
 
100
- epistery.config.setPath(domain);
100
+ await epistery.config.setPath(domain);
101
101
 
102
102
  if (epistery.config.data && epistery.config.data.verified) {
103
103
  return res
@@ -125,7 +125,7 @@ export default function authRoutes(epistery) {
125
125
  epistery.config.data.challenge_requester_ip = req.ip;
126
126
  epistery.config.data.provider = providerConfig;
127
127
 
128
- epistery.config.save();
128
+ await epistery.config.save();
129
129
  console.log(
130
130
  `Domain claim initiated: ${domain} by ${normalizedClientAddress}`,
131
131
  );
@@ -149,7 +149,7 @@ export default function authRoutes(epistery) {
149
149
  .json({ status: "error", message: "Domain not found" });
150
150
  }
151
151
 
152
- epistery.config.setPath(domain);
152
+ await epistery.config.setPath(domain);
153
153
 
154
154
  if (!epistery.config.data.pending) {
155
155
  return res.status(400).json({
@@ -208,7 +208,7 @@ export default function authRoutes(epistery) {
208
208
  delete epistery.config.data.challenge_token;
209
209
  delete epistery.config.data.challenge_address;
210
210
  delete epistery.config.data.challenge_requester_ip;
211
- epistery.config.save();
211
+ await epistery.config.save();
212
212
 
213
213
  res.json({ status: "success", message: "Domain claimed successfully" });
214
214
  } catch (error) {
@@ -229,7 +229,7 @@ export default function authRoutes(epistery) {
229
229
  return res.json({ isAdmin: false });
230
230
  }
231
231
 
232
- epistery.config.setPath(domain);
232
+ await epistery.config.setPath(domain);
233
233
 
234
234
  if (
235
235
  !epistery.config.data ||
package/routes/domain.mjs CHANGED
@@ -24,7 +24,7 @@ export default function domainRoutes(epistery) {
24
24
 
25
25
  // Check if domain already exists
26
26
  const config = Utils.GetConfig();
27
- config.setPath(domain);
27
+ await config.setPath(domain);
28
28
 
29
29
  let domainConfig = config.data;
30
30
  if (!domainConfig.domain) domainConfig.domain = domain;
@@ -37,7 +37,7 @@ export default function domainRoutes(epistery) {
37
37
  };
38
38
 
39
39
  // Save domain config with custom provider (marked as pending)
40
- config.save();
40
+ await config.save();
41
41
 
42
42
  res.json({
43
43
  status: "success",
package/routes/fido.mjs CHANGED
@@ -64,8 +64,8 @@ export default function fidoRoutes(epistery) {
64
64
  return res.status(500).json({ error: "Domain not set" });
65
65
  }
66
66
 
67
- epistery.config.setPath(`/${domain}/fido`);
68
- epistery.config.writeFile(
67
+ await epistery.config.setPath(`/${domain}/fido`);
68
+ await epistery.config.writeFile(
69
69
  `${credentialId}.json`,
70
70
  JSON.stringify({
71
71
  credentialId,
@@ -97,11 +97,11 @@ export default function fidoRoutes(epistery) {
97
97
  return res.status(500).json({ error: "Domain not set" });
98
98
  }
99
99
 
100
- epistery.config.setPath(`/${domain}/fido`);
100
+ await epistery.config.setPath(`/${domain}/fido`);
101
101
 
102
102
  let buf;
103
103
  try {
104
- buf = epistery.config.readFile(`${credentialId}.json`);
104
+ buf = await epistery.config.readFile(`${credentialId}.json`);
105
105
  } catch (e) {
106
106
  return res.status(404).json({ error: "Blob not found" });
107
107
  }
@@ -82,9 +82,9 @@ export function registeredChains(): ChainConfig[] {
82
82
  *
83
83
  * Chains without a config override are returned unchanged.
84
84
  */
85
- export function configuredChains(): ChainConfig[] {
85
+ export async function configuredChains(): Promise<ChainConfig[]> {
86
86
  const config = new Config();
87
- const rootData = config.read('/');
87
+ const rootData = await config.read('/');
88
88
  return registeredChains().map(chain => {
89
89
  const id = String(chain.chainId);
90
90
  const privateRpc = rootData?.default?.rpc?.[id]?.privateRpc
@@ -101,9 +101,9 @@ export function configuredChains(): ChainConfig[] {
101
101
  * Checks `[default] defaultChainId`, then `[default.provider] chainId`,
102
102
  * falling back to Polygon mainnet (137).
103
103
  */
104
- export function defaultChainId(): string {
104
+ export async function defaultChainId(): Promise<string> {
105
105
  const config = new Config();
106
- const rootData = config.read('/');
106
+ const rootData = await config.read('/');
107
107
  return String(
108
108
  rootData?.default?.defaultChainId
109
109
  || rootData?.default?.provider?.chainId
package/src/epistery.ts CHANGED
@@ -43,7 +43,7 @@ export class Epistery {
43
43
  return clientWalletInfo;
44
44
  }
45
45
 
46
- public static getStatus(client: ClientWalletInfo, server: DomainConfig): EpisteryStatus {
46
+ public static async getStatus(client: ClientWalletInfo, server: DomainConfig): Promise<EpisteryStatus> {
47
47
  // Build nativeCurrency object from flat fields with sensible defaults
48
48
  let nativeCurrency = undefined;
49
49
  if (server?.provider?.nativeCurrencySymbol) {
@@ -56,7 +56,7 @@ export class Epistery {
56
56
 
57
57
  // Read IPFS config from root config
58
58
  const config = Utils.GetConfig();
59
- const rootConfig = config.read('/');
59
+ const rootConfig = await config.read('/');
60
60
  const ipfsConfig = rootConfig.ipfs;
61
61
 
62
62
  const status: EpisteryStatus = {
@@ -155,7 +155,7 @@ export class Epistery {
155
155
  ): Promise<any> {
156
156
  // RPC comes from ~/.epistery via Config (GetDomainInfo falls back to root
157
157
  // [provider]); never process.env — env is for deployment vars only.
158
- const domainInfo = Utils.GetDomainInfo(domain);
158
+ const domainInfo = await Utils.GetDomainInfo(domain);
159
159
  const rpcUrl = domainInfo?.provider?.rpc;
160
160
  if (!rpcUrl) {
161
161
  throw new Error(`No provider RPC configured in ~/.epistery for domain "${domain}"`);
@@ -307,7 +307,7 @@ export class Epistery {
307
307
  // RPC from ~/.epistery root config via Config — not process.env. A signed
308
308
  // tx already encodes its chainId; we just need the network's RPC, which
309
309
  // single-domain apps declare once at root [provider].
310
- const rootData = Utils.GetConfig().read('/');
310
+ const rootData = await Utils.GetConfig().read('/');
311
311
  const rpcUrl = rootData.provider?.rpc ?? rootData.default?.provider?.rpc;
312
312
  if (!rpcUrl) {
313
313
  throw new Error('No provider RPC configured in ~/.epistery (root [provider])');
@@ -67,32 +67,38 @@ export class CliWallet {
67
67
  /**
68
68
  * Get the default domain from config.ini [cli] section
69
69
  */
70
- static getDefaultDomain(): string {
70
+ static async getDefaultDomain(): Promise<string> {
71
71
  const config = new Config();
72
+ await config.setPath('/');
72
73
  return (config.data as any).cli?.default_domain || 'localhost';
73
74
  }
74
75
 
75
76
  /**
76
- * Set the default domain in config.ini [cli] section
77
+ * Set the default domain in config.ini [cli] section.
78
+ *
79
+ * Loads the root config first so the rest of it ([profile], [default.provider],
80
+ * …) is preserved on save. (Before the async migration, Config did not
81
+ * auto-load on construction, so this wrote {cli:…} over the whole root file.)
77
82
  */
78
- static setDefaultDomain(domain: string): void {
83
+ static async setDefaultDomain(domain: string): Promise<void> {
79
84
  const config = new Config();
85
+ await config.setPath('/');
80
86
  if (!(config.data as any).cli) {
81
87
  (config.data as any).cli = {};
82
88
  }
83
89
  (config.data as any).cli.default_domain = domain;
84
- config.save();
90
+ await config.save();
85
91
  }
86
92
 
87
93
  /**
88
94
  * Initialize a new domain with wallet
89
95
  * Creates ~/.epistery/{domain}/config.ini with new wallet
90
96
  */
91
- static initialize(domain: string, provider?: { name: string, chainId: number, rpc: string }): CliWallet {
97
+ static async initialize(domain: string, provider?: { name: string, chainId: number, rpc: string }): Promise<CliWallet> {
92
98
  const config = new Config();
93
99
 
94
100
  // Check if domain already exists
95
- config.setPath(domain);
101
+ await config.setPath(domain);
96
102
  if (config.data.wallet) {
97
103
  throw new Error(`Domain '${domain}' already initialized. Use load() to access it.`);
98
104
  }
@@ -101,7 +107,7 @@ export class CliWallet {
101
107
  const ethersWallet = ethers.Wallet.createRandom();
102
108
 
103
109
  // Get provider from root default, argument, or fallback default
104
- config.setPath('/');
110
+ await config.setPath('/');
105
111
  const providerConfig = provider || config.data.default?.provider || {
106
112
  chainId: 420420422,
107
113
  name: 'polkadot-hub-testnet',
@@ -109,7 +115,7 @@ export class CliWallet {
109
115
  };
110
116
 
111
117
  // Create domain config
112
- config.setPath(`/${domain}`);
118
+ await config.setPath(`/${domain}`);
113
119
  config.data = {
114
120
  domain: domain,
115
121
  wallet: {
@@ -120,7 +126,7 @@ export class CliWallet {
120
126
  },
121
127
  provider: providerConfig
122
128
  };
123
- config.save();
129
+ await config.save();
124
130
 
125
131
  console.log(`Initialized domain: ${domain}`);
126
132
  console.log(`Address: ${ethersWallet.address}`);
@@ -133,12 +139,11 @@ export class CliWallet {
133
139
  * Load domain wallet from config
134
140
  * Throws if domain doesn't exist - use initialize() first
135
141
  */
136
- static load(domain?: string): CliWallet {
142
+ static async load(domain?: string): Promise<CliWallet> {
137
143
  const config = new Config();
138
- const domainName = domain || CliWallet.getDefaultDomain();
144
+ const domainName = domain || await CliWallet.getDefaultDomain();
139
145
 
140
- config.setPath(`/${domainName}`);
141
- config.load();
146
+ await config.setPath(`/${domainName}`);
142
147
 
143
148
  if (!config.data.wallet) {
144
149
  throw new Error(