@ethernity-cloud/sdk-js 1.5.11 → 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.
@@ -101,6 +101,11 @@ const CONTAINER_MARK = '_ecld_state';
101
101
 
102
102
  class StatePermissionError extends Error {}
103
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
+
104
109
  function unwrapStored(stored) {
105
110
  if (stored && typeof stored === 'object' && stored[CONTAINER_MARK] === 1) {
106
111
  return [stored.acl || null, stored.data];
@@ -108,13 +113,25 @@ function unwrapStored(stored) {
108
113
  return [null, stored];
109
114
  }
110
115
 
111
- 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) {
112
126
  // For owned state, bind the authoring caller (committedBy) INTO the blob, so
113
127
  // it lands in the CID -- the field the securelock signs -- making the caller
114
128
  // cryptographically bound to the commit, not merely a re-stampable sidecar.
115
- if (!acl) return data; // unowned keeps the legacy shape
116
- 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 };
117
133
  if (committedBy) container.committedBy = committedBy;
134
+ if (nonce != null) container.nonce = Math.trunc(nonce);
118
135
  return container;
119
136
  }
120
137
 
@@ -328,6 +345,15 @@ class StateRegistry {
328
345
  return esr_wallet.deriveWalletAddress(_identityPriv);
329
346
  }
330
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
+
331
357
  _signer() {
332
358
  // Reuse esr_wallet's derivation rather than repeating it here: two copies
333
359
  // would eventually disagree, and the address a client funds would stop
@@ -368,11 +394,11 @@ class StateRegistry {
368
394
  return data;
369
395
  }
370
396
 
371
- /** [acl, data, version, cid] for `key`; NO permission check here. */
397
+ /** [acl, data, version, cid, nonce] for `key`; NO permission check here. */
372
398
  async _readContainer(key, fallback = {}) {
373
399
  const [cid, version] = await this._contract().getState(this.walletAddress, keyHash(key));
374
400
  if (!version || !cid) {
375
- return [null, fallback, 0, null];
401
+ return [null, fallback, 0, null, 0];
376
402
  }
377
403
  if (!looksLikeCID(cid)) {
378
404
  throw new Error(
@@ -383,7 +409,23 @@ class StateRegistry {
383
409
  const blob = await this._fetch(key, cid);
384
410
  const stored = JSON.parse(decrypt(blob).toString('utf8'));
385
411
  const [acl, data] = unwrapStored(stored);
386
- 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;
387
429
  }
388
430
 
389
431
  /**
@@ -393,7 +435,14 @@ class StateRegistry {
393
435
  * commit lands in between, the contract rejects ours and we re-read and
394
436
  * retry, so concurrent tasks cannot silently lose updates.
395
437
  */
396
- 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 || {});
397
446
  const transform = async (acl, data) => {
398
447
  if (!canWrite(acl)) {
399
448
  throw new StatePermissionError(
@@ -405,7 +454,7 @@ class StateRegistry {
405
454
  if (!nextAcl && _taskCaller) nextAcl = newAcl(_taskCaller); // first-writer-owns
406
455
  return [nextAcl, await mutate(data)];
407
456
  };
408
- const [, newData] = await this._commitTransform(key, transform, attempts);
457
+ const [, newData] = await this._commitTransform(key, transform, attempts, nonce);
409
458
  return newData;
410
459
  }
411
460
 
@@ -415,16 +464,33 @@ class StateRegistry {
415
464
  * data while unowned). On a version race we re-read and retry, so
416
465
  * concurrent tasks cannot silently lose updates.
417
466
  */
418
- async _commitTransform(key, transform, attempts = 3) {
467
+ async _commitTransform(key, transform, attempts = 3, nonce = null) {
419
468
  let lastError = null;
420
469
  for (let attempt = 0; attempt < attempts; attempt += 1) {
421
470
  // eslint-disable-next-line no-await-in-loop
422
- 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
+ }
423
489
  // eslint-disable-next-line no-await-in-loop
424
490
  const [nextAcl, newData] = await transform(acl, data, currentVersion);
425
491
  // Bind the authoring caller into the blob (hence the CID/signature) for
426
492
  // owned state.
427
- const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null);
493
+ const stored = wrapStored(nextAcl, newData, nextAcl ? _taskCaller : null, finalNonce);
428
494
 
429
495
  const blob = encrypt(Buffer.from(JSON.stringify(stored), 'utf8'));
430
496
  // Compute the CID from OUR bytes — never accept one from the node.
@@ -622,6 +688,14 @@ async function esrAcl(key) {
622
688
 
623
689
  function setTaskCaller(caller) { _taskCaller = normAddr(caller); }
624
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
+
625
699
  module.exports = {
626
700
  StateRegistry,
627
701
  configure,
@@ -632,12 +706,14 @@ module.exports = {
632
706
  setTaskCaller,
633
707
  restampLedgerCaller,
634
708
  StatePermissionError,
709
+ StateNonceError,
635
710
  esrGrant,
636
711
  esrRevoke,
637
712
  esrSetPublicRead,
638
713
  esrTransfer,
639
714
  esrOwner,
640
715
  esrAcl,
716
+ esrNonce,
641
717
  // local-testing hooks (esr_local only)
642
718
  setContractOverride,
643
719
  setLocalCommitApply,
@@ -1,225 +1,230 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { TaskStatus } = require('./task_status');
4
-
5
- // Load EVERY export of ./serverless/backend into the payload's scope. The
6
- // backend is copied next to this file by ecld-build (and staged the same way
7
- // by ecld-test locally), so one code path serves both the enclave and local
8
- // testing. Previously only a function literally named `hello` was reachable
9
- // (hardcoded destructure); any other exported function threw ReferenceError.
10
- // A missing backend leaves only ___etny_result___ in scope, which is the
11
- // stock-enclave behaviour.
12
- let backendFunctions = {};
13
- // If the backend fails to load, remember WHY. A silently-swallowed failure
14
- // (missing npm module, bad require inside backend.js) makes every task die
15
- // later with a misleading "X is not defined". Only a genuinely absent backend
16
- // stays silent — that is the stock-enclave configuration.
17
- let backendImportError = null;
18
- try {
19
- backendFunctions = { ...require('./serverless/backend') };
20
- } catch (e) {
21
- backendFunctions = {};
22
- // MODULE_NOT_FOUND messages embed the whole require stack (which always
23
- // mentions serverless/backend.js), so parse out WHICH module is missing:
24
- // only a missing backend module itself means "no backend shipped".
25
- const m = /Cannot find module '([^']+)'/.exec(e.message || '');
26
- const missingBackendItself =
27
- e.code === 'MODULE_NOT_FOUND' && m && /serverless[\\/]backend/.test(m[1]);
28
- if (!missingBackendItself) {
29
- backendImportError = `${e.name || 'Error'}: ${(e.message || '').split('\n')[0]}`;
30
- }
31
- }
32
-
33
- /**
34
- * Automatic encoding for task results (single code path for all results):
35
- * JSON-serializable values ride as JSON, strings as text, bytes-like as
36
- * base64. Anything else is JSON-stringified with a String() fallback -- a
37
- * task result never fails on encoding.
38
- */
39
- function encodeResultData(data) {
40
- if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
41
- return ['base64', Buffer.from(data).toString('base64')];
42
- }
43
- if (typeof data === 'string') {
44
- return ['text', data];
45
- }
46
- try {
47
- JSON.stringify(data);
48
- return ['json', data === undefined ? null : data];
49
- } catch (e) {
50
- try {
51
- return ['json', JSON.parse(JSON.stringify(data, (k, v) =>
52
- typeof v === 'bigint' || typeof v === 'function' ? String(v) : v))];
53
- } catch (e2) {
54
- return ['text', String(data)];
55
- }
56
- }
57
- }
58
-
59
- /**
60
- * Return a task result (THE way to end a task).
61
- *
62
- * Builds the structured result envelope:
63
- * { ecld: 1, type: 'json'|'text'|'base64', data: ..., esr: ... }
64
- *
65
- * - `data` is encoded automatically (see encodeResultData).
66
- * - `state: true` (default) attaches the ESR state of every key this task
67
- * touched; `state: 'meta'` attaches {key, version, cid} without the state
68
- * payload; `state: false` attaches nothing.
69
- * - `keys: [...]` restricts the attachment to those keys (already-touched
70
- * only -- to attach an untouched key, use the async esrFetch helper).
71
- *
72
- * ESR attachment is best-effort: an ESR-disabled build (or any attachment
73
- * error) yields esr: null, never a failed task.
74
- */
75
- function ecldResult(data = null, { state = true, keys = null } = {}) {
76
- const [type, encoded] = encodeResultData(data);
77
- const envelope = { ecld: 1, type, data: encoded, esr: null };
78
- if (state || keys) {
79
- try {
80
- // eslint-disable-next-line global-require
81
- const ecldState = require('./ecld_state');
82
- envelope.esr = ecldState.ledgerSnapshot(state === true, keys);
83
- } catch (e) {
84
- envelope.esr = null;
85
- }
86
- }
87
- return [0, JSON.stringify(envelope)];
88
- }
89
-
90
- /** Legacy alias: same builder, ESR attachment off -- today's behavior. */
91
- function ___etny_result___(data) {
92
- return ecldResult(data, { state: false });
93
- }
94
-
95
- /**
96
- * Standard state-fetch task body: awaits the reads (populating the task
97
- * ledger), then attaches them. The runner's cache-gated read submits this on
98
- * a cache miss: `await esrFetch('profile-7')`.
99
- */
100
- async function esrFetch(...keys) {
101
- try {
102
- // eslint-disable-next-line global-require
103
- const { StateRegistry } = require('./ecld_state');
104
- const reg = new StateRegistry();
105
- for (const k of keys) {
106
- // eslint-disable-next-line no-await-in-loop
107
- await reg.get(k);
108
- }
109
- } catch (e) { /* ESR-disabled build: esr stays null */ }
110
- return ecldResult(null, { keys: keys.length ? keys : null });
111
- }
112
-
113
- // Names of *_ADDRESS-style enclave config vars that are set but EMPTY.
114
- // The enclave is sealed: env can only come from the image, so a present-but-
115
- // empty required value is always a build/render defect, never a runtime
116
- // choice. ESR_* vars only exist when the project enabled ESR at build time.
117
- function emptyRequiredConfig() {
118
- const required = ['ESR_CONTRACT_ADDRESS'];
119
- return required.filter(
120
- (name) => name in process.env && !String(process.env[name]).trim()
121
- );
122
- }
123
-
124
- async function executeTask(payload, input) {
125
- const missing = emptyRequiredConfig();
126
- if (missing.length) {
127
- return [
128
- TaskStatus.CONFIG_ERROR,
129
- 'ENCLAVE CONFIG ERROR: required value(s) empty inside the enclave: ' +
130
- missing.join(', ') +
131
- ' | The enclave is sealed, so this value had to be baked at build' +
132
- ' time and was not. Re-run ecld-build (it validates ESR config)' +
133
- ' and republish.'
134
- ];
135
- }
136
- if (backendImportError !== null) {
137
- return [
138
- TaskStatus.IMPORT_ERROR,
139
- 'BACKEND IMPORT ERROR: ' + backendImportError +
140
- ' | The serverless backend failed to load inside the enclave, so none of its' +
141
- ' functions are available. Common causes: a module missing from the enclave' +
142
- ' image dependencies, or a bad require() inside backend.js.'
143
- ];
144
- }
145
- const scope = {
146
- '___etny_result___': ___etny_result___, // legacy alias (no ESR)
147
- 'ecldResult': ecldResult, // the result API
148
- 'esrFetch': esrFetch, // standard state-fetch task
149
- ...backendFunctions,
150
- };
151
- // State ownership / ACL API (present only in ESR-enabled builds). Enforced
152
- // inside ecld_state against the trustedzone-attested caller.
153
- try {
154
- // eslint-disable-next-line global-require
155
- const ecldState = require('./ecld_state');
156
- for (const name of ['taskCaller', 'esrGrant', 'esrRevoke',
157
- 'esrSetPublicRead', 'esrTransfer', 'esrOwner', 'esrAcl']) {
158
- if (!(name in scope)) scope[name] = ecldState[name];
159
- }
160
- } catch (e) { /* non-ESR build */ }
161
- return exec(payload, input, scope);
162
- }
163
-
164
- async function exec(payload, input, globals = null) {
165
- try {
166
- if (payload && payload !== "") {
167
- const scope = globals || {};
168
- if (input && input !== "") {
169
- scope['___etny_data_set___'] = input;
170
- }
171
- // `with` exposes every backend function and ___etny_data_set___ to
172
- // the payload by bare name — matching how payloads are written
173
- // (e.g. `processData(___etny_data_set___)`).
174
- with (scope) {
175
- // ESR reads/commits are async, so a state-using payload
176
- // (`esrIncrement()`, `await reg.commit(...)`) evaluates to a
177
- // Promise. Await it so the finished value -- not the pending
178
- // Promise -- is what gets wrapped. Non-async payloads await to
179
- // themselves, so this is transparent for plain results.
180
- const value = await eval(payload);
181
- // A payload that called ecldResult/___etny_result___ itself
182
- // already holds the finished [0, envelopeJson] tuple -- pass
183
- // it through instead of wrapping the result a second time.
184
- if (Array.isArray(value) && value.length === 2 &&
185
- value[0] === 0 && typeof value[1] === 'string') {
186
- return value;
187
- }
188
- return ___etny_result___(value);
189
- }
190
- } else {
191
- return [TaskStatus.PAYLOAD_NOT_DEFINED, 'Could not find the source file to execute'];
192
- }
193
-
194
- // return [TaskStatus.SUCCESS, 'TASK EXECUTED SUCCESSFULLY'];
195
- } catch (error) {
196
- // A task returns its result by RETURNING (via ___etny_result___), not by
197
- // throwing, so anything caught here is a genuine failure. In every branch
198
- // deliver the FULL stack trace to the data owner AS THE RESULT (visible in
199
- // their runner output) and never crash the enclave -- always return a
200
- // normal [code, data] tuple so the trustedzone stops waiting. Honor the
201
- // embedded-result SUCCESS (code 0) case first.
202
- try {
203
- if (error && error.args && error.args[0] && error.args[0][0] === 0) {
204
- return [TaskStatus.SUCCESS, error.args[0][1]];
205
- }
206
- } catch (e) { /* fall through to failure reporting */ }
207
-
208
- const detail = (error && (error.stack || error.message)) || String(error);
209
- console.error('Error in payload execution -- full stack follows:\n' + detail);
210
-
211
- if (error instanceof ReferenceError) {
212
- return [TaskStatus.SYSTEM_ERROR, 'SYSTEM_ERROR (ReferenceError): ' + detail];
213
- } else if (error instanceof EvalError) {
214
- return [TaskStatus.KEY_ERROR, 'KEY_ERROR (EvalError): ' + detail];
215
- } else if (error instanceof SyntaxError) {
216
- return [TaskStatus.SYNTAX_WARNING, 'SYNTAX_WARNING (SyntaxError): ' + detail];
217
- } else {
218
- return [TaskStatus.BASE_EXCEPTION, 'BASE_EXCEPTION: ' + detail];
219
- }
220
- }
221
- }
222
-
223
- module.exports = {
224
- executeTask
225
- };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { TaskStatus } = require('./task_status');
4
+
5
+ // Load EVERY export of ./serverless/backend into the payload's scope. The
6
+ // backend is copied next to this file by ecld-build (and staged the same way
7
+ // by ecld-test locally), so one code path serves both the enclave and local
8
+ // testing. Previously only a function literally named `hello` was reachable
9
+ // (hardcoded destructure); any other exported function threw ReferenceError.
10
+ // A missing backend leaves only ___etny_result___ in scope, which is the
11
+ // stock-enclave behaviour.
12
+ let backendFunctions = {};
13
+ // If the backend fails to load, remember WHY. A silently-swallowed failure
14
+ // (missing npm module, bad require inside backend.js) makes every task die
15
+ // later with a misleading "X is not defined". Only a genuinely absent backend
16
+ // stays silent — that is the stock-enclave configuration.
17
+ let backendImportError = null;
18
+ try {
19
+ backendFunctions = { ...require('./serverless/backend') };
20
+ } catch (e) {
21
+ backendFunctions = {};
22
+ // MODULE_NOT_FOUND messages embed the whole require stack (which always
23
+ // mentions serverless/backend.js), so parse out WHICH module is missing:
24
+ // only a missing backend module itself means "no backend shipped".
25
+ const m = /Cannot find module '([^']+)'/.exec(e.message || '');
26
+ const missingBackendItself =
27
+ e.code === 'MODULE_NOT_FOUND' && m && /serverless[\\/]backend/.test(m[1]);
28
+ if (!missingBackendItself) {
29
+ backendImportError = `${e.name || 'Error'}: ${(e.message || '').split('\n')[0]}`;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Automatic encoding for task results (single code path for all results):
35
+ * JSON-serializable values ride as JSON, strings as text, bytes-like as
36
+ * base64. Anything else is JSON-stringified with a String() fallback -- a
37
+ * task result never fails on encoding.
38
+ */
39
+ function encodeResultData(data) {
40
+ if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
41
+ return ['base64', Buffer.from(data).toString('base64')];
42
+ }
43
+ if (typeof data === 'string') {
44
+ return ['text', data];
45
+ }
46
+ try {
47
+ JSON.stringify(data);
48
+ return ['json', data === undefined ? null : data];
49
+ } catch (e) {
50
+ try {
51
+ return ['json', JSON.parse(JSON.stringify(data, (k, v) =>
52
+ typeof v === 'bigint' || typeof v === 'function' ? String(v) : v))];
53
+ } catch (e2) {
54
+ return ['text', String(data)];
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Return a task result (THE way to end a task).
61
+ *
62
+ * Builds the structured result envelope:
63
+ * { ecld: 1, type: 'json'|'text'|'base64', data: ..., esr: ... }
64
+ *
65
+ * - `data` is encoded automatically (see encodeResultData).
66
+ * - `state: true` (default) attaches the ESR state of every key this task
67
+ * touched; `state: 'meta'` attaches {key, version, cid} without the state
68
+ * payload; `state: false` attaches nothing.
69
+ * - `keys: [...]` restricts the attachment to those keys (already-touched
70
+ * only -- to attach an untouched key, use the async esrFetch helper).
71
+ *
72
+ * ESR attachment is best-effort: an ESR-disabled build (or any attachment
73
+ * error) yields esr: null, never a failed task.
74
+ */
75
+ function ecldResult(data = null, { state = true, keys = null } = {}) {
76
+ const [type, encoded] = encodeResultData(data);
77
+ const envelope = { ecld: 1, type, data: encoded, esr: null };
78
+ if (state || keys) {
79
+ try {
80
+ // eslint-disable-next-line global-require
81
+ const ecldState = require('./ecld_state');
82
+ envelope.esr = ecldState.ledgerSnapshot(state === true, keys);
83
+ } catch (e) {
84
+ envelope.esr = null;
85
+ }
86
+ }
87
+ return [0, JSON.stringify(envelope)];
88
+ }
89
+
90
+ /** Legacy alias: same builder, ESR attachment off -- today's behavior. */
91
+ function ___etny_result___(data) {
92
+ return ecldResult(data, { state: false });
93
+ }
94
+
95
+ /**
96
+ * Standard state-fetch task body: awaits the reads (populating the task
97
+ * ledger), then attaches them. The runner's cache-gated read submits this on
98
+ * a cache miss: `await esrFetch('profile-7')`.
99
+ */
100
+ async function esrFetch(...keys) {
101
+ try {
102
+ // eslint-disable-next-line global-require
103
+ const { StateRegistry } = require('./ecld_state');
104
+ const reg = new StateRegistry();
105
+ for (const k of keys) {
106
+ // eslint-disable-next-line no-await-in-loop
107
+ await reg.get(k);
108
+ }
109
+ } catch (e) { /* ESR-disabled build: esr stays null */ }
110
+ return ecldResult(null, { keys: keys.length ? keys : null });
111
+ }
112
+
113
+ // Names of *_ADDRESS-style enclave config vars that are set but EMPTY.
114
+ // The enclave is sealed: env can only come from the image, so a present-but-
115
+ // empty required value is always a build/render defect, never a runtime
116
+ // choice. ESR_* vars only exist when the project enabled ESR at build time.
117
+ function emptyRequiredConfig() {
118
+ const required = ['ESR_CONTRACT_ADDRESS'];
119
+ return required.filter(
120
+ (name) => name in process.env && !String(process.env[name]).trim()
121
+ );
122
+ }
123
+
124
+ async function executeTask(payload, input) {
125
+ const missing = emptyRequiredConfig();
126
+ if (missing.length) {
127
+ return [
128
+ TaskStatus.CONFIG_ERROR,
129
+ 'ENCLAVE CONFIG ERROR: required value(s) empty inside the enclave: ' +
130
+ missing.join(', ') +
131
+ ' | The enclave is sealed, so this value had to be baked at build' +
132
+ ' time and was not. Re-run ecld-build (it validates ESR config)' +
133
+ ' and republish.'
134
+ ];
135
+ }
136
+ if (backendImportError !== null) {
137
+ return [
138
+ TaskStatus.IMPORT_ERROR,
139
+ 'BACKEND IMPORT ERROR: ' + backendImportError +
140
+ ' | The serverless backend failed to load inside the enclave, so none of its' +
141
+ ' functions are available. Common causes: a module missing from the enclave' +
142
+ ' image dependencies, or a bad require() inside backend.js.'
143
+ ];
144
+ }
145
+ const scope = {
146
+ '___etny_result___': ___etny_result___, // legacy alias (no ESR)
147
+ 'ecldResult': ecldResult, // the result API
148
+ 'esrFetch': esrFetch, // standard state-fetch task
149
+ ...backendFunctions,
150
+ };
151
+ // State ownership / ACL API (present only in ESR-enabled builds). Enforced
152
+ // inside ecld_state against the trustedzone-attested caller.
153
+ try {
154
+ // eslint-disable-next-line global-require
155
+ const ecldState = require('./ecld_state');
156
+ for (const name of ['taskCaller', 'esrGrant', 'esrRevoke',
157
+ 'esrSetPublicRead', 'esrTransfer', 'esrOwner', 'esrAcl', 'esrNonce']) {
158
+ if (!(name in scope)) scope[name] = ecldState[name];
159
+ }
160
+ } catch (e) { /* non-ESR build */ }
161
+ return exec(payload, input, scope);
162
+ }
163
+
164
+ async function exec(payload, input, globals = null) {
165
+ try {
166
+ if (payload && payload !== "") {
167
+ const scope = globals || {};
168
+ if (input && input !== "") {
169
+ scope['___etny_data_set___'] = input;
170
+ }
171
+ // `with` exposes every backend function and ___etny_data_set___ to
172
+ // the payload by bare name — matching how payloads are written
173
+ // (e.g. `processData(___etny_data_set___)`).
174
+ with (scope) {
175
+ // ESR reads/commits are async, so a state-using payload
176
+ // (`esrIncrement()`, `await reg.commit(...)`) evaluates to a
177
+ // Promise. Await it so the finished value -- not the pending
178
+ // Promise -- is what gets wrapped. Non-async payloads await to
179
+ // themselves, so this is transparent for plain results.
180
+ const value = await eval(payload);
181
+ // A payload that called ecldResult/___etny_result___ itself
182
+ // already holds the finished [0, envelopeJson] tuple -- pass
183
+ // it through instead of wrapping the result a second time.
184
+ if (Array.isArray(value) && value.length === 2 &&
185
+ value[0] === 0 && typeof value[1] === 'string') {
186
+ return value;
187
+ }
188
+ return ___etny_result___(value);
189
+ }
190
+ } else {
191
+ return [TaskStatus.PAYLOAD_NOT_DEFINED, 'Could not find the source file to execute'];
192
+ }
193
+
194
+ // return [TaskStatus.SUCCESS, 'TASK EXECUTED SUCCESSFULLY'];
195
+ } catch (error) {
196
+ // A task returns its result by RETURNING (via ___etny_result___), not by
197
+ // throwing, so anything caught here is a genuine failure. In every branch
198
+ // deliver the FULL stack trace to the data owner AS THE RESULT (visible in
199
+ // their runner output) and never crash the enclave -- always return a
200
+ // normal [code, data] tuple so the trustedzone stops waiting. Honor the
201
+ // embedded-result SUCCESS (code 0) case first.
202
+ try {
203
+ if (error && error.args && error.args[0] && error.args[0][0] === 0) {
204
+ return [TaskStatus.SUCCESS, error.args[0][1]];
205
+ }
206
+ } catch (e) { /* fall through to failure reporting */ }
207
+
208
+ // A duplicate-suppressed commit (StateNonceError) gets its own task
209
+ // code so the dApp can distinguish "already applied" from a failure.
210
+ if (error && error.constructor && error.constructor.name === 'StateNonceError') {
211
+ return [TaskStatus.ESR_NONCE_VIOLATION, 'ESR_NONCE_VIOLATION: ' + error.message];
212
+ }
213
+ const detail = (error && (error.stack || error.message)) || String(error);
214
+ console.error('Error in payload execution -- full stack follows:\n' + detail);
215
+
216
+ if (error instanceof ReferenceError) {
217
+ return [TaskStatus.SYSTEM_ERROR, 'SYSTEM_ERROR (ReferenceError): ' + detail];
218
+ } else if (error instanceof EvalError) {
219
+ return [TaskStatus.KEY_ERROR, 'KEY_ERROR (EvalError): ' + detail];
220
+ } else if (error instanceof SyntaxError) {
221
+ return [TaskStatus.SYNTAX_WARNING, 'SYNTAX_WARNING (SyntaxError): ' + detail];
222
+ } else {
223
+ return [TaskStatus.BASE_EXCEPTION, 'BASE_EXCEPTION: ' + detail];
224
+ }
225
+ }
226
+ }
227
+
228
+ module.exports = {
229
+ executeTask
230
+ };