@ethernity-cloud/sdk-js 1.5.9 → 1.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,11 +65,46 @@ The sdk has been tested on the following operating systems:
65
65
  ```
66
66
  Required after build, to build and integrate the secure certificates that will be used during executions and to register the project to the Ethernity Cloud Image Register.
67
67
 
68
- - **Run**: To run the project, run:
68
+ - **Test (local, no chain)**: To run your backend locally with the enclave's own
69
+ executor — no SGX, no gas, instant — run:
69
70
  ```sh
70
- npm run start
71
+ npx ecld-test 'hello("World")'
72
+ npx ecld-test --file payload.js
73
+ npx ecld-test --input data.json 'processData(___etny_data_set___)'
74
+ ```
75
+ This validates the *call* (function names, arguments, result wiring) before a
76
+ single on-chain request is paid for. If your dApp uses ESR (Enclave State
77
+ Registry), state is emulated locally and **on by default**: `require('../ecld_state').StateRegistry`
78
+ get/commit, ownership/ACL, and `taskCaller()` all work in-process against an
79
+ in-memory registry, and persist between runs in `.ecld-esr-local.*.json`. The
80
+ task caller defaults to your developer address; override it with `--caller`.
81
+ `npx ecld-test serve` starts a local API so the runner's LOCAL mode drives your
82
+ real integration end to end. Exit code 0 on `SUCCESS`, 1 otherwise.
83
+
84
+ - **Run (on the network)**: To submit a payload to the network and print the
85
+ decrypted result, run:
86
+ ```sh
87
+ npx ecld-run 'hello("World")'
88
+ npx ecld-run --file payload.js
89
+ npx ecld-run --input data.json 'processData(___etny_data_set___)'
90
+ npx ecld-run --json 'esrIncrement()'
91
+ ```
92
+ This is the network-side sibling of `ecld-test`: instead of executing locally
93
+ it drives the runner end to end — encrypt → IPFS → on-chain request → wait for
94
+ a node → download and decrypt the result. It **costs gas and needs a funded
95
+ key**. Network and enclaves come from `.env` (`BLOCKCHAIN_NETWORK`,
96
+ `PROJECT_NAME`, `TRUSTED_ZONE_IMAGE`), overridable with
97
+ `--network`/`--securelock`/`--trustedzone`; the signing key is `PRIVATE_KEY`
98
+ (or `ECLD_PRIVATE_KEY`) — a funded `0x` key that pays for the order. Resources
99
+ are tunable with `--task-price`/`--cpu`/`--memory`/`--storage`/`--bandwidth`/`--duration`/`--validators`.
100
+ Exit code 0 on a `SUCCESS` task result, 1 otherwise.
101
+
102
+ - **Inspect (read-only)**: To read enclave and on-chain diagnostics — network,
103
+ trustedzone/securelock registration, and ESR state — without spending gas, run:
104
+ ```sh
105
+ npx ecld-info
106
+ npx ecld-info esr state <key>
71
107
  ```
72
- command to start the demo application and test the integration.
73
108
 
74
109
  ## Project Structure
75
110
 
@@ -50,6 +50,16 @@ let _bucket = null;
50
50
  let _contractAddress = null;
51
51
  let _provider = null;
52
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
+
53
63
  /**
54
64
  * Wire the registry to the enclave's identity, storage and chain access.
55
65
  * Called by securelock at task start; payload code never calls this.
@@ -91,6 +101,11 @@ const CONTAINER_MARK = '_ecld_state';
91
101
 
92
102
  class StatePermissionError extends Error {}
93
103
 
104
+ // A commit's idempotency nonce was already used: the dApp supplied `nonce` on
105
+ // commit() and it is not greater than the last accepted nonce for the key --
106
+ // a duplicate (or out-of-order) submission. The state was NOT changed.
107
+ class StateNonceError extends Error {}
108
+
94
109
  function unwrapStored(stored) {
95
110
  if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
96
111
  return [stored.acl || null, stored.data];
@@ -98,13 +113,25 @@ function unwrapStored(stored) {
98
113
  return [null, stored];
99
114
  }
100
115
 
101
- function wrapStored(acl, data, committedBy = null) {
116
+ /** Last accepted idempotency nonce carried by a stored blob (0 if none). */
117
+ function storedNonce(stored) {
118
+ if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
119
+ const n = parseInt(stored.nonce, 10);
120
+ return Number.isFinite(n) ? n : 0;
121
+ }
122
+ return 0;
123
+ }
124
+
125
+ function wrapStored(acl, data, committedBy = null, nonce = null) {
102
126
  // For owned state, bind the authoring caller (committedBy) INTO the blob, so
103
127
  // it lands in the CID -- the field the securelock signs -- making the caller
104
128
  // cryptographically bound to the commit, not merely a re-stampable sidecar.
105
- if (!acl) return data; // unowned keeps the legacy shape
106
- const container = { [CONTAINER_MARK]: 1, acl, data };
129
+ // Unowned state keeps the legacy bare shape -- unless a nonce must be
130
+ // carried, which forces the container so the nonce survives in the blob.
131
+ if (!acl && nonce == null) return data;
132
+ const container = { [CONTAINER_MARK]: 1, acl: acl || null, data };
107
133
  if (committedBy) container.committedBy = committedBy;
134
+ if (nonce != null) container.nonce = Math.trunc(nonce);
108
135
  return container;
109
136
  }
110
137
 
@@ -318,6 +345,15 @@ class StateRegistry {
318
345
  return esr_wallet.deriveWalletAddress(_identityPriv);
319
346
  }
320
347
 
348
+ /**
349
+ * Alias of walletAddress — the ENCLAVE's own on-chain address (its identity,
350
+ * and the namespace all its state keys live under). Preferred name in new
351
+ * code; walletAddress remains for compatibility.
352
+ */
353
+ get enclaveAddress() {
354
+ return this.walletAddress;
355
+ }
356
+
321
357
  _signer() {
322
358
  // Reuse esr_wallet's derivation rather than repeating it here: two copies
323
359
  // would eventually disagree, and the address a client funds would stop
@@ -329,6 +365,8 @@ class StateRegistry {
329
365
  }
330
366
 
331
367
  _contract(signerOrProvider) {
368
+ // Local emulator: use the in-memory contract object instead of a real one.
369
+ if (_contractOverride) return _contractOverride;
332
370
  return new ethers.Contract(_contractAddress, ESR_ABI, signerOrProvider || _provider);
333
371
  }
334
372
 
@@ -356,11 +394,11 @@ class StateRegistry {
356
394
  return data;
357
395
  }
358
396
 
359
- /** [acl, data, version, cid] for `key`; NO permission check here. */
397
+ /** [acl, data, version, cid, nonce] for `key`; NO permission check here. */
360
398
  async _readContainer(key, fallback = {}) {
361
399
  const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
362
400
  if (!version || !cid) {
363
- return [null, fallback, 0, null];
401
+ return [null, fallback, 0, null, 0];
364
402
  }
365
403
  if (!looksLikeCID(cid)) {
366
404
  throw new Error(
@@ -371,7 +409,23 @@ class StateRegistry {
371
409
  const blob = await this._fetch(key, cid);
372
410
  const stored = JSON.parse(decrypt(blob).toString('utf8'));
373
411
  const [acl, data] = unwrapStored(stored);
374
- return [acl, data, Number(version), cid];
412
+ return [acl, data, Number(version), cid, storedNonce(stored)];
413
+ }
414
+
415
+ /**
416
+ * The last accepted idempotency nonce for `key` (0 if none was ever used).
417
+ * A dApp that wants duplicate suppression reads this, picks a greater value
418
+ * (e.g. +1, or a timestamp), and passes it to commit(..., { nonce }).
419
+ */
420
+ async getNonce(key) {
421
+ const [acl, , , , nonce] = await this._readContainer(key);
422
+ if (!canRead(acl)) {
423
+ throw new StatePermissionError(
424
+ `caller ${_taskCaller || '<anonymous>'} has no read permission on ` +
425
+ `state key '${key}' (owner: ${acl.owner})`
426
+ );
427
+ }
428
+ return nonce;
375
429
  }
376
430
 
377
431
  /**
@@ -381,7 +435,14 @@ class StateRegistry {
381
435
  * commit lands in between, the contract rejects ours and we re-read and
382
436
  * retry, so concurrent tasks cannot silently lose updates.
383
437
  */
384
- async commit(key, mutate, attempts = 3) {
438
+ async commit(key, mutate, opts = 3) {
439
+ // Back-compat: the third arg used to be `attempts` (a number); it may now
440
+ // also be { attempts, nonce }. `nonce` is an idempotency guard the dApp
441
+ // controls: it must be STRICTLY GREATER than the last accepted nonce for
442
+ // the key (see getNonce). A duplicate or stale nonce throws
443
+ // StateNonceError and the state is NOT changed.
444
+ const { attempts = 3, nonce = null } =
445
+ typeof opts === 'number' ? { attempts: opts } : (opts || {});
385
446
  const transform = async (acl, data) => {
386
447
  if (!canWrite(acl)) {
387
448
  throw new StatePermissionError(
@@ -393,7 +454,7 @@ class StateRegistry {
393
454
  if (!nextAcl && _taskCaller) nextAcl = newAcl(_taskCaller); // first-writer-owns
394
455
  return [nextAcl, await mutate(data)];
395
456
  };
396
- const [, newData] = await this._commitTransform(key, transform, attempts);
457
+ const [, newData] = await this._commitTransform(key, transform, attempts, nonce);
397
458
  return newData;
398
459
  }
399
460
 
@@ -403,16 +464,33 @@ class StateRegistry {
403
464
  * data while unowned). On a version race we re-read and retry, so
404
465
  * concurrent tasks cannot silently lose updates.
405
466
  */
406
- async _commitTransform(key, transform, attempts = 3) {
467
+ async _commitTransform(key, transform, attempts = 3, nonce = null) {
407
468
  let lastError = null;
408
469
  for (let attempt = 0; attempt < attempts; attempt += 1) {
409
470
  // eslint-disable-next-line no-await-in-loop
410
- const [acl, data, currentVersion] = await this._readContainer(key);
471
+ const [acl, data, currentVersion, , priorNonce] = await this._readContainer(key);
472
+ // The nonce check re-runs on every retry against the freshly read state,
473
+ // so a racing commit that consumed the same nonce fails this one.
474
+ let finalNonce;
475
+ if (nonce != null) {
476
+ const n = Math.trunc(nonce);
477
+ if (n <= priorNonce) {
478
+ throw new StateNonceError(
479
+ `nonce ${n} was already used for state key '${key}' (last accepted: ` +
480
+ `${priorNonce}); duplicate commit suppressed, state unchanged`
481
+ );
482
+ }
483
+ finalNonce = n;
484
+ } else {
485
+ // Preserve the stored nonce so a nonce-less commit cannot reset the
486
+ // guard and reopen replays.
487
+ finalNonce = priorNonce > 0 ? priorNonce : null;
488
+ }
411
489
  // eslint-disable-next-line no-await-in-loop
412
490
  const [nextAcl, newData] = await transform(acl, data, currentVersion);
413
491
  // Bind the authoring caller into the blob (hence the CID/signature) for
414
492
  // owned state.
415
- const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null);
493
+ const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null, finalNonce);
416
494
 
417
495
  const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
418
496
  // Compute the CID from OUR bytes — never accept one from the node.
@@ -515,6 +593,14 @@ class StateRegistry {
515
593
  await _swift.putFileContent(
516
594
  _bucket, `esr.commit.${Number(relayNonce)}.json`, '',
517
595
  Buffer.from(JSON.stringify(auth), 'utf8'));
596
+
597
+ // LOCAL TESTING: with no node to relay commitFor, apply the commit to the
598
+ // in-memory registry now so getState/getVersion reflect it. No-op in the
599
+ // real enclave (hook is null) -- there the node applies it on-chain.
600
+ if (_localCommitApply) {
601
+ await _localCommitApply(enclave, keyHashHex, cid, Number(expectedVersion));
602
+ }
603
+
518
604
  return { relayed: true, relayNonce: Number(relayNonce) };
519
605
  }
520
606
  }
@@ -600,6 +686,16 @@ async function esrAcl(key) {
600
686
  return { ...acl };
601
687
  }
602
688
 
689
+ function setTaskCaller(caller) { _taskCaller = normAddr(caller); }
690
+
691
+ /**
692
+ * The last accepted idempotency nonce for `key` (0 if none). See
693
+ * StateRegistry.getNonce / commit(key, mutate, { nonce }).
694
+ */
695
+ async function esrNonce(key) {
696
+ return new StateRegistry().getNonce(key);
697
+ }
698
+
603
699
  module.exports = {
604
700
  StateRegistry,
605
701
  configure,
@@ -607,12 +703,19 @@ module.exports = {
607
703
  looksLikeCID,
608
704
  ledgerSnapshot,
609
705
  taskCaller,
706
+ setTaskCaller,
610
707
  restampLedgerCaller,
611
708
  StatePermissionError,
709
+ StateNonceError,
612
710
  esrGrant,
613
711
  esrRevoke,
614
712
  esrSetPublicRead,
615
713
  esrTransfer,
616
714
  esrOwner,
617
- esrAcl
715
+ esrAcl,
716
+ esrNonce,
717
+ // local-testing hooks (esr_local only)
718
+ setContractOverride,
719
+ setLocalCommitApply,
720
+ normAddr
618
721
  };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Local ESR emulator for `ecld-test` -- run state-using backends with no chain.
3
+ *
4
+ * In-memory stand-ins for the two external things StateRegistry talks to, so
5
+ * get/commit/esrGrant/... behave EXACTLY as on-chain but in-process, instantly,
6
+ * with zero orders and zero gas:
7
+ *
8
+ * MemSwift -- state-blob store (replaces SwiftStream): putFileContent /
9
+ * getFileContentBytes over a Map, optionally mirrored to a JSON
10
+ * file so state survives across `ecld-test` runs.
11
+ * MemContract -- the ESR contract methods StateRegistry calls
12
+ * (getState / getVersion / relayNonce / commitDigest) plus the
13
+ * commit APPLY the node would do on-chain, keyed by
14
+ * (enclave, keyHash) -> { cid, version }.
15
+ *
16
+ * install(ecldState, { caller, filePrefix }) wires these into ecld_state via its
17
+ * local-testing hooks. Used only by the local test harness; the real enclave
18
+ * never requires this file.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const { ethers } = require('ethers');
25
+
26
+ function key(enclave, keyHash) {
27
+ return `${String(enclave).toLowerCase()}|${String(keyHash).toLowerCase()}`;
28
+ }
29
+
30
+ class MemSwift {
31
+ constructor(file) {
32
+ this._file = file || null;
33
+ this._store = new Map();
34
+ if (this._file && fs.existsSync(this._file)) {
35
+ try {
36
+ const raw = JSON.parse(fs.readFileSync(this._file, 'utf8'));
37
+ for (const [k, v] of Object.entries(raw)) this._store.set(k, Buffer.from(v, 'base64'));
38
+ } catch (e) { /* start empty */ }
39
+ }
40
+ }
41
+
42
+ _persist() {
43
+ if (!this._file) return;
44
+ try {
45
+ const obj = {};
46
+ for (const [k, v] of this._store) obj[k] = Buffer.from(v).toString('base64');
47
+ fs.writeFileSync(this._file + '.tmp', JSON.stringify(obj));
48
+ fs.renameSync(this._file + '.tmp', this._file);
49
+ } catch (e) { /* best-effort */ }
50
+ }
51
+
52
+ async putFileContent(bucket, name, _prefix, data) {
53
+ const buf = Buffer.isBuffer(data) ? data
54
+ : (typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data));
55
+ this._store.set(`${bucket}/${name}`, buf);
56
+ this._persist();
57
+ return true;
58
+ }
59
+
60
+ async getFileContentBytes(bucket, name) {
61
+ const v = this._store.get(`${bucket}/${name}`);
62
+ return v === undefined ? null : v;
63
+ }
64
+
65
+ async getFileContent(bucket, name) {
66
+ const v = await this.getFileContentBytes(bucket, name);
67
+ return v === null ? null : v.toString('utf8');
68
+ }
69
+
70
+ async createBucket() { return true; }
71
+ }
72
+
73
+ class MemContract {
74
+ constructor(file) {
75
+ this._file = file || null;
76
+ this._entries = {}; // key -> { cid, version, updatedAt }
77
+ this._nonce = {}; // enclave -> n
78
+ if (this._file && fs.existsSync(this._file)) {
79
+ try {
80
+ const d = JSON.parse(fs.readFileSync(this._file, 'utf8'));
81
+ this._entries = d.entries || {};
82
+ this._nonce = d.nonce || {};
83
+ } catch (e) { /* start empty */ }
84
+ }
85
+ }
86
+
87
+ _persist() {
88
+ if (!this._file) return;
89
+ try {
90
+ fs.writeFileSync(this._file + '.tmp', JSON.stringify({ entries: this._entries, nonce: this._nonce }));
91
+ fs.renameSync(this._file + '.tmp', this._file);
92
+ } catch (e) { /* best-effort */ }
93
+ }
94
+
95
+ // ---- the ethers.Contract methods StateRegistry calls ----
96
+ async getState(enclave, keyHash) {
97
+ const e = this._entries[key(enclave, keyHash)];
98
+ if (!e) return ['', ethers.BigNumber.from(0), ethers.BigNumber.from(0)];
99
+ return [e.cid, ethers.BigNumber.from(e.version), ethers.BigNumber.from(e.updatedAt || 0)];
100
+ }
101
+
102
+ async getVersion(enclave, keyHash) {
103
+ const e = this._entries[key(enclave, keyHash)];
104
+ return ethers.BigNumber.from(e ? e.version : 0);
105
+ }
106
+
107
+ async relayNonce(enclave) {
108
+ return ethers.BigNumber.from(this._nonce[String(enclave).toLowerCase()] || 0);
109
+ }
110
+
111
+ async commitDigest(enclave, keyHash, cid, expectedVersion, relayNonce) {
112
+ // Deterministic 32-byte digest (no real signing needed locally; the
113
+ // securelock still signs it with the local test key).
114
+ return ethers.utils.id(JSON.stringify([String(enclave).toLowerCase(), keyHash, cid,
115
+ Number(expectedVersion), Number(relayNonce)]));
116
+ }
117
+
118
+ async entryCount() {
119
+ return ethers.BigNumber.from(Object.keys(this._entries).length);
120
+ }
121
+
122
+ // ---- the on-chain commitFor, applied locally ----
123
+ applyCommit(enclave, keyHash, cid, expectedVersion) {
124
+ const k = key(enclave, keyHash);
125
+ const cur = this._entries[k] ? Number(this._entries[k].version) : 0;
126
+ if (Number(expectedVersion) !== cur) {
127
+ throw new Error(`VersionMismatch: expected ${expectedVersion} but current is ${cur}`);
128
+ }
129
+ this._entries[k] = { cid, version: cur + 1, updatedAt: 0 };
130
+ const e = String(enclave).toLowerCase();
131
+ this._nonce[e] = (this._nonce[e] || 0) + 1;
132
+ this._persist();
133
+ }
134
+ }
135
+
136
+ // A fixed local test identity so walletAddress is stable across runs.
137
+ const LOCAL_IDENTITY = Buffer.from('ecld-local-test-identity-key-0001');
138
+
139
+ function install(ecldState, { caller = null, filePrefix = '.ecld-esr-local' } = {}) {
140
+ const swiftFile = filePrefix ? `${filePrefix}.swift.json` : null;
141
+ const regFile = filePrefix ? `${filePrefix}.registry.json` : null;
142
+ const swift = new MemSwift(swiftFile);
143
+ const contract = new MemContract(regFile);
144
+
145
+ ecldState.configure({
146
+ identityPriv: LOCAL_IDENTITY,
147
+ swiftStreamService: swift,
148
+ bucket: 'ecld-local',
149
+ contractAddress: '0xLOCAL',
150
+ provider: null,
151
+ caller,
152
+ });
153
+ ecldState.setContractOverride(contract);
154
+ ecldState.setLocalCommitApply(
155
+ (enclave, keyHash, cid, expectedVersion) =>
156
+ contract.applyCommit(enclave, keyHash, cid, expectedVersion));
157
+
158
+ return { swift, contract };
159
+ }
160
+
161
+ module.exports = { install, MemSwift, MemContract };