@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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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,
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
};
|