@ethernity-cloud/sdk-js 1.5.15 → 1.5.17

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.
@@ -1,761 +1,815 @@
1
- // Enclave State Registry (ESR) — in-enclave state API (Nodenithy).
2
- //
3
- // Gives payload code durable, encrypted state across tasks:
4
- //
5
- // const { StateRegistry } = require('./ecld_state');
6
- // const state = new StateRegistry();
7
- // await state.commit('my-key', (s) => ({ ...s, n: (s.n || 0) + 1 }));
8
- //
9
- // State is encrypted inside the enclave, stored as an IPFS object, and only a
10
- // POINTER (the CID) plus a version goes on-chain. The registry keeps one entry
11
- // per (enclave, key) with monotonic versions and optimistic concurrency.
12
- //
13
- // ## Why the enclave computes the CID itself
14
- //
15
- // The node is untrusted. If it told the enclave which CID to commit, a
16
- // malicious operator could pin the enclave's blob but return the CID of
17
- // DIFFERENT content; the enclave would sign that onto the chain, and clients
18
- // would fetch attacker-chosen state believing the enclave authored it.
19
- //
20
- // A CID is a hash of the content, so the enclave derives it from the bytes it
21
- // just encrypted (CIDv1/raw = base32(0x01 0x55 0x12 0x20 || sha256(content)))
22
- // and commits that. The node pins and verifies but never chooses. It also means
23
- // the enclave never waits on the node: it writes the blob, writes the CID,
24
- // commits, and moves on.
25
- //
26
- // ## Encryption
27
- //
28
- // State is encrypted with a key derived from the enclave identity under a
29
- // SECOND domain separator (distinct from the ESR wallet's), so the state key
30
- // cannot collide with the wallet key. State therefore inherits exactly the
31
- // identity's security: enclave-only on mainnet, reproducible on testnet — which
32
- // is the accepted testnet posture (functional testing, not secrecy).
33
- //
34
- // SECURITY: the state key and the wallet key never leave this module. Only the
35
- // address and the CID are ever emitted.
36
- const crypto = require('crypto');
37
- const { ethers } = require('ethers');
38
- const esr_wallet = require('./esr_wallet');
39
-
40
- const DOMAIN_SEP_STATE = 'ethernity-cloud/esr-encryption/v1';
41
-
42
- // AES-GCM parameters, pinned explicitly so the blob layout never depends on a
43
- // library default: nonce(12) || tag(16) || ciphertext.
44
- const NONCE_LEN = 12;
45
- const TAG_LEN = 16;
46
-
47
- let _identityPriv = null;
48
- let _swift = null;
49
- let _bucket = null;
50
- let _contractAddress = null;
51
- let _provider = null;
52
-
53
- // LOCAL TESTING ONLY (esr_local). In the real enclave these stay null: the node
54
- // applies commitFor on-chain and _contract() builds a real ethers.Contract. The
55
- // local ESR emulator overrides the "contract" with an in-memory object and
56
- // applies commits itself, so ecld-test runs state-using backends with no chain.
57
- let _contractOverride = null;
58
- let _localCommitApply = null;
59
-
60
- function setContractOverride(obj) { _contractOverride = obj; }
61
- function setLocalCommitApply(fn) { _localCommitApply = fn; }
62
-
63
- /**
64
- * Wire the registry to the enclave's identity, storage and chain access.
65
- * Called by securelock at task start; payload code never calls this.
66
- */
67
- function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider, caller = null }) {
68
- _identityPriv = identityPriv;
69
- _swift = swiftStreamService;
70
- _bucket = bucket;
71
- _contractAddress = contractAddress;
72
- _provider = provider;
73
- _esrLedger = [];
74
- _taskNonces = {};
75
- _taskLedger = {};
76
- _taskCaller = normAddr(caller);
77
- }
78
-
79
- // The authenticated task caller: the wallet that placed the DO request, read
80
- // from the PoX contract by the TRUSTEDZONE and forwarded over its signed
81
- // handoff (caller.securelock + .sig, verified by securelock before configure).
82
- // null when the trustedzone did not supply one -- anonymity is never a
83
- // privilege: null cannot claim ownership or touch owned state.
84
- let _taskCaller = null;
85
-
86
- function taskCaller() {
87
- return _taskCaller;
88
- }
89
-
90
- function normAddr(addr) {
91
- return typeof addr === 'string' && addr ? addr.toLowerCase() : null;
92
- }
93
-
94
- /**
95
- * State container + ACL. Stored blobs are either legacy (raw state --
96
- * "unowned") or an owned container:
97
- * { _ecld_state: 1, acl: {...}, data: <state> }
98
- * acl = { owner, read: [], write: [], public_read: false }. Enforcement
99
- * happens HERE, inside the enclave, against the trustedzone-attested caller.
100
- */
101
- const CONTAINER_MARK = '_ecld_state';
102
-
103
- class StatePermissionError extends Error {}
104
-
105
- // A commit's idempotency nonce was already used: the dApp supplied `nonce` on
106
- // commit() and it is not greater than the last accepted nonce for the key --
107
- // a duplicate (or out-of-order) submission. The state was NOT changed.
108
- class StateNonceError extends Error {}
109
-
110
- function unwrapStored(stored) {
111
- if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
112
- return [stored.acl || null, stored.data];
113
- }
114
- return [null, stored];
115
- }
116
-
117
- function wrapStored(acl, data, committedBy = null, nonce = null) {
118
- // For owned state, bind the authoring caller (committedBy) INTO the blob, so
119
- // it lands in the CID -- the field the securelock signs -- making the caller
120
- // cryptographically bound to the commit, not merely a re-stampable sidecar.
121
- // Unowned state keeps the bare shape (no ACL, no binding) -- unless a nonce
122
- // must be carried, which forces the container shape.
123
- //
124
- // `nonce` is a REPORTING COPY of the registry's stored idempotency nonce as
125
- // of this commit. The on-chain value (getNonce) is always the primary; the
126
- // blob copy exists so the signed CID cryptographically commits to the nonce
127
- // the enclave intended, and reads verify the two match (_readContainer).
128
- if (!acl && nonce == null) return data;
129
- const container = { [CONTAINER_MARK]: 1, acl: acl || null, data };
130
- if (committedBy) container.committedBy = committedBy;
131
- if (nonce != null) container.nonce = Math.trunc(nonce);
132
- return container;
133
- }
134
-
135
- /**
136
- * Post-payload: re-assert the trusted caller across the ESR ledger. Called by
137
- * the securelock after the payload returns, with the attested caller captured
138
- * before execution (outside the payload's reach). Overwrites callerUsed (and
139
- * re-fixes enclave to the real signer) in every owned entry and RE-STAGES the
140
- * ledger, so a forged callerUsed cannot reach the trustedzone. Returns true if
141
- * any entry's callerUsed differed from the trusted value (tamper detected).
142
- * Defence in depth -- the trustedzone and the CID-bound caller are the sound
143
- * layers; this runs in the same process the payload subverted.
144
- */
145
- async function restampLedgerCaller(trustedCaller) {
146
- const trusted = normAddr(trustedCaller);
147
- if (!_esrLedger.length) return false;
148
- let tampered = false;
149
- let signerAddr = null;
150
- try {
151
- signerAddr = await new StateRegistry()._signer().getAddress();
152
- } catch (e) { signerAddr = null; }
153
- for (const entry of _esrLedger) {
154
- if (!entry.owned) continue;
155
- if (normAddr(entry.callerUsed) !== trusted) tampered = true;
156
- entry.callerUsed = trusted || '';
157
- if (signerAddr) entry.enclave = signerAddr;
158
- }
159
- try {
160
- await _swift.putFileContent(
161
- _bucket, 'esr.authorizations.json', '',
162
- Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
163
- } catch (e) { /* best-effort restage; trustedzone still adjudicates */ }
164
- return tampered;
165
- }
166
-
167
- function aclMembers(acl, field) {
168
- return new Set((acl[field] || []).map(normAddr).filter(Boolean));
169
- }
170
-
171
- function canRead(acl) {
172
- if (!acl || acl.public_read) return true;
173
- const c = _taskCaller;
174
- if (!c) return false;
175
- return c === normAddr(acl.owner) || aclMembers(acl, 'read').has(c) || aclMembers(acl, 'write').has(c);
176
- }
177
-
178
- function canWrite(acl) {
179
- if (!acl) return true; // unowned: writable; claimed when a caller exists
180
- const c = _taskCaller;
181
- if (!c) return false;
182
- return c === normAddr(acl.owner) || aclMembers(acl, 'write').has(c);
183
- }
184
-
185
- function newAcl(owner) {
186
- return { owner, read: [], write: [], public_read: false };
187
- }
188
-
189
- // Task-scoped ledger of every state key this execution touched, recorded by
190
- // get()/commit(). ecldResult() snapshots it to attach fresh state to the task
191
- // result, so callers (and the runner's state cache) get current state in the
192
- // same result -- no separate read task needed. Reset per task in configure().
193
- let _taskLedger = {};
194
-
195
- function ledgerRecord(key, version, cid, state) {
196
- _taskLedger[key] = { key, version: Number(version), cid, state };
197
- }
198
-
199
- /**
200
- * The `esr` attachment for ecldResult: wallet + entries. Synchronous by
201
- * design (the securelock consumes results synchronously): entries come from
202
- * the in-memory ledger of keys this task already touched. To attach a key the
203
- * task did not touch, use the async esrFetch() helper, which awaits the reads
204
- * (populating the ledger) before building the result.
205
- */
206
- function ledgerSnapshot(includeState = true, keys = null) {
207
- const reg = new StateRegistry();
208
- const wanted = keys ? keys : Object.keys(_taskLedger);
209
- const entries = [];
210
- for (const k of wanted) {
211
- const e = _taskLedger[k];
212
- if (!e) continue;
213
- const entry = { ...e };
214
- if (!includeState) delete entry.state;
215
- entries.push(entry);
216
- }
217
- return { wallet: reg.walletAddress, entries };
218
- }
219
-
220
- function keccak256(buf) {
221
- return Buffer.from(ethers.utils.keccak256(buf).slice(2), 'hex');
222
- }
223
-
224
- /**
225
- * AES key for state, derived under its own domain separator. A separate
226
- * separator from the wallet's means the two keys are provably unrelated:
227
- * compromising one does not yield the other.
228
- */
229
- function stateKey() {
230
- if (!_identityPriv) {
231
- throw new Error('StateRegistry is not configured (no enclave identity)');
232
- }
233
- const material = Buffer.isBuffer(_identityPriv)
234
- ? _identityPriv
235
- : Buffer.from(String(_identityPriv), 'utf8');
236
- return keccak256(Buffer.concat([Buffer.from(DOMAIN_SEP_STATE, 'utf8'), material]));
237
- }
238
-
239
- /**
240
- * CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
241
- * Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
242
- * enclave commit a CID it derived itself rather than one a node handed it.
243
- */
244
- function cidv1Raw(content) {
245
- const digest = crypto.createHash('sha256').update(content).digest();
246
- const raw = Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]);
247
- return 'b' + base32Encode(raw).toLowerCase().replace(/=+$/, '');
248
- }
249
-
250
- // RFC4648 base32, no padding stripped here (the caller strips it).
251
- function base32Encode(buf) {
252
- const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
253
- let bits = 0;
254
- let value = 0;
255
- let out = '';
256
- for (const byte of buf) {
257
- value = (value << 8) | byte;
258
- bits += 8;
259
- while (bits >= 5) {
260
- out += A[(value >>> (bits - 5)) & 31];
261
- bits -= 5;
262
- }
263
- }
264
- if (bits > 0) out += A[(value << (5 - bits)) & 31];
265
- return out;
266
- }
267
-
268
- /**
269
- * True only for values shaped like an IPFS CID.
270
- *
271
- * The contract accepts any non-empty string as the pointer, so a buggy writer
272
- * can commit something that is not a CID (the live registry holds one 0x…
273
- * digest). That is a defect in the writer, not a supported format: such an
274
- * entry is rejected rather than fetched, because passing it on means an error
275
- * at best and a retry-loop at worst.
276
- */
277
- function looksLikeCID(value) {
278
- const cid = (value || '').trim();
279
- if (!cid || cid.startsWith('0x')) return false;
280
- if (cid.startsWith('Qm') && cid.length === 46) return true;
281
- if (cid.startsWith('b') && cid.length >= 46 && cid === cid.toLowerCase()) return true;
282
- return false;
283
- }
284
-
285
- function encrypt(plaintext) {
286
- const nonce = crypto.randomBytes(NONCE_LEN);
287
- const cipher = crypto.createCipheriv('aes-256-gcm', stateKey(), nonce, {
288
- authTagLength: TAG_LEN
289
- });
290
- const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
291
- return Buffer.concat([nonce, cipher.getAuthTag(), ct]);
292
- }
293
-
294
- function decrypt(blob) {
295
- if (blob.length < NONCE_LEN + TAG_LEN) {
296
- throw new Error('state blob is too short to be valid');
297
- }
298
- const nonce = blob.subarray(0, NONCE_LEN);
299
- const tag = blob.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN);
300
- const ct = blob.subarray(NONCE_LEN + TAG_LEN);
301
- const decipher = crypto.createDecipheriv('aes-256-gcm', stateKey(), nonce, {
302
- authTagLength: TAG_LEN
303
- });
304
- decipher.setAuthTag(tag);
305
- return Buffer.concat([decipher.update(ct), decipher.final()]);
306
- }
307
-
308
- /** The bytes32 the contract is keyed by: keccak256(key). */
309
- function keyHash(key) {
310
- return ethers.utils.id(key);
311
- }
312
-
313
- const ESR_ABI = [
314
- 'function commit(bytes32 key, string newCID, uint256 expectedVersion, uint256 nonce)',
315
- 'function commitFor(address enclave, bytes32 key, string newCID, uint256 expectedVersion, uint256 relayNonce, uint256 nonce, bytes signature)',
316
- 'function commitDigest(address enclave, bytes32 key, string newCID, uint256 expectedVersion, uint256 relayNonce, uint256 nonce) view returns (bytes32)',
317
- 'function relayNonce(address enclave) view returns (uint256)',
318
- 'function getState(address enclave, bytes32 key) view returns (string cid, uint256 version, uint64 updatedAt)',
319
- 'function getVersion(address enclave, bytes32 key) view returns (uint256)',
320
- 'function getNonce(address enclave, bytes32 key) view returns (uint256)',
321
- 'function exists(address enclave, bytes32 key) view returns (bool)'
322
- ];
323
-
324
- // Order-wide ledger of signed authorizations. ESR commits do NOT pay their own
325
- // gas (see contracts/esr/RELAY-DESIGN.md): the enclave only SIGNS each commit
326
- // (commitFor); the NODE relays it and pays, and does all gas accounting. The
327
- // securelock does no gas math -- nothing it could claim about cost is trusted.
328
- let _esrLedger = [];
329
-
330
- // Per-task memo of the last idempotency nonce ACCEPTED per key, so a
331
- // getNonce() right after a commit in the SAME task returns the fresh value
332
- // even before the node's relay lands on-chain.
333
- let _taskNonces = {};
334
-
335
- class StateRegistry {
336
- constructor() {
337
- if (!_contractAddress) {
338
- throw new Error(
339
- 'ESR is not enabled for this enclave. Enable it (ECLD_ESR_ENABLE) and ' +
340
- 'rebuild, so the registry address is baked into the image.'
341
- );
342
- }
343
- this._priv = null;
344
- }
345
-
346
- /** The enclave's on-chain address — safe to publish and to fund. */
347
- get walletAddress() {
348
- return esr_wallet.deriveWalletAddress(_identityPriv);
349
- }
350
-
351
- /**
352
- * Alias of walletAddress — the ENCLAVE's own on-chain address (its identity,
353
- * and the namespace all its state keys live under). Preferred name in new
354
- * code; walletAddress remains for compatibility.
355
- */
356
- get enclaveAddress() {
357
- return this.walletAddress;
358
- }
359
-
360
- _signer() {
361
- // Reuse esr_wallet's derivation rather than repeating it here: two copies
362
- // would eventually disagree, and the address a client funds would stop
363
- // matching the key that signs. The key stays inside this method.
364
- if (this._priv === null) {
365
- this._priv = esr_wallet._deriveWalletPrivateKey(_identityPriv);
366
- }
367
- return new ethers.Wallet(this._priv, _provider);
368
- }
369
-
370
- _contract(signerOrProvider) {
371
- // Local emulator: use the in-memory contract object instead of a real one.
372
- if (_contractOverride) return _contractOverride;
373
- return new ethers.Contract(_contractAddress, ESR_ABI, signerOrProvider || _provider);
374
- }
375
-
376
- /** Current on-chain version for `key`; 0 when never committed. */
377
- async getVersion(key) {
378
- const v = await this._contract().getVersion(this.walletAddress, keyHash(key));
379
- return Number(v);
380
- }
381
-
382
- /**
383
- * Decrypted state for `key`, or `fallback` ({} by default) when absent.
384
- *
385
- * Throws on a pointer that is not a CID: treating a broken pointer as "no
386
- * state" would let the next commit overwrite data that is still recoverable.
387
- */
388
- async get(key, fallback = {}) {
389
- const [acl, data, version, cid] = await this._readContainer(key, fallback);
390
- if (!canRead(acl)) {
391
- throw new StatePermissionError(
392
- `caller ${_taskCaller || '<anonymous>'} has no read permission on ` +
393
- `state key '${key}' (owner: ${acl.owner})`
394
- );
395
- }
396
- ledgerRecord(key, version || 0, cid, data);
397
- return data;
398
- }
399
-
400
- /** [acl, data, version, cid] for `key`; NO permission check here. */
401
- async _readContainer(key, fallback = {}) {
402
- const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
403
- if (!version || !cid) {
404
- return [null, fallback, 0, null];
405
- }
406
- if (!looksLikeCID(cid)) {
407
- throw new Error(
408
- `ESR entry for '${key}' holds a pointer that is not a CID (${cid.slice(0, 32)}…). ` +
409
- 'The committing code is writing a non-CID value.'
410
- );
411
- }
412
- const blob = await this._fetch(key, cid);
413
- const stored = JSON.parse(decrypt(blob).toString('utf8'));
414
- const [acl, data] = unwrapStored(stored);
415
- if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1
416
- && stored.nonce != null) {
417
- // The blob carries a REPORTING COPY of the idempotency nonce; the chain
418
- // is primary. Both were written by the same commit (the CID the chain
419
- // points at IS this blob), so they must agree -- a mismatch means the
420
- // registry entry and the state object have been tampered with or torn
421
- // apart. Compare against the RAW chain value (not the task memo): the
422
- // blob we fetched is the chain's cid.
423
- const chainNonce = Number(await this._contract().getNonce(this.walletAddress, keyHash(key)));
424
- const blobNonce = Math.trunc(Number(stored.nonce));
425
- if (blobNonce !== chainNonce) {
426
- throw new Error(
427
- `ESR nonce mismatch for '${key}': the state object reports nonce ` +
428
- `${blobNonce} but the registry records ${chainNonce} -- refusing ` +
429
- 'to use this state'
430
- );
431
- }
432
- }
433
- return [acl, data, Number(version), cid];
434
- }
435
-
436
- /**
437
- * The last accepted idempotency nonce for `key` (0 if none was ever used).
438
- * A dApp that wants duplicate suppression reads this and passes
439
- * getNonce(key) + 1 to commit(..., { nonce }) -- the contract accepts
440
- * EXACTLY the next value in sequence, nothing else.
441
- *
442
- * The nonce is PUBLIC data: the registry records it on-chain next to the
443
- * version, and anyone can read it with a free eth_call (getNonce) -- so no
444
- * read-ACL applies here, and web3 clients see the same value via the
445
- * runner's esrNonce.
446
- *
447
- * Reads the chain (authoritative, post-relay), merged with this task's
448
- * own accepted commits so `commit(..., { nonce: N }); getNonce()` returns N
449
- * even before the node's relay lands.
450
- */
451
- async getNonce(key) {
452
- const chain = Number(await this._contract().getNonce(this.walletAddress, keyHash(key)));
453
- return Math.max(chain || 0, Number(_taskNonces[key] || 0));
454
- }
455
-
456
- /**
457
- * Read-modify-write `key` under optimistic concurrency.
458
- *
459
- * `mutate` receives the current state and returns the new state. If another
460
- * commit lands in between, the contract rejects ours and we re-read and
461
- * retry, so concurrent tasks cannot silently lose updates.
462
- */
463
- async commit(key, mutate, opts = 3) {
464
- // Back-compat: the third arg used to be `attempts` (a number); it may now
465
- // also be { attempts, nonce }. `nonce` is an idempotency guard the dApp
466
- // controls: it must be EXACTLY the last accepted nonce for the key + 1
467
- // (see getNonce; the sequence is 1, 2, 3, ... with no gaps and no
468
- // reuse). Anything else throws StateNonceError and the state is NOT
469
- // changed.
470
- const { attempts = 3, nonce = null } =
471
- typeof opts === 'number' ? { attempts: opts } : (opts || {});
472
- const transform = async (acl, data) => {
473
- if (!canWrite(acl)) {
474
- throw new StatePermissionError(
475
- `caller ${_taskCaller || '<anonymous>'} has no write permission on ` +
476
- `state key '${key}' (owner: ${acl.owner})`
477
- );
478
- }
479
- let nextAcl = acl;
480
- if (!nextAcl && _taskCaller) nextAcl = newAcl(_taskCaller); // first-writer-owns
481
- return [nextAcl, await mutate(data)];
482
- };
483
- const [, newData] = await this._commitTransform(key, transform, attempts, nonce);
484
- return newData;
485
- }
486
-
487
- /**
488
- * Optimistic-concurrency commit of transform(acl, data, version) ->
489
- * [newAcl, newData]. The stored blob is the wrapped container (or the bare
490
- * data while unowned). On a version race we re-read and retry, so
491
- * concurrent tasks cannot silently lose updates.
492
- */
493
- async _commitTransform(key, transform, attempts = 3, nonce = null) {
494
- let lastError = null;
495
- for (let attempt = 0; attempt < attempts; attempt += 1) {
496
- // eslint-disable-next-line no-await-in-loop
497
- const [acl, data, currentVersion] = await this._readContainer(key);
498
- // The nonce check re-runs on every retry against the freshest visible
499
- // nonce (chain merged with this task's own accepted commits), so a
500
- // duplicate fails in-enclave with task code 36 instead of as a failed
501
- // relay. The contract re-enforces the same rule on-chain either way.
502
- if (nonce != null) {
503
- const n = Math.trunc(nonce);
504
- // eslint-disable-next-line no-await-in-loop
505
- const storedNonce = await this.getNonce(key);
506
- if (n !== storedNonce + 1) {
507
- throw new StateNonceError(
508
- `nonce ${n} is out of sequence for state key '${key}' (last accepted: ` +
509
- `${storedNonce}, expected: ${storedNonce + 1}); commit suppressed, ` +
510
- 'state unchanged'
511
- );
512
- }
513
- }
514
- // eslint-disable-next-line no-await-in-loop
515
- const [nextAcl, newData] = await transform(acl, data, currentVersion);
516
- // The blob carries a REPORTING COPY of what the registry will store for
517
- // this key after this commit: the supplied nonce, or the preserved
518
- // current value for a nonce-less commit. Chain stays primary; reads
519
- // verify the two match.
520
- // eslint-disable-next-line no-await-in-loop
521
- const finalNonce = nonce != null ? Math.trunc(nonce) : ((await this.getNonce(key)) || null);
522
- // Bind the authoring caller into the blob (hence the CID/signature) for
523
- // owned state.
524
- const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null, finalNonce);
525
-
526
- const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
527
- // Compute the CID from OUR bytes — never accept one from the node.
528
- const cid = cidv1Raw(blob);
529
-
530
- // Hand the blob and the CID to the node for pinning. It verifies the CID
531
- // matches; it does not get to choose it. Fire-and-forget.
532
- // eslint-disable-next-line no-await-in-loop
533
- await this._publish(key, blob, cid);
534
-
535
- try {
536
- // On-chain, 0 means "no guard: preserve the stored nonce".
537
- // eslint-disable-next-line no-await-in-loop
538
- await this._sendCommit(keyHash(key), cid, currentVersion,
539
- nextAcl ? normAddr(nextAcl.owner) : null,
540
- nonce != null ? Math.trunc(nonce) : 0);
541
- // Record the POST-commit values (the DATA -- the ACL never leaves in
542
- // results): this is what the chain shows once the relay lands.
543
- ledgerRecord(key, currentVersion + 1, cid, newData);
544
- if (nonce != null) _taskNonces[key] = Math.trunc(nonce);
545
- return [nextAcl, newData];
546
- } catch (e) {
547
- const msg = String(e && e.message ? e.message : e);
548
- if (/VersionMismatch/i.test(msg) || /version/i.test(msg)) {
549
- lastError = e;
550
- // eslint-disable-next-line no-continue
551
- continue;
552
- }
553
- throw e;
554
- }
555
- }
556
- throw new Error(`ESR commit failed after ${attempts} attempts: ${lastError}`);
557
- }
558
-
559
- async _publish(key, blob, cid) {
560
- await _swift.putFileContent(_bucket, `state.${key}.enc`, '', blob);
561
- await _swift.putFileContent(_bucket, `state.${key}.cid`, '', cid);
562
- }
563
-
564
- /**
565
- * Read a state object back, verifying it against the on-chain CID.
566
- *
567
- * The enclave cannot reach IPFS (the Kubo API binds loopback on the host), so
568
- * the node stages state objects in the bucket. That makes the node the
569
- * delivery path but not a trusted one: content is checked against the CID the
570
- * chain records, so a substituted blob is rejected rather than decrypted.
571
- */
572
- async _fetch(key, cid) {
573
- for (const name of [`state.${key}.enc`, `state.${cid}.enc`]) {
574
- // eslint-disable-next-line no-await-in-loop
575
- const data = await _swift.getFileContentBytes(_bucket, name).catch(() => null);
576
- if (data && data.length) {
577
- const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
578
- if (cidv1Raw(buf) !== cid) {
579
- throw new Error(
580
- `State object ${name} does not match the committed CID ${cid} — refusing to use it`
581
- );
582
- }
583
- return buf;
584
- }
585
- }
586
- throw new Error(`Could not read state object for '${key}' (${cid})`);
587
- }
588
-
589
- async _sendCommit(keyHashHex, cid, expectedVersion, ownerAddr = null, nonce = 0) {
590
- // The enclave never pays gas and does NO gas math: it SIGNS the commit
591
- // (commitFor) and stages the signed authorization for the node, which
592
- // relays it and pays. The trustedzone independently re-prices the whole
593
- // ledger and adjudicates. Nothing the securelock could claim about cost is
594
- // trusted, so it claims nothing.
595
- const signer = this._signer();
596
- const enclave = await signer.getAddress();
597
- const contract = this._contract();
598
-
599
- const relayNonce = await contract.relayNonce(enclave);
600
- // The idempotency nonce is signature-bound: it is part of the digest the
601
- // enclave signs, so the relaying node cannot alter or strip it.
602
- const digest = await contract.commitDigest(
603
- enclave, keyHashHex, cid, expectedVersion, relayNonce, Math.trunc(nonce || 0));
604
- // Sign the raw 32-byte digest as an eth_sign message (matches the
605
- // contract's "\x19Ethereum Signed Message:\n32" recovery).
606
- const signature = await signer.signMessage(ethers.utils.arrayify(digest));
607
-
608
- const auth = {
609
- enclave,
610
- keyHash: keyHashHex,
611
- cid,
612
- expectedVersion: Number(expectedVersion),
613
- relayNonce: Number(relayNonce),
614
- // PUBLIC idempotency nonce (0 = no guard): recorded on-chain by
615
- // commitFor, enforced strictly increasing per (enclave, key).
616
- nonce: Math.trunc(nonce || 0),
617
- signature,
618
- // IMPERSONATION GUARD: the trustedzone requires callerUsed to equal the
619
- // DO owner it reads from the PoX contract, so a payload that forged the
620
- // in-enclave caller to impersonate another user is rejected outside the
621
- // payload's process. It does NOT verify the caller was a permitted
622
- // writer (it cannot read the encrypted ACL). Unowned commits carry no
623
- // constraint.
624
- callerUsed: _taskCaller || '',
625
- owned: Boolean(ownerAddr)
626
- };
627
-
628
- _esrLedger.push(auth);
629
- await _swift.putFileContent(
630
- _bucket, 'esr.authorizations.json', '',
631
- Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
632
- await _swift.putFileContent(
633
- _bucket, `esr.commit.${Number(relayNonce)}.json`, '',
634
- Buffer.from(JSON.stringify(auth), 'utf8'));
635
-
636
- // LOCAL TESTING: with no node to relay commitFor, apply the commit to the
637
- // in-memory registry now so getState/getVersion reflect it. No-op in the
638
- // real enclave (hook is null) -- there the node applies it on-chain.
639
- if (_localCommitApply) {
640
- await _localCommitApply(enclave, keyHashHex, cid, Number(expectedVersion),
641
- Math.trunc(nonce || 0));
642
- }
643
-
644
- return { relayed: true, relayNonce: Number(relayNonce) };
645
- }
646
- }
647
-
648
- /** Owner-only ACL mutation; an unowned key is claimed first (owner = caller). */
649
- async function ownerOp(key, mutator) {
650
- if (!_taskCaller) {
651
- throw new StatePermissionError(
652
- 'state management requires an authenticated caller (the trustedzone supplied none)'
653
- );
654
- }
655
- const reg = new StateRegistry();
656
- const transform = async (acl, data) => {
657
- let nextAcl = acl || newAcl(_taskCaller);
658
- if (normAddr(nextAcl.owner) !== _taskCaller) {
659
- throw new StatePermissionError(
660
- `caller ${_taskCaller} is not the owner of state key '${key}' (owner: ${nextAcl.owner})`
661
- );
662
- }
663
- nextAcl = mutator({ ...nextAcl });
664
- return [nextAcl, data];
665
- };
666
- const [acl] = await reg._commitTransform(key, transform);
667
- return { ...acl };
668
- }
669
-
670
- function assertLevel(level) {
671
- if (level !== 'read' && level !== 'write') throw new Error("level must be 'read' or 'write'");
672
- }
673
-
674
- async function esrGrant(key, address, level = 'read') {
675
- assertLevel(level);
676
- const addr = normAddr(address);
677
- if (!addr) throw new Error('a grantee address is required');
678
- return ownerOp(key, (acl) => {
679
- const members = new Set((acl[level] || []).map(normAddr).filter(Boolean));
680
- members.add(addr);
681
- acl[level] = [...members].sort();
682
- return acl;
683
- });
684
- }
685
-
686
- async function esrRevoke(key, address, level = 'read') {
687
- assertLevel(level);
688
- const addr = normAddr(address);
689
- return ownerOp(key, (acl) => {
690
- acl[level] = (acl[level] || []).map(normAddr).filter((a) => a && a !== addr).sort();
691
- return acl;
692
- });
693
- }
694
-
695
- async function esrSetPublicRead(key, enabled = true) {
696
- return ownerOp(key, (acl) => {
697
- acl.public_read = Boolean(enabled);
698
- return acl;
699
- });
700
- }
701
-
702
- async function esrTransfer(key, newOwner) {
703
- const addr = normAddr(newOwner);
704
- if (!addr) throw new Error('a new owner address is required');
705
- return ownerOp(key, (acl) => {
706
- acl.owner = addr;
707
- return acl;
708
- });
709
- }
710
-
711
- /** Owner address of `key`, or null while unowned. Exposes nothing else. */
712
- async function esrOwner(key) {
713
- const [acl] = await new StateRegistry()._readContainer(key);
714
- return acl ? acl.owner : null;
715
- }
716
-
717
- /** Owner-only: the full ACL of `key`. */
718
- async function esrAcl(key) {
719
- const [acl] = await new StateRegistry()._readContainer(key);
720
- if (!acl) return null;
721
- if (!_taskCaller || normAddr(acl.owner) !== _taskCaller) {
722
- throw new StatePermissionError(
723
- `caller ${_taskCaller || '<anonymous>'} is not the owner of state key '${key}'`
724
- );
725
- }
726
- return { ...acl };
727
- }
728
-
729
- function setTaskCaller(caller) { _taskCaller = normAddr(caller); }
730
-
731
- /**
732
- * The last accepted idempotency nonce for `key` (0 if none). See
733
- * StateRegistry.getNonce / commit(key, mutate, { nonce }).
734
- */
735
- async function esrNonce(key) {
736
- return new StateRegistry().getNonce(key);
737
- }
738
-
739
- module.exports = {
740
- StateRegistry,
741
- configure,
742
- cidv1Raw,
743
- looksLikeCID,
744
- ledgerSnapshot,
745
- taskCaller,
746
- setTaskCaller,
747
- restampLedgerCaller,
748
- StatePermissionError,
749
- StateNonceError,
750
- esrGrant,
751
- esrRevoke,
752
- esrSetPublicRead,
753
- esrTransfer,
754
- esrOwner,
755
- esrAcl,
756
- esrNonce,
757
- // local-testing hooks (esr_local only)
758
- setContractOverride,
759
- setLocalCommitApply,
760
- normAddr
761
- };
1
+ // Enclave State Registry (ESR) — in-enclave state API (Nodenithy).
2
+ //
3
+ // Gives payload code durable, encrypted state across tasks:
4
+ //
5
+ // const { StateRegistry } = require('./ecld_state');
6
+ // const state = new StateRegistry();
7
+ // await state.commit('my-key', (s) => ({ ...s, n: (s.n || 0) + 1 }));
8
+ //
9
+ // State is encrypted inside the enclave, stored as an IPFS object, and only a
10
+ // POINTER (the CID) plus a version goes on-chain. The registry keeps one entry
11
+ // per (enclave, key) with monotonic versions and optimistic concurrency.
12
+ //
13
+ // ## Why the enclave computes the CID itself
14
+ //
15
+ // The node is untrusted. If it told the enclave which CID to commit, a
16
+ // malicious operator could pin the enclave's blob but return the CID of
17
+ // DIFFERENT content; the enclave would sign that onto the chain, and clients
18
+ // would fetch attacker-chosen state believing the enclave authored it.
19
+ //
20
+ // A CID is a hash of the content, so the enclave derives it from the bytes it
21
+ // just encrypted (CIDv1/raw = base32(0x01 0x55 0x12 0x20 || sha256(content)))
22
+ // and commits that. The node pins and verifies but never chooses. It also means
23
+ // the enclave never waits on the node: it writes the blob, writes the CID,
24
+ // commits, and moves on.
25
+ //
26
+ // ## Encryption
27
+ //
28
+ // State is encrypted with a key derived from the enclave identity under a
29
+ // SECOND domain separator (distinct from the ESR wallet's), so the state key
30
+ // cannot collide with the wallet key. State therefore inherits exactly the
31
+ // identity's security: enclave-only on mainnet, reproducible on testnet — which
32
+ // is the accepted testnet posture (functional testing, not secrecy).
33
+ //
34
+ // SECURITY: the state key and the wallet key never leave this module. Only the
35
+ // address and the CID are ever emitted.
36
+ const crypto = require('crypto');
37
+ const { ethers } = require('ethers');
38
+ const esr_wallet = require('./esr_wallet');
39
+
40
+ const DOMAIN_SEP_STATE = 'ethernity-cloud/esr-encryption/v1';
41
+
42
+ // AES-GCM parameters, pinned explicitly so the blob layout never depends on a
43
+ // library default: nonce(12) || tag(16) || ciphertext.
44
+ const NONCE_LEN = 12;
45
+ const TAG_LEN = 16;
46
+
47
+ let _identityPriv = null;
48
+ let _swift = null;
49
+ let _bucket = null;
50
+ let _contractAddress = null;
51
+ let _provider = null;
52
+
53
+ // LOCAL TESTING ONLY (esr_local). In the real enclave these stay null: the node
54
+ // applies commitFor on-chain and _contract() builds a real ethers.Contract. The
55
+ // local ESR emulator overrides the "contract" with an in-memory object and
56
+ // applies commits itself, so ecld-test runs state-using backends with no chain.
57
+ let _contractOverride = null;
58
+ let _localCommitApply = null;
59
+
60
+ function setContractOverride(obj) { _contractOverride = obj; }
61
+ function setLocalCommitApply(fn) { _localCommitApply = fn; }
62
+
63
+ /**
64
+ * Wire the registry to the enclave's identity, storage and chain access.
65
+ * Called by securelock at task start; payload code never calls this.
66
+ */
67
+ function configure({ identityPriv, swiftStreamService, bucket, contractAddress, provider, caller = null }) {
68
+ _identityPriv = identityPriv;
69
+ _swift = swiftStreamService;
70
+ _bucket = bucket;
71
+ _contractAddress = contractAddress;
72
+ _provider = provider;
73
+ _esrLedger = [];
74
+ _taskNonces = {};
75
+ _taskRelayNonces = {};
76
+ _taskLedger = {};
77
+ _taskCaller = normAddr(caller);
78
+ }
79
+
80
+ // The authenticated task caller: the wallet that placed the DO request, read
81
+ // from the PoX contract by the TRUSTEDZONE and forwarded over its signed
82
+ // handoff (caller.securelock + .sig, verified by securelock before configure).
83
+ // null when the trustedzone did not supply one -- anonymity is never a
84
+ // privilege: null cannot claim ownership or touch owned state.
85
+ let _taskCaller = null;
86
+
87
+ function taskCaller() {
88
+ return _taskCaller;
89
+ }
90
+
91
+ function normAddr(addr) {
92
+ return typeof addr === 'string' && addr ? addr.toLowerCase() : null;
93
+ }
94
+
95
+ /**
96
+ * State container + ACL. Stored blobs are either legacy (raw state --
97
+ * "unowned") or an owned container:
98
+ * { _ecld_state: 1, acl: {...}, data: <state> }
99
+ * acl = { owner, read: [], write: [], public_read: false }. Enforcement
100
+ * happens HERE, inside the enclave, against the trustedzone-attested caller.
101
+ */
102
+ const CONTAINER_MARK = '_ecld_state';
103
+
104
+ class StatePermissionError extends Error {}
105
+
106
+ // A commit's idempotency nonce was already used: the dApp supplied `nonce` on
107
+ // commit() and it is not greater than the last accepted nonce for the key --
108
+ // a duplicate (or out-of-order) submission. The state was NOT changed.
109
+ class StateNonceError extends Error {}
110
+
111
+ // Per-run cap on state commits: bounds the number of relayed transactions the
112
+ // node must track/pay and the size of the adjudicated ledger bound into the
113
+ // result transaction. 256 so the on-chain count fits ONE BYTE (bias-1:
114
+ // 0x00 = 1 commit ... 0xFF = 256; an all-zero root means no commits at all).
115
+ // The over-limit commit is NOT applied; earlier commits stand. Surfaces as
116
+ // task code 38 (ESR_COMMIT_LIMIT_EXCEEDED).
117
+ const MAX_COMMITS_PER_TASK = 256;
118
+
119
+ class StateLimitError extends Error {}
120
+
121
+ function unwrapStored(stored) {
122
+ if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
123
+ return [stored.acl || null, stored.data];
124
+ }
125
+ return [null, stored];
126
+ }
127
+
128
+ function wrapStored(acl, data, committedBy = null, nonce = null) {
129
+ // For owned state, bind the authoring caller (committedBy) INTO the blob, so
130
+ // it lands in the CID -- the field the securelock signs -- making the caller
131
+ // cryptographically bound to the commit, not merely a re-stampable sidecar.
132
+ // Unowned state keeps the bare shape (no ACL, no binding) -- unless a nonce
133
+ // must be carried, which forces the container shape.
134
+ //
135
+ // `nonce` is a REPORTING COPY of the registry's stored idempotency nonce as
136
+ // of this commit. The on-chain value (getNonce) is always the primary; the
137
+ // blob copy exists so the signed CID cryptographically commits to the nonce
138
+ // the enclave intended, and reads verify the two match (_readContainer).
139
+ if (!acl && nonce == null) return data;
140
+ const container = { [CONTAINER_MARK]: 1, acl: acl || null, data };
141
+ if (committedBy) container.committedBy = committedBy;
142
+ if (nonce != null) container.nonce = Math.trunc(nonce);
143
+ return container;
144
+ }
145
+
146
+ /**
147
+ * Post-payload: re-assert the trusted caller across the ESR ledger. Called by
148
+ * the securelock after the payload returns, with the attested caller captured
149
+ * before execution (outside the payload's reach). Overwrites callerUsed (and
150
+ * re-fixes enclave to the real signer) in every owned entry and RE-STAGES the
151
+ * ledger, so a forged callerUsed cannot reach the trustedzone. Returns true if
152
+ * any entry's callerUsed differed from the trusted value (tamper detected).
153
+ * Defence in depth -- the trustedzone and the CID-bound caller are the sound
154
+ * layers; this runs in the same process the payload subverted.
155
+ */
156
+ async function restampLedgerCaller(trustedCaller) {
157
+ const trusted = normAddr(trustedCaller);
158
+ if (!_esrLedger.length) return false;
159
+ let tampered = false;
160
+ let signerAddr = null;
161
+ try {
162
+ signerAddr = await new StateRegistry()._signer().getAddress();
163
+ } catch (e) { signerAddr = null; }
164
+ for (const entry of _esrLedger) {
165
+ if (!entry.owned) continue;
166
+ if (normAddr(entry.callerUsed) !== trusted) tampered = true;
167
+ entry.callerUsed = trusted || '';
168
+ if (signerAddr) entry.enclave = signerAddr;
169
+ }
170
+ try {
171
+ await _swift.putFileContent(
172
+ _bucket, 'esr.authorizations.json', '',
173
+ Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
174
+ } catch (e) { /* best-effort restage; trustedzone still adjudicates */ }
175
+ return tampered;
176
+ }
177
+
178
+ function aclMembers(acl, field) {
179
+ return new Set((acl[field] || []).map(normAddr).filter(Boolean));
180
+ }
181
+
182
+ function canRead(acl) {
183
+ if (!acl || acl.public_read) return true;
184
+ const c = _taskCaller;
185
+ if (!c) return false;
186
+ return c === normAddr(acl.owner) || aclMembers(acl, 'read').has(c) || aclMembers(acl, 'write').has(c);
187
+ }
188
+
189
+ function canWrite(acl) {
190
+ if (!acl) return true; // unowned: writable; claimed when a caller exists
191
+ const c = _taskCaller;
192
+ if (!c) return false;
193
+ return c === normAddr(acl.owner) || aclMembers(acl, 'write').has(c);
194
+ }
195
+
196
+ function newAcl(owner) {
197
+ return { owner, read: [], write: [], public_read: false };
198
+ }
199
+
200
+ // Task-scoped ledger of every state key this execution touched, recorded by
201
+ // get()/commit(). ecldResult() snapshots it to attach fresh state to the task
202
+ // result, so callers (and the runner's state cache) get current state in the
203
+ // same result -- no separate read task needed. Reset per task in configure().
204
+ let _taskLedger = {};
205
+
206
+ function ledgerRecord(key, version, cid, state) {
207
+ _taskLedger[key] = { key, version: Number(version), cid, state };
208
+ }
209
+
210
+ /**
211
+ * The `esr` attachment for ecldResult: wallet + entries. Synchronous by
212
+ * design (the securelock consumes results synchronously): entries come from
213
+ * the in-memory ledger of keys this task already touched. To attach a key the
214
+ * task did not touch, use the async esrFetch() helper, which awaits the reads
215
+ * (populating the ledger) before building the result.
216
+ */
217
+ function ledgerSnapshot(includeState = true, keys = null) {
218
+ const reg = new StateRegistry();
219
+ const wanted = keys ? keys : Object.keys(_taskLedger);
220
+ const entries = [];
221
+ for (const k of wanted) {
222
+ const e = _taskLedger[k];
223
+ if (!e) continue;
224
+ const entry = { ...e };
225
+ if (!includeState) delete entry.state;
226
+ entries.push(entry);
227
+ }
228
+ return { wallet: reg.walletAddress, entries };
229
+ }
230
+
231
+ function keccak256(buf) {
232
+ return Buffer.from(ethers.utils.keccak256(buf).slice(2), 'hex');
233
+ }
234
+
235
+ /**
236
+ * AES key for state, derived under its own domain separator. A separate
237
+ * separator from the wallet's means the two keys are provably unrelated:
238
+ * compromising one does not yield the other.
239
+ */
240
+ function stateKey() {
241
+ if (!_identityPriv) {
242
+ throw new Error('StateRegistry is not configured (no enclave identity)');
243
+ }
244
+ const material = Buffer.isBuffer(_identityPriv)
245
+ ? _identityPriv
246
+ : Buffer.from(String(_identityPriv), 'utf8');
247
+ return keccak256(Buffer.concat([Buffer.from(DOMAIN_SEP_STATE, 'utf8'), material]));
248
+ }
249
+
250
+ /**
251
+ * CIDv1/raw/sha2-256 for `content`, computed without touching IPFS.
252
+ * Matches `ipfs add --cid-version=1 --raw-leaves`, which is what lets the
253
+ * enclave commit a CID it derived itself rather than one a node handed it.
254
+ */
255
+ function cidv1Raw(content) {
256
+ const digest = crypto.createHash('sha256').update(content).digest();
257
+ const raw = Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]);
258
+ return 'b' + base32Encode(raw).toLowerCase().replace(/=+$/, '');
259
+ }
260
+
261
+ // RFC4648 base32, no padding stripped here (the caller strips it).
262
+ function base32Encode(buf) {
263
+ const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
264
+ let bits = 0;
265
+ let value = 0;
266
+ let out = '';
267
+ for (const byte of buf) {
268
+ value = (value << 8) | byte;
269
+ bits += 8;
270
+ while (bits >= 5) {
271
+ out += A[(value >>> (bits - 5)) & 31];
272
+ bits -= 5;
273
+ }
274
+ }
275
+ if (bits > 0) out += A[(value << (5 - bits)) & 31];
276
+ return out;
277
+ }
278
+
279
+ /**
280
+ * True only for values shaped like an IPFS CID.
281
+ *
282
+ * The contract accepts any non-empty string as the pointer, so a buggy writer
283
+ * can commit something that is not a CID (the live registry holds one 0x…
284
+ * digest). That is a defect in the writer, not a supported format: such an
285
+ * entry is rejected rather than fetched, because passing it on means an error
286
+ * at best and a retry-loop at worst.
287
+ */
288
+ function looksLikeCID(value) {
289
+ const cid = (value || '').trim();
290
+ if (!cid || cid.startsWith('0x')) return false;
291
+ if (cid.startsWith('Qm') && cid.length === 46) return true;
292
+ if (cid.startsWith('b') && cid.length >= 46 && cid === cid.toLowerCase()) return true;
293
+ return false;
294
+ }
295
+
296
+ function encrypt(plaintext) {
297
+ const nonce = crypto.randomBytes(NONCE_LEN);
298
+ const cipher = crypto.createCipheriv('aes-256-gcm', stateKey(), nonce, {
299
+ authTagLength: TAG_LEN
300
+ });
301
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
302
+ return Buffer.concat([nonce, cipher.getAuthTag(), ct]);
303
+ }
304
+
305
+ function decrypt(blob) {
306
+ if (blob.length < NONCE_LEN + TAG_LEN) {
307
+ throw new Error('state blob is too short to be valid');
308
+ }
309
+ const nonce = blob.subarray(0, NONCE_LEN);
310
+ const tag = blob.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN);
311
+ const ct = blob.subarray(NONCE_LEN + TAG_LEN);
312
+ const decipher = crypto.createDecipheriv('aes-256-gcm', stateKey(), nonce, {
313
+ authTagLength: TAG_LEN
314
+ });
315
+ decipher.setAuthTag(tag);
316
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
317
+ }
318
+
319
+ /** The bytes32 the contract is keyed by: keccak256(key). */
320
+ function keyHash(key) {
321
+ return ethers.utils.id(key);
322
+ }
323
+
324
+ const ESR_ABI = [
325
+ 'function commit(bytes32 key, string newCID, uint256 expectedVersion, uint256 nonce)',
326
+ 'function commitFor(address enclave, bytes32 key, string newCID, uint256 expectedVersion, uint256 relayNonce, uint256 nonce, bytes signature)',
327
+ 'function commitDigest(address enclave, bytes32 key, string newCID, uint256 expectedVersion, uint256 relayNonce, uint256 nonce) view returns (bytes32)',
328
+ 'function relayNonce(address enclave, bytes32 key) view returns (uint256)',
329
+ 'function getState(address enclave, bytes32 key) view returns (string cid, uint256 version, uint64 updatedAt)',
330
+ 'function getVersion(address enclave, bytes32 key) view returns (uint256)',
331
+ 'function getNonce(address enclave, bytes32 key) view returns (uint256)',
332
+ 'function exists(address enclave, bytes32 key) view returns (bool)'
333
+ ];
334
+
335
+ // Order-wide ledger of signed authorizations. ESR commits do NOT pay their own
336
+ // gas (see contracts/esr/RELAY-DESIGN.md): the enclave only SIGNS each commit
337
+ // (commitFor); the NODE relays it and pays, and does all gas accounting. The
338
+ // securelock does no gas math -- nothing it could claim about cost is trusted.
339
+ let _esrLedger = [];
340
+
341
+ // Per-task memo of the last idempotency nonce ACCEPTED per key, so a
342
+ // getNonce() right after a commit in the SAME task returns the fresh value
343
+ // even before the node's relay lands on-chain.
344
+ let _taskNonces = {};
345
+
346
+ // Per-task memo of the NEXT relay nonce per key hash, so multiple commits to
347
+ // the same key within one task sign sequential relay nonces even while the
348
+ // node is still relaying the earlier ones.
349
+ let _taskRelayNonces = {};
350
+
351
+ class StateRegistry {
352
+ constructor() {
353
+ if (!_contractAddress) {
354
+ throw new Error(
355
+ 'ESR is not enabled for this enclave. Enable it (ECLD_ESR_ENABLE) and ' +
356
+ 'rebuild, so the registry address is baked into the image.'
357
+ );
358
+ }
359
+ this._priv = null;
360
+ }
361
+
362
+ /** The enclave's on-chain address — safe to publish and to fund. */
363
+ get walletAddress() {
364
+ return esr_wallet.deriveWalletAddress(_identityPriv);
365
+ }
366
+
367
+ /**
368
+ * Alias of walletAddress — the ENCLAVE's own on-chain address (its identity,
369
+ * and the namespace all its state keys live under). Preferred name in new
370
+ * code; walletAddress remains for compatibility.
371
+ */
372
+ get enclaveAddress() {
373
+ return this.walletAddress;
374
+ }
375
+
376
+ _signer() {
377
+ // Reuse esr_wallet's derivation rather than repeating it here: two copies
378
+ // would eventually disagree, and the address a client funds would stop
379
+ // matching the key that signs. The key stays inside this method.
380
+ if (this._priv === null) {
381
+ this._priv = esr_wallet._deriveWalletPrivateKey(_identityPriv);
382
+ }
383
+ return new ethers.Wallet(this._priv, _provider);
384
+ }
385
+
386
+ _contract(signerOrProvider) {
387
+ // Local emulator: use the in-memory contract object instead of a real one.
388
+ if (_contractOverride) return _contractOverride;
389
+ return new ethers.Contract(_contractAddress, ESR_ABI, signerOrProvider || _provider);
390
+ }
391
+
392
+ /** Current on-chain version for `key`; 0 when never committed. */
393
+ async getVersion(key) {
394
+ const v = await this._contract().getVersion(this.walletAddress, keyHash(key));
395
+ return Number(v);
396
+ }
397
+
398
+ /**
399
+ * Decrypted state for `key`, or `fallback` ({} by default) when absent.
400
+ *
401
+ * Throws on a pointer that is not a CID: treating a broken pointer as "no
402
+ * state" would let the next commit overwrite data that is still recoverable.
403
+ */
404
+ async get(key, fallback = {}) {
405
+ const [acl, data, version, cid] = await this._readContainer(key, fallback);
406
+ if (!canRead(acl)) {
407
+ throw new StatePermissionError(
408
+ `caller ${_taskCaller || '<anonymous>'} has no read permission on ` +
409
+ `state key '${key}' (owner: ${acl.owner})`
410
+ );
411
+ }
412
+ ledgerRecord(key, version || 0, cid, data);
413
+ return data;
414
+ }
415
+
416
+ /** [acl, data, version, cid] for `key`; NO permission check here. */
417
+ async _readContainer(key, fallback = {}) {
418
+ const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
419
+ if (!version || !cid) {
420
+ return [null, fallback, 0, null];
421
+ }
422
+ if (!looksLikeCID(cid)) {
423
+ throw new Error(
424
+ `ESR entry for '${key}' holds a pointer that is not a CID (${cid.slice(0, 32)}…). ` +
425
+ 'The committing code is writing a non-CID value.'
426
+ );
427
+ }
428
+ const blob = await this._fetch(key, cid);
429
+ const stored = JSON.parse(decrypt(blob).toString('utf8'));
430
+ const [acl, data] = unwrapStored(stored);
431
+ if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1
432
+ && stored.nonce != null) {
433
+ // The blob carries a REPORTING COPY of the idempotency nonce; the chain
434
+ // is primary. Both were written by the same commit (the CID the chain
435
+ // points at IS this blob), so they must agree -- a mismatch means the
436
+ // registry entry and the state object have been tampered with or torn
437
+ // apart. Compare against the RAW chain value (not the task memo): the
438
+ // blob we fetched is the chain's cid.
439
+ const chainNonce = Number(await this._contract().getNonce(this.walletAddress, keyHash(key)));
440
+ const blobNonce = Math.trunc(Number(stored.nonce));
441
+ if (blobNonce !== chainNonce) {
442
+ throw new Error(
443
+ `ESR nonce mismatch for '${key}': the state object reports nonce ` +
444
+ `${blobNonce} but the registry records ${chainNonce} -- refusing ` +
445
+ 'to use this state'
446
+ );
447
+ }
448
+ }
449
+ return [acl, data, Number(version), cid];
450
+ }
451
+
452
+ /**
453
+ * The last accepted idempotency nonce for `key` (0 if none was ever used).
454
+ * A dApp that wants duplicate suppression reads this and passes
455
+ * getNonce(key) + 1 to commit(..., { nonce }) -- the contract accepts
456
+ * EXACTLY the next value in sequence, nothing else.
457
+ *
458
+ * The nonce is PUBLIC data: the registry records it on-chain next to the
459
+ * version, and anyone can read it with a free eth_call (getNonce) -- so no
460
+ * read-ACL applies here, and web3 clients see the same value via the
461
+ * runner's esrNonce.
462
+ *
463
+ * Reads the chain (authoritative, post-relay), merged with this task's
464
+ * own accepted commits so `commit(..., { nonce: N }); getNonce()` returns N
465
+ * even before the node's relay lands.
466
+ */
467
+ async getNonce(key) {
468
+ const chain = Number(await this._contract().getNonce(this.walletAddress, keyHash(key)));
469
+ return Math.max(chain || 0, Number(_taskNonces[key] || 0));
470
+ }
471
+
472
+ /**
473
+ * Read-modify-write `key` under optimistic concurrency.
474
+ *
475
+ * `mutate` receives the current state and returns the new state. If another
476
+ * commit lands in between, the contract rejects ours and we re-read and
477
+ * retry, so concurrent tasks cannot silently lose updates.
478
+ */
479
+ async commit(key, mutate, opts = 3) {
480
+ // Back-compat: the third arg used to be `attempts` (a number); it may now
481
+ // also be { attempts, nonce }. The per-key nonce sequence advances by
482
+ // exactly 1 on EVERY commit (1, 2, 3, ... -- no gaps, no reuse; see
483
+ // getNonce).
484
+ // - Omitted: the SDK takes the next value in sequence automatically.
485
+ // Always succeeds (subject to permissions/versioning) but carries NO
486
+ // exactly-once guarantee -- a resubmitted task picks a fresh number.
487
+ // - Explicit: must be EXACTLY the last accepted nonce + 1, pinned by
488
+ // the CLIENT before submitting. Anything else throws StateNonceError
489
+ // (task code 36) and the state is NOT changed.
490
+ const { attempts = 3, nonce = null } =
491
+ typeof opts === 'number' ? { attempts: opts } : (opts || {});
492
+ const transform = async (acl, data) => {
493
+ if (!canWrite(acl)) {
494
+ throw new StatePermissionError(
495
+ `caller ${_taskCaller || '<anonymous>'} has no write permission on ` +
496
+ `state key '${key}' (owner: ${acl.owner})`
497
+ );
498
+ }
499
+ let nextAcl = acl;
500
+ if (!nextAcl && _taskCaller) nextAcl = newAcl(_taskCaller); // first-writer-owns
501
+ return [nextAcl, await mutate(data)];
502
+ };
503
+ const [, newData] = await this._commitTransform(key, transform, attempts, nonce);
504
+ return newData;
505
+ }
506
+
507
+ /**
508
+ * Optimistic-concurrency commit of transform(acl, data, version) ->
509
+ * [newAcl, newData]. The stored blob is the wrapped container (or the bare
510
+ * data while unowned). On a version race we re-read and retry, so
511
+ * concurrent tasks cannot silently lose updates.
512
+ */
513
+ async _commitTransform(key, transform, attempts = 3, nonce = null) {
514
+ if (_esrLedger.length >= MAX_COMMITS_PER_TASK) {
515
+ throw new StateLimitError(
516
+ `this task already made ${_esrLedger.length} state commits -- the ` +
517
+ `per-run limit is ${MAX_COMMITS_PER_TASK}. Batch your writes (one ` +
518
+ 'commit can update a whole object) or split the work across tasks.'
519
+ );
520
+ }
521
+ let lastError = null;
522
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
523
+ // eslint-disable-next-line no-await-in-loop
524
+ const [acl, data, currentVersion] = await this._readContainer(key);
525
+ // The nonce check re-runs on every retry against the freshest visible
526
+ // nonce (chain merged with this task's own accepted commits), so a
527
+ // duplicate fails in-enclave with task code 36 instead of as a failed
528
+ // relay. The contract re-enforces the same rule on-chain either way.
529
+ let finalNonce;
530
+ if (nonce != null) {
531
+ finalNonce = Math.trunc(nonce);
532
+ if (finalNonce === 0) {
533
+ throw new StateNonceError(
534
+ `nonce=0 is reserved for 'not set' (the registry then assigns the ` +
535
+ `next value itself) -- omit the nonce on commit('${key}', ...) ` +
536
+ 'instead of passing 0. Pinned nonces start at 1: use getNonce() + 1.'
537
+ );
538
+ }
539
+ // eslint-disable-next-line no-await-in-loop
540
+ const storedNonce = await this.getNonce(key);
541
+ if (finalNonce !== storedNonce + 1) {
542
+ throw new StateNonceError(
543
+ `nonce ${finalNonce} is out of sequence for state key '${key}' (last accepted: ` +
544
+ `${storedNonce}, expected: ${storedNonce + 1}); commit suppressed, ` +
545
+ 'state unchanged'
546
+ );
547
+ }
548
+ } else {
549
+ // No explicit nonce: the CONTRACT assigns the next value in sequence
550
+ // itself (wire nonce 0 = "omitted"); this predicted value feeds the
551
+ // blob's reporting copy and the task memo. The sequence advances on
552
+ // EVERY commit; only an EXPLICIT nonce carries the exactly-once
553
+ // guarantee (an omitted one is auto-assigned a fresh number, so it
554
+ // cannot deduplicate -- by design).
555
+ // eslint-disable-next-line no-await-in-loop
556
+ finalNonce = (await this.getNonce(key)) + 1;
557
+ }
558
+ // eslint-disable-next-line no-await-in-loop
559
+ const [nextAcl, newData] = await transform(acl, data, currentVersion);
560
+ // The blob carries a REPORTING COPY of the nonce the registry will
561
+ // store for this key after this commit. Chain stays primary; reads
562
+ // verify the two match.
563
+ // Bind the authoring caller into the blob (hence the CID/signature) for
564
+ // owned state.
565
+ const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null, finalNonce);
566
+
567
+ const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
568
+ // Compute the CID from OUR bytes — never accept one from the node.
569
+ const cid = cidv1Raw(blob);
570
+
571
+ // Hand the blob and the CID to the node for pinning. It verifies the CID
572
+ // matches; it does not get to choose it. Fire-and-forget.
573
+ // eslint-disable-next-line no-await-in-loop
574
+ await this._publish(key, blob, cid);
575
+
576
+ try {
577
+ // Wire value: the pinned nonce, or 0 = "omitted" -- the contract
578
+ // then assigns the next value atomically on-chain.
579
+ // eslint-disable-next-line no-await-in-loop
580
+ await this._sendCommit(keyHash(key), cid, currentVersion,
581
+ nextAcl ? normAddr(nextAcl.owner) : null,
582
+ nonce != null ? Math.trunc(nonce) : 0);
583
+ // Record the POST-commit values (the DATA -- the ACL never leaves in
584
+ // results): this is what the chain shows once the relay lands.
585
+ ledgerRecord(key, currentVersion + 1, cid, newData);
586
+ _taskNonces[key] = finalNonce;
587
+ return [nextAcl, newData];
588
+ } catch (e) {
589
+ const msg = String(e && e.message ? e.message : e);
590
+ if (/VersionMismatch/i.test(msg) || /version/i.test(msg)) {
591
+ lastError = e;
592
+ // eslint-disable-next-line no-continue
593
+ continue;
594
+ }
595
+ throw e;
596
+ }
597
+ }
598
+ throw new Error(`ESR commit failed after ${attempts} attempts: ${lastError}`);
599
+ }
600
+
601
+ async _publish(key, blob, cid) {
602
+ await _swift.putFileContent(_bucket, `state.${key}.enc`, '', blob);
603
+ await _swift.putFileContent(_bucket, `state.${key}.cid`, '', cid);
604
+ }
605
+
606
+ /**
607
+ * Read a state object back, verifying it against the on-chain CID.
608
+ *
609
+ * The enclave cannot reach IPFS (the Kubo API binds loopback on the host), so
610
+ * the node stages state objects in the bucket. That makes the node the
611
+ * delivery path but not a trusted one: content is checked against the CID the
612
+ * chain records, so a substituted blob is rejected rather than decrypted.
613
+ */
614
+ async _fetch(key, cid) {
615
+ for (const name of [`state.${key}.enc`, `state.${cid}.enc`]) {
616
+ // eslint-disable-next-line no-await-in-loop
617
+ const data = await _swift.getFileContentBytes(_bucket, name).catch(() => null);
618
+ if (data && data.length) {
619
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
620
+ if (cidv1Raw(buf) !== cid) {
621
+ throw new Error(
622
+ `State object ${name} does not match the committed CID ${cid} — refusing to use it`
623
+ );
624
+ }
625
+ return buf;
626
+ }
627
+ }
628
+ throw new Error(`Could not read state object for '${key}' (${cid})`);
629
+ }
630
+
631
+ async _sendCommit(keyHashHex, cid, expectedVersion, ownerAddr = null, nonce = 0) {
632
+ // The enclave never pays gas and does NO gas math: it SIGNS the commit
633
+ // (commitFor) and stages the signed authorization for the node, which
634
+ // relays it and pays. The trustedzone independently re-prices the whole
635
+ // ledger and adjudicates. Nothing the securelock could claim about cost is
636
+ // trusted, so it claims nothing.
637
+ const signer = this._signer();
638
+ const enclave = await signer.getAddress();
639
+ const contract = this._contract();
640
+
641
+ // Relay nonce is PER (enclave, key): same-key commits are strictly
642
+ // serialized network-wide, different keys relay in parallel. Merge the
643
+ // chain value with this task's own staged commits so a second commit to
644
+ // the same key in ONE task signs the next number even before (or while)
645
+ // the node relays the first.
646
+ const khHex = String(keyHashHex).toLowerCase();
647
+ const chainRn = Number(await contract.relayNonce(enclave, keyHashHex));
648
+ const relayNonce = Math.max(chainRn, Number(_taskRelayNonces[khHex] || 0));
649
+ // The idempotency nonce is signature-bound: it is part of the digest the
650
+ // enclave signs, so the relaying node cannot alter or strip it.
651
+ const digest = await contract.commitDigest(
652
+ enclave, keyHashHex, cid, expectedVersion, relayNonce, Math.trunc(nonce || 0));
653
+ // Sign the raw 32-byte digest as an eth_sign message (matches the
654
+ // contract's "\x19Ethereum Signed Message:\n32" recovery).
655
+ const signature = await signer.signMessage(ethers.utils.arrayify(digest));
656
+
657
+ const auth = {
658
+ enclave,
659
+ keyHash: keyHashHex,
660
+ cid,
661
+ expectedVersion: Number(expectedVersion),
662
+ relayNonce: Number(relayNonce),
663
+ // PUBLIC idempotency nonce (0 = no guard): recorded on-chain by
664
+ // commitFor, enforced strictly increasing per (enclave, key).
665
+ nonce: Math.trunc(nonce || 0),
666
+ signature,
667
+ // IMPERSONATION GUARD: the trustedzone requires callerUsed to equal the
668
+ // DO owner it reads from the PoX contract, so a payload that forged the
669
+ // in-enclave caller to impersonate another user is rejected outside the
670
+ // payload's process. It does NOT verify the caller was a permitted
671
+ // writer (it cannot read the encrypted ACL). Unowned commits carry no
672
+ // constraint.
673
+ callerUsed: _taskCaller || '',
674
+ owned: Boolean(ownerAddr)
675
+ };
676
+
677
+ _esrLedger.push(auth);
678
+ await _swift.putFileContent(
679
+ _bucket, 'esr.authorizations.json', '',
680
+ Buffer.from(JSON.stringify(_esrLedger), 'utf8'));
681
+ // The staged filename carries the key prefix so commits to different keys
682
+ // within one task never collide, and the per-key relay order is explicit.
683
+ const kh16 = khHex.replace(/^0x/, '').slice(0, 16);
684
+ await _swift.putFileContent(
685
+ _bucket, `esr.commit.${kh16}.${Number(relayNonce)}.json`, '',
686
+ Buffer.from(JSON.stringify(auth), 'utf8'));
687
+ _taskRelayNonces[khHex] = Number(relayNonce) + 1;
688
+
689
+ // LOCAL TESTING: with no node to relay commitFor, apply the commit to the
690
+ // in-memory registry now so getState/getVersion reflect it. No-op in the
691
+ // real enclave (hook is null) -- there the node applies it on-chain.
692
+ if (_localCommitApply) {
693
+ await _localCommitApply(enclave, keyHashHex, cid, Number(expectedVersion),
694
+ Math.trunc(nonce || 0));
695
+ }
696
+
697
+ return { relayed: true, relayNonce: Number(relayNonce) };
698
+ }
699
+ }
700
+
701
+ /** Owner-only ACL mutation; an unowned key is claimed first (owner = caller). */
702
+ async function ownerOp(key, mutator) {
703
+ if (!_taskCaller) {
704
+ throw new StatePermissionError(
705
+ 'state management requires an authenticated caller (the trustedzone supplied none)'
706
+ );
707
+ }
708
+ const reg = new StateRegistry();
709
+ const transform = async (acl, data) => {
710
+ let nextAcl = acl || newAcl(_taskCaller);
711
+ if (normAddr(nextAcl.owner) !== _taskCaller) {
712
+ throw new StatePermissionError(
713
+ `caller ${_taskCaller} is not the owner of state key '${key}' (owner: ${nextAcl.owner})`
714
+ );
715
+ }
716
+ nextAcl = mutator({ ...nextAcl });
717
+ return [nextAcl, data];
718
+ };
719
+ const [acl] = await reg._commitTransform(key, transform);
720
+ return { ...acl };
721
+ }
722
+
723
+ function assertLevel(level) {
724
+ if (level !== 'read' && level !== 'write') throw new Error("level must be 'read' or 'write'");
725
+ }
726
+
727
+ async function esrGrant(key, address, level = 'read') {
728
+ assertLevel(level);
729
+ const addr = normAddr(address);
730
+ if (!addr) throw new Error('a grantee address is required');
731
+ return ownerOp(key, (acl) => {
732
+ const members = new Set((acl[level] || []).map(normAddr).filter(Boolean));
733
+ members.add(addr);
734
+ acl[level] = [...members].sort();
735
+ return acl;
736
+ });
737
+ }
738
+
739
+ async function esrRevoke(key, address, level = 'read') {
740
+ assertLevel(level);
741
+ const addr = normAddr(address);
742
+ return ownerOp(key, (acl) => {
743
+ acl[level] = (acl[level] || []).map(normAddr).filter((a) => a && a !== addr).sort();
744
+ return acl;
745
+ });
746
+ }
747
+
748
+ async function esrSetPublicRead(key, enabled = true) {
749
+ return ownerOp(key, (acl) => {
750
+ acl.public_read = Boolean(enabled);
751
+ return acl;
752
+ });
753
+ }
754
+
755
+ async function esrTransfer(key, newOwner) {
756
+ const addr = normAddr(newOwner);
757
+ if (!addr) throw new Error('a new owner address is required');
758
+ return ownerOp(key, (acl) => {
759
+ acl.owner = addr;
760
+ return acl;
761
+ });
762
+ }
763
+
764
+ /** Owner address of `key`, or null while unowned. Exposes nothing else. */
765
+ async function esrOwner(key) {
766
+ const [acl] = await new StateRegistry()._readContainer(key);
767
+ return acl ? acl.owner : null;
768
+ }
769
+
770
+ /** Owner-only: the full ACL of `key`. */
771
+ async function esrAcl(key) {
772
+ const [acl] = await new StateRegistry()._readContainer(key);
773
+ if (!acl) return null;
774
+ if (!_taskCaller || normAddr(acl.owner) !== _taskCaller) {
775
+ throw new StatePermissionError(
776
+ `caller ${_taskCaller || '<anonymous>'} is not the owner of state key '${key}'`
777
+ );
778
+ }
779
+ return { ...acl };
780
+ }
781
+
782
+ function setTaskCaller(caller) { _taskCaller = normAddr(caller); }
783
+
784
+ /**
785
+ * The last accepted idempotency nonce for `key` (0 if none). See
786
+ * StateRegistry.getNonce / commit(key, mutate, { nonce }).
787
+ */
788
+ async function esrNonce(key) {
789
+ return new StateRegistry().getNonce(key);
790
+ }
791
+
792
+ module.exports = {
793
+ StateRegistry,
794
+ configure,
795
+ cidv1Raw,
796
+ looksLikeCID,
797
+ ledgerSnapshot,
798
+ taskCaller,
799
+ setTaskCaller,
800
+ restampLedgerCaller,
801
+ StatePermissionError,
802
+ StateNonceError,
803
+ StateLimitError,
804
+ esrGrant,
805
+ esrRevoke,
806
+ esrSetPublicRead,
807
+ esrTransfer,
808
+ esrOwner,
809
+ esrAcl,
810
+ esrNonce,
811
+ // local-testing hooks (esr_local only)
812
+ setContractOverride,
813
+ setLocalCommitApply,
814
+ normAddr
815
+ };