@ethernity-cloud/sdk-js 1.7.0 → 1.7.7

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
@@ -41,19 +41,22 @@ The sdk has been tested on the following operating systems:
41
41
  - Bloxberg:
42
42
  - Testnet - tested and working. CAS-attested: the securelock is
43
43
  provisioned by the ethernity-cas validator set exactly as on mainnet.
44
- `ecld-publish` registers the securelock session on-chain in the
45
- SessionRegistry (`0xcb1F389b...`, see `cas/config.js`), resolves a CAS
46
- from the ValidatorRegistry (`0xa821b36F...`, or `ECLD_CAS_ADDR=host:port`)
44
+ `ecld-publish` registers the image in the ImageRegistry
45
+ (ECImageRegistryV3, `0xa372a6e1...`) before anything discloses its
46
+ name, which makes the name the publishing wallet's; then registers the
47
+ securelock session on-chain in the SessionRegistry (`0x44442968...`, see
48
+ `cas/config.js`), which takes it only from that wallet, resolves a CAS
49
+ from the ValidatorRegistry (`0x0014aB10...`, or `ECLD_CAS_ADDR=host:port`)
47
50
  for the compose and the certificate harvest, takes the trustedzone
48
- session name from the ImageRegistry (ECImageRegistryV2,
49
- `0x99A84C62...`), and links the session to the published image. `ecld-build` signs the securelock `--production`.
51
+ session name from the ImageRegistry, and links the session to the
52
+ published image. `ecld-build` signs the securelock `--production`.
50
53
  Harvesting the certificate needs SGX.
51
54
  - Testnet Unsafe - the unsafe network beside the testnet (see below).
52
55
  - Mainnet - to be provided during the following updates
53
56
  - LitVM:
54
57
  - LiteForge - CAS-attested the same way, by LitVM's own ethernity-cas
55
58
  validator set (SessionRegistry `0x8ad24b3F...`, ValidatorRegistry
56
- `0x2E27677f...`).
59
+ `0xbE3759f3...`).
57
60
  - LiteForge Unsafe - the unsafe network beside LiteForge (see below).
58
61
  - Polyhon:
59
62
  - Amoy Testnet - to be provided during the following updates
@@ -108,6 +111,27 @@ sets the enclave heap (default `1024M`).
108
111
  npm run ecld-publish
109
112
  ```
110
113
  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.
114
+ `DEVELOPER_FEE` in `.env` is your fee as the image's publisher, in percent of
115
+ each task's base price (the node's price): the network adds it to what the
116
+ dApp user pays and pays it to the image's reward address when the task is
117
+ validated. Default 0; the registry accepts up to 15, and the fee is fixed
118
+ once the image is registered. The reward address is the publishing wallet
119
+ unless `REWARD_ADDRESS` names another one; the image's owner can change it
120
+ later with the registry's `changeImageRewardAddress`.
121
+
122
+ How the image reaches the network: unless `.env` names an IPFS API of your
123
+ own (`IPFS_ENDPOINT` other than the public `ipfs.ethernity.cloud`), the
124
+ publish runs its own IPFS node in docker (`ipfs/kubo`), adds the image and
125
+ its compose there and peers it with the network's bootnode, which pins
126
+ every registered image. On a network whose image registry records images
127
+ before their certificate (the bloxberg testnet), the publish registers the
128
+ image from your wallet first (`registerImage`, with your node's address),
129
+ has the certificate extracted (on your SGX host, or by
130
+ publickey.ethernity.cloud, which fetches the image from your node or the
131
+ bootnode and reports its progress) and writes it from the same wallet
132
+ (`setImageCert`). Keep the publish running until it prints the registration:
133
+ your node is the image's first source. `ECLD_KUBO_SWARM_PORT` (default
134
+ 4001) is the port your node listens on when it is free.
111
135
 
112
136
  - **Test (local, no chain)**: To run your backend locally with the enclave's own
113
137
  executor — no SGX, no gas, instant — run:
@@ -189,63 +213,63 @@ function hello(msg='World') {
189
213
 
190
214
  module.exports = { hello };
191
215
  ```
192
- From your frontend application, using the ethernity cloud runner library, you will be calling the function as seen in the below example, where we pass `hello("World");` to be executed on the backend which will run in the Blockchain:
216
+ From your frontend application, using the ethernity cloud runner library (`@ethernity-cloud/runner`, installed by `ecld-init` at the version this SDK depends on), you will be calling the function as seen in the below example, where we pass `hello("World");` to be executed on the backend which will run in the Blockchain. `npm start` runs `src/preStart.mjs` first, which fills the constants at the top from the project's `.env`: the securelock name the dApp is published as, the trustedzone it was built against, the network's token address and chain id, the `-unsafe` network when the dApp runs without a CAS, and the IPFS endpoint.
193
217
  ```js
194
- const AppCss = require('./App.css');
218
+ import './App.css';
195
219
  import EthernityCloudRunner from "@ethernity-cloud/runner";
196
- import {ECEvent, ECRunner, ECStatus} from "@ethernity-cloud/runner/enums";
197
- import Web3 from 'web3';
220
+ import { ECStatus } from "@ethernity-cloud/runner/enums";
198
221
 
222
+ // Filled in from the project's .env by src/preStart.mjs on `npm start`.
199
223
  const PROJECT_NAME = "";
224
+ const TRUSTED_ZONE_IMAGE = "";
225
+ const NETWORK_ADDRESS = "";
226
+ const CHAIN_ID = null;
227
+ const UNSAFE_NETWORK = null;
200
228
  const IPFS_ENDPOINT = "";
201
229
 
230
+ // A call into src/serverless/backend.js; its return value is the task result.
202
231
  const code = `hello("World");`;
203
232
 
204
233
  function App() {
205
234
  const executeTask = async () => {
206
- const runner = new EthernityCloudRunner();
207
- // this is a server provided by Ethernity CLOUD, please bear in mind that you can use your own Decentralized Storage server
208
- const ipfsAddress = IPFS_ENDPOINT;
209
- runner.initializeStorage(ipfsAddress);
210
- console.log(PROJECT_NAME)
211
- const onTaskProgress = (e) => {
212
- if (e.detail.status === ECStatus.ERROR) {
213
- console.error(e.detail.message);
214
- } else {
215
- console.log(e.detail.message);
216
- }
217
- };
218
-
219
- const onTaskCompleted = (e) => {
220
- console.log(`Task Result: ${e.detail.message.result}`);
221
- // display the result in page below the buttons
222
- const result = document.createElement("p");
223
- result.innerHTML = `Task Result: ${e.detail.message.result}`;
224
- document.body.appendChild(result);
235
+ // The browser wallet (MetaMask) signs and pays for the order.
236
+ const runner = new EthernityCloudRunner(NETWORK_ADDRESS, {}, CHAIN_ID || undefined);
237
+ if (UNSAFE_NETWORK) {
238
+ await runner.setNetwork(...UNSAFE_NETWORK);
225
239
  }
226
-
227
- runner.addEventListener(ECEvent.TASK_PROGRESS, onTaskProgress);
228
- runner.addEventListener(ECEvent.TASK_COMPLETED, onTaskCompleted);
229
-
230
- await runner.run(PROJECT_NAME,
231
- code,
232
- '',
233
- { taskPrice: 10, cpu: 1, memory: 1, storage: 10, bandwidth: 1, duration: 1, validators: 1 });
240
+ runner.initializeStorage(IPFS_ENDPOINT);
241
+
242
+ // Events are named by task status; detail is { message, status, progress }.
243
+ runner.addEventListener(ECStatus.DEFAULT, (e) => {
244
+ console.log(`[${e.detail.progress}] ${e.detail.message}`);
245
+ });
246
+ runner.addEventListener(ECStatus.ERROR, (e) => {
247
+ console.error(e.detail.message);
248
+ });
249
+ runner.addEventListener(ECStatus.SUCCESS, async () => {
250
+ const result = await runner.getResult();
251
+ console.log(`Task Result: ${result}`);
252
+ const line = document.createElement("p");
253
+ line.textContent = `Task Result: ${result}`;
254
+ document.body.appendChild(line);
255
+ });
256
+
257
+ const resources = { taskPrice: 10, cpu: 1, memory: 1, storage: 10, bandwidth: 1, duration: 1, validators: 1 };
258
+ await runner.run(resources, PROJECT_NAME, code, '', TRUSTED_ZONE_IMAGE);
234
259
  };
260
+
235
261
  const connectWallet = async () => {
236
- if (window.ethereum) {
237
- window.web3 = new Web3(window.ethereum);
238
- try {
239
- // Request account access
240
- await window.ethereum.request({ method: 'eth_requestAccounts' });
241
- console.log("Wallet connected");
242
- } catch (error) {
243
- console.error("User denied account access");
244
- }
245
- } else {
246
- console.log('Please install MetaMask!');
247
- }
248
- };
262
+ if (!window.ethereum) {
263
+ console.log('Please install MetaMask!');
264
+ return;
265
+ }
266
+ try {
267
+ await window.ethereum.request({ method: 'eth_requestAccounts' });
268
+ console.log("Wallet connected");
269
+ } catch (error) {
270
+ console.error("User denied account access");
271
+ }
272
+ };
249
273
 
250
274
  return (
251
275
  <div className="container">
package/cas/config.js CHANGED
@@ -14,15 +14,15 @@
14
14
  // Kept in sync with etny-{pynithy,nodenithy}/v3/networks.yaml
15
15
  // (cas_session_registry_address, validator_registry_address).
16
16
  const VALIDATOR_REGISTRY = {
17
- 'etny-pynithy-testnet': '0xa821b36F378F76c793c436F5f9c9CC36c684eBE5',
18
- 'etny-nodenithy-testnet': '0xa821b36F378F76c793c436F5f9c9CC36c684eBE5',
19
- 'ecld-pynithy-litvm-testnet': '0x2E27677fb67531eb09134fE331C27899f87ADe10',
20
- 'ecld-nodenithy-litvm-testnet': '0x2E27677fb67531eb09134fE331C27899f87ADe10',
17
+ 'etny-pynithy-testnet': '0x0014aB102FEa6b02e3D793FF4b9a0Af309fB4E14',
18
+ 'etny-nodenithy-testnet': '0x0014aB102FEa6b02e3D793FF4b9a0Af309fB4E14',
19
+ 'ecld-pynithy-litvm-testnet': '0xbE3759f327e8643fC5A1717fb2072A0dAcBAD29E',
20
+ 'ecld-nodenithy-litvm-testnet': '0xbE3759f327e8643fC5A1717fb2072A0dAcBAD29E',
21
21
  };
22
22
 
23
23
  const SESSION_REGISTRY = {
24
- 'etny-pynithy-testnet': '0xcb1F389bF4524d1D61EDcbC24eC1F1F9C3FF4Fa6',
25
- 'etny-nodenithy-testnet': '0xcb1F389bF4524d1D61EDcbC24eC1F1F9C3FF4Fa6',
24
+ 'etny-pynithy-testnet': '0x444429681fCb26470Cb12B5436E48402cF7478A3',
25
+ 'etny-nodenithy-testnet': '0x444429681fCb26470Cb12B5436E48402cF7478A3',
26
26
  'ecld-pynithy-litvm-testnet': '0x8ad24b3F406A41a0F8D3440021792EB203957F43',
27
27
  'ecld-nodenithy-litvm-testnet': '0x8ad24b3F406A41a0F8D3440021792EB203957F43',
28
28
  };
package/cas/resolver.js CHANGED
@@ -3,9 +3,13 @@
3
3
  // The validators are enumerated from the ValidatorRegistry and their enclave
4
4
  // endpoints read from the CasKeyStore the registry names via keyStore() --
5
5
  // the same chain data the node's own resolver uses before every task. Only
6
- // /dns4, /dns, /ip4 and /ip6 endpoints are dialled: the SDK runs on a
7
- // developer's machine with no Tor proxy. An endpoint is taken only after its
8
- // REST port answered GET /validator/identity.
6
+ // /dns4, /dns, /ip4 and /ip6 endpoints are used: the SDK runs on a
7
+ // developer's machine with no Tor proxy. The CAS the publish dials is taken
8
+ // only after its REST port answered GET /validator/identity. The CAS the
9
+ // published compose names is the first active validator in registry order,
10
+ // unprobed: the node and the extraction service resolve a CAS again before
11
+ // they run the compose, and a rerun of the same publish renders the same
12
+ // compose while the validator set is unchanged.
9
13
  //
10
14
  // Multiaddrs advertise the ENCLAVE port; the REST port follows the pairing
11
15
  // convention rest = 9081 + (enclave - 19765). Enclave ports outside
@@ -53,23 +57,33 @@ async function identityAnswers(host, restPort, timeoutMs) {
53
57
  }
54
58
  }
55
59
 
56
- // "host:port" (the ENCLAVE port) of the first active validator whose REST port
57
- // answers, sweeping from a rotating offset; null when none answered.
58
- async function resolveCas(rpcUrl, registryAddress, probeTimeoutMs = 10000, startAt = null) {
60
+ // The ValidatorRegistry, the CasKeyStore it names and its validator count.
61
+ async function registry(rpcUrl, registryAddress) {
59
62
  const provider = new ethers.providers.JsonRpcProvider({ url: rpcUrl, timeout: 30000 });
60
63
  const reg = new ethers.Contract(registryAddress, REGISTRY_ABI, provider);
61
64
  const total = (await reg.validatorCount()).toNumber();
62
- if (total === 0) return null;
63
- const store = new ethers.Contract(await reg.keyStore(), STORE_ABI, provider);
64
- const offset = startAt === null ? Math.floor(Math.random() * total) : startAt % total;
65
- for (let step = 0; step < total; step++) {
66
- const v = await reg.validatorSet((offset + step) % total);
67
- if (!(await reg.isValidator(v))) continue;
68
- for (const ma of await store.multiaddrsOf(v)) {
69
- const parsed = parseMultiaddr(ma);
70
- if (!parsed) continue;
71
- if (await identityAnswers(parsed.host, restPortFor(parsed.port), probeTimeoutMs)) {
72
- return `${parsed.host}:${parsed.port}`;
65
+ const store = total ? new ethers.Contract(await reg.keyStore(), STORE_ABI, provider) : null;
66
+ return { reg, store, total };
67
+ }
68
+
69
+ // The usable endpoints of validator `index` as {host, port} (the ENCLAVE
70
+ // port), or none when it is not an active validator.
71
+ async function endpointsOf({ reg, store }, index) {
72
+ const v = await reg.validatorSet(index);
73
+ if (!(await reg.isValidator(v))) return [];
74
+ return (await store.multiaddrsOf(v)).map(parseMultiaddr).filter(Boolean);
75
+ }
76
+
77
+ // "host:port" (the ENCLAVE port) of the first active validator whose REST port
78
+ // answers, sweeping from a rotating offset; null when none answered.
79
+ async function resolveCas(rpcUrl, registryAddress, probeTimeoutMs = 10000, startAt = null) {
80
+ const set = await registry(rpcUrl, registryAddress);
81
+ if (set.total === 0) return null;
82
+ const offset = startAt === null ? Math.floor(Math.random() * set.total) : startAt % set.total;
83
+ for (let step = 0; step < set.total; step++) {
84
+ for (const { host, port } of await endpointsOf(set, (offset + step) % set.total)) {
85
+ if (await identityAnswers(host, restPortFor(port), probeTimeoutMs)) {
86
+ return `${host}:${port}`;
73
87
  }
74
88
  }
75
89
  }
@@ -92,4 +106,21 @@ async function casAddressFor(rpcUrl, registryAddress) {
92
106
  return resolved;
93
107
  }
94
108
 
95
- module.exports = { parseMultiaddr, restPortFor, identityAnswers, resolveCas, casAddressFor };
109
+ // The CAS the published compose names: ECLD_CAS_ADDR when set, else
110
+ // "host:port" (the ENCLAVE port) of the first active validator in registry
111
+ // order, unprobed. Throws when neither yields one.
112
+ async function publishedCasAddress(rpcUrl, registryAddress) {
113
+ const override = String(process.env.ECLD_CAS_ADDR || '').trim();
114
+ if (override) return override;
115
+ if (!registryAddress) {
116
+ throw new Error('no ValidatorRegistry is known for this network and ECLD_CAS_ADDR is not set');
117
+ }
118
+ const set = await registry(rpcUrl, registryAddress);
119
+ for (let index = 0; index < set.total; index++) {
120
+ const [first] = await endpointsOf(set, index);
121
+ if (first) return `${first.host}:${first.port}`;
122
+ }
123
+ throw new Error(`no active validator in ${registryAddress} publishes a usable endpoint`);
124
+ }
125
+
126
+ module.exports = { parseMultiaddr, restPortFor, identityAnswers, resolveCas, casAddressFor, publishedCasAddress };
@@ -14,11 +14,21 @@
14
14
  const crypto = require('crypto');
15
15
  const axios = require('axios');
16
16
  const { ethers } = require('ethers');
17
+ const { revertReason } = require('../revert.js');
17
18
 
19
+ // `latest` and `record` name a version by its record key: a record id
20
+ // (keccak256(abi.encode(name, sessionHash))), or the body's hash on the
21
+ // registries that key records by it. The record holds the body's hash on both.
18
22
  const ABI = [
19
23
  'function register(bytes32 sessionHash, string name, string bodyCid, string imageCid, uint8 hashAlgo, (string[] tolerate, string[] ignoreAdvisories, string[] mrenclaves, uint16 minIsvSvn, bool debugAllowed, bool skipQuoteVerification) rulesIn) returns (uint32)',
20
24
  'function latest(string name) view returns (bytes32)',
21
- 'function linkImage(bytes32 sessionHash, string imageCid)',
25
+ 'function record(bytes32 id) view returns (bytes32 sessionHash, address creator, string enclaveName, uint32 version, string bodyCid, string imageCid, uint8 hashAlgo, uint64 registeredAt, bool exists)',
26
+ 'function linkImage(bytes32 id, string imageCid)',
27
+ 'error NotCreator(address creator, address caller)',
28
+ 'error DuplicateSession(bytes32 recordId)',
29
+ 'error NotImageNameOwner(string project, address nameOwner, address caller)',
30
+ 'error NotAllowedPublisher(address caller)',
31
+ 'error AmbiguousSecurelockName(string name)',
22
32
  ];
23
33
 
24
34
  const HASH_ALGO_SHA256 = 1;
@@ -78,52 +88,90 @@ function contract(rpcUrl, registryAddress, key) {
78
88
  return new ethers.Contract(registryAddress, ABI, signer);
79
89
  }
80
90
 
91
+ // The latest version of `name`: {id, hash, creator}, or null when it has none.
92
+ async function latestRecord(reg, name) {
93
+ const id = await reg.latest(name);
94
+ if (/^0x0+$/.test(id)) return null;
95
+ const rec = await reg.record(id);
96
+ return { id, hash: rec.sessionHash.toLowerCase(), creator: rec.creator };
97
+ }
98
+
99
+ // Send `method(...args)` to the registry. It is simulated first, so a call
100
+ // the registry refuses stops with the registry's reason (`refused` names the
101
+ // call), then sent with its gas estimate plus 30%: a registration's cost
102
+ // grows with the length of the name. The wallet signs for the chain its RPC
103
+ // serves (ethers takes no chainId override on a contract call), so an RPC
104
+ // serving another chain than `chainId` is refused before anything is sent.
105
+ async function send(reg, chainId, refused, method, ...args) {
106
+ const { chainId: served } = await reg.provider.getNetwork();
107
+ if (served !== chainId) {
108
+ throw new Error(`SessionRegistry: the RPC serves chain ${served}, not ${chainId}`);
109
+ }
110
+ try {
111
+ await reg.callStatic[method](...args);
112
+ } catch (e) {
113
+ throw new Error(`SessionRegistry refuses ${refused}: ${revertReason(e, reg.interface)}`);
114
+ }
115
+ const gas = await reg.estimateGas[method](...args);
116
+ const tx = await reg[method](...args, {
117
+ gasLimit: gas.mul(13).div(10), gasPrice: ethers.utils.parseUnits('1', 'mwei'),
118
+ });
119
+ const rcpt = await tx.wait();
120
+ if (rcpt.status !== 1) throw new Error(`SessionRegistry.${method} reverted for ${refused} (tx ${tx.hash})`);
121
+ }
122
+
81
123
  // Pin `body` and register it under its own `name:`. Returns
82
124
  // {name, hash, cid, registered}: `registered` is false when the chain already
83
- // held these exact bytes as the name's latest version.
125
+ // held these exact bytes as the name's latest version, registered by this
126
+ // wallet.
84
127
  async function register(rpcUrl, chainId, registryAddress, key, body, ipfsApiUrl, imageCid = '') {
85
128
  const { name, rules } = parseNameAndRules(body.toString('utf8'));
86
129
  if (!name) throw new Error('the session body has no `name:`');
87
130
  const hash = sessionHash(body);
88
131
  const reg = contract(rpcUrl, registryAddress, key);
89
132
  const cid = await pinBody(ipfsApiUrl, body);
90
- if ((await reg.latest(name)).toLowerCase() === hash) {
133
+ const latest = await latestRecord(reg, name);
134
+ if (latest && latest.hash === hash && latest.creator.toLowerCase() === (await reg.signer.getAddress()).toLowerCase()) {
91
135
  return { name, hash, cid, registered: false };
92
136
  }
93
- const tx = await reg.register(hash, name, cid, imageCid, HASH_ALGO_SHA256, rules, {
94
- gasLimit: 800000, gasPrice: ethers.utils.parseUnits('1', 'mwei'), chainId,
95
- });
96
- const rcpt = await tx.wait();
97
- if (rcpt.status !== 1) {
98
- throw new Error(`SessionRegistry.register reverted for ${name} (tx ${tx.hash}): this name's creator may be another wallet, or the SessionRules shape differs from the ABI`);
99
- }
137
+ await send(reg, chainId, name, 'register', hash, name, cid, imageCid, HASH_ALGO_SHA256, rules);
100
138
  return { name, hash, cid, registered: true };
101
139
  }
102
140
 
103
- // Point the name's latest version at the published image CID.
104
- async function linkImage(rpcUrl, chainId, registryAddress, key, name, imageCid) {
141
+ // Point the name's latest version at the published image CID. A node behind
142
+ // the chain can answer that a name registered minutes earlier has no record,
143
+ // so the name is read again every 15 seconds for `waitMs` before it is called
144
+ // unregistered.
145
+ async function linkImage(rpcUrl, chainId, registryAddress, key, name, imageCid, waitMs = 600000) {
105
146
  const reg = contract(rpcUrl, registryAddress, key);
106
- const hash = await reg.latest(name);
107
- if (/^0x0+$/.test(hash)) throw new Error(`no registered session named ${name}`);
108
- const tx = await reg.linkImage(hash, imageCid, { gasLimit: 300000, gasPrice: ethers.utils.parseUnits('1', 'mwei'), chainId });
109
- const rcpt = await tx.wait();
110
- if (rcpt.status !== 1) throw new Error(`SessionRegistry.linkImage reverted for ${name} (tx ${tx.hash})`);
147
+ const deadline = Date.now() + waitMs;
148
+ let latest = await latestRecord(reg, name);
149
+ while (!latest) {
150
+ if (Date.now() >= deadline) throw new Error(`no registered session named ${name}`);
151
+ await sleep(15000);
152
+ latest = await latestRecord(reg, name);
153
+ }
154
+ await send(reg, chainId, `the link of ${name}`, 'linkImage', latest.id, imageCid);
111
155
  }
112
156
 
113
157
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
114
158
 
115
- // Block until the chain reports `name` at `hash`, then hold while the
116
- // validators pick it up: the elected writer's poll (ECAS_SESSION_WATCH_SECS,
117
- // 60 s) notices the registration and generates the secrets, its recordKey
118
- // transaction confirms, and the other validators' next poll adopts the
119
- // recorded blob. The enclave cannot retry and the validator it dials is the
120
- // node's choice, so the wait covers the LAST validator to adopt.
159
+ // Block until the chain reports `name`'s latest version at body hash `hash`,
160
+ // then hold while the validators pick it up: the elected writer's poll
161
+ // (ECAS_SESSION_WATCH_SECS, 60 s) notices the registration and generates the
162
+ // secrets, its recordKey transaction confirms, and the other validators' next
163
+ // poll adopts the recorded blob. The enclave cannot retry and the validator
164
+ // it dials is the node's choice, so the wait covers the LAST validator to
165
+ // adopt.
121
166
  async function waitVisible(rpcUrl, registryAddress, name, hash, timeoutMs = 180000) {
122
167
  const reg = contract(rpcUrl, registryAddress, null);
123
168
  const deadline = Date.now() + timeoutMs;
124
169
  for (;;) {
125
170
  let got;
126
- try { got = (await reg.latest(name)).toLowerCase(); } catch (e) { got = `(read failed: ${e.message})`; }
171
+ try {
172
+ const latest = await latestRecord(reg, name);
173
+ got = latest ? latest.hash : '(none)';
174
+ } catch (e) { got = `(read failed: ${e.message})`; }
127
175
  if (got === hash.toLowerCase()) {
128
176
  console.log(`\t✔ session ${name} visible on chain at ${got}`);
129
177
  break;
package/info.js CHANGED
@@ -46,7 +46,7 @@ const NETWORKS = {
46
46
  BLOXBERG_TESTNET: {
47
47
  type: 'testnet', chainId: 8995, rpc: 'https://bloxberg.ethernity.cloud',
48
48
  protocol: '0x02882F03097fE8cD31afbdFbB5D72a498B41112c',
49
- imageRegistry: '0x99A84C624C028bdf0a855A1E9E3f2fcf7275B3D8',
49
+ imageRegistry: '0xa372a6e1Eb7Fcf343AF6b91E809b900C55001CD1',
50
50
  esr: '0x0Ea1728EAE108FD3B9340ae91451348E2Cc6b4E4',
51
51
  },
52
52
  LITVM_LITEFORGE: {
package/init.js CHANGED
@@ -28,6 +28,14 @@ const writeEnv = (key, value) => {
28
28
  fs.writeFileSync(envFile, content, "utf8");
29
29
  };
30
30
 
31
+ // The project name is the securelock's image name, and <name>-unsafe on an
32
+ // -unsafe network. ECImageRegistryV3 takes 1 to 128 of [0-9A-Za-z._-], so a
33
+ // project name is at most 121 of them; spaces are written as '_'. It also
34
+ // begins the securelock's session name, <name>_SECURELOCK_V3_..., which the
35
+ // SessionRegistry refuses when "_SECURELOCK_" occurs in it twice, so a project
36
+ // name does not contain "SECURELOCK".
37
+ const PROJECT_NAME_RULE = /^(?!.*SECURELOCK)[0-9A-Za-z._-]{1,121}$/;
38
+
31
39
  const getProjectName = () => {
32
40
  return new Promise((resolve) => {
33
41
  const askProjectName = () => {
@@ -37,6 +45,11 @@ const getProjectName = () => {
37
45
  "Project name cannot be blank. Please enter a valid name.",
38
46
  );
39
47
  askProjectName();
48
+ } else if (!PROJECT_NAME_RULE.test(projectName.trim().replace(/ /g, "_"))) {
49
+ console.log(
50
+ "A project name is 1 to 121 letters, digits, '.', '-' or '_' (a space becomes '_'), and does not contain SECURELOCK. Please enter a valid name.",
51
+ );
52
+ askProjectName();
40
53
  } else {
41
54
  console.log(`You have chosen the project name: ${projectName}`);
42
55
  resolve(projectName);
@@ -310,9 +323,11 @@ const main = async () => {
310
323
  );
311
324
  }
312
325
  console.log("Installing required packages...");
313
- // Simulate npm install
326
+ // The app runs on the runner at the range this SDK depends on; the spec is
327
+ // quoted because cmd.exe treats a bare ^ as its escape character.
328
+ const runnerSpec = `@ethernity-cloud/runner@${require("./package.json").dependencies["@ethernity-cloud/runner"]}`;
314
329
  execSync(
315
- "npm install @ethernity-cloud/runner@0.0.26 @testing-library/jest-dom@5.17.0 @testing-library/react@13.4.0 @testing-library/user-event@13.5.0 react@18.3.1 react-dom@18.3.1 react-scripts@5.0.1 web-vitals@2.1.4 web3@4.9.0 dotenv@16.4.5",
330
+ `npm install "${runnerSpec}" @testing-library/jest-dom@5.17.0 @testing-library/react@13.4.0 @testing-library/user-event@13.5.0 react@18.3.1 react-dom@18.3.1 react-scripts@5.0.1 web-vitals@2.1.4 dotenv@16.4.5`,
316
331
  { stdio: "inherit" },
317
332
  );
318
333
  } else {
package/localKubo.js ADDED
@@ -0,0 +1,160 @@
1
+ // The Kubo a publish runs for its own upload.
2
+ //
3
+ // An image is published by adding it to IPFS and registering its CID. The
4
+ // public write API of ipfs.ethernity.cloud is closing, so a publish without an
5
+ // IPFS endpoint of its own runs a Kubo in docker (the SDK already requires
6
+ // docker), adds the image through that Kubo's HTTP API with the same call the
7
+ // public API received (so a build gives the same CIDs as before), peers the
8
+ // Kubo with the bootnode, whose mirror pins every registered image and from
9
+ // which the certificate extraction service fetches, and stops it once the
10
+ // certificate is on chain. The Kubo listens for the API on the loopback
11
+ // interface only; its swarm port is published when free, so a publisher with
12
+ // a reachable address is also dialed directly.
13
+ const { execFileSync } = require('child_process');
14
+ const net = require('net');
15
+ const axios = require('axios');
16
+
17
+ const KUBO_IMAGE = process.env.ECLD_KUBO_IMAGE || 'ipfs/kubo:release';
18
+ // The bootnode's IPFS node (ipfs.ethernity.cloud), as the node agents peer with it.
19
+ const BOOTNODE_MULTIADDR = '/dns4/ipfs.ethernity.cloud/tcp/4001/p2p/QmRBc1eBt4hpJQUqHqn6eA8ixQPD3LFcUDsn6coKBQtia5';
20
+ const SWARM_PORT = Number(process.env.ECLD_KUBO_SWARM_PORT || 4001);
21
+ const API_READY_MS = 90000;
22
+ // The public IPFS API: an IPFS_ENDPOINT naming it is not an endpoint of the
23
+ // application's own, so the publish runs its own Kubo instead.
24
+ const PUBLIC_IPFS_HOST = 'ipfs.ethernity.cloud';
25
+
26
+ const PRIVATE = /^\/ip4\/(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|0\.0\.0\.0|169\.254\.)|^\/ip6\/(::1|fe80|fc|fd)/;
27
+
28
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
+
30
+ function portFree(port) {
31
+ return new Promise((resolve) => {
32
+ const server = net.createServer();
33
+ server.once('error', () => resolve(false));
34
+ server.listen(port, '0.0.0.0', () => server.close(() => resolve(true)));
35
+ });
36
+ }
37
+
38
+ function freePort() {
39
+ return new Promise((resolve, reject) => {
40
+ const server = net.createServer();
41
+ server.once('error', reject);
42
+ server.listen(0, '127.0.0.1', () => {
43
+ const { port } = server.address();
44
+ server.close(() => resolve(port));
45
+ });
46
+ });
47
+ }
48
+
49
+ // Whether the configured endpoint is one of the application's own; the public
50
+ // API and an empty setting both mean "run a Kubo for this publish".
51
+ function ownEndpoint(endpoint) {
52
+ if (!endpoint) return false;
53
+ try {
54
+ return new URL(endpoint).hostname !== PUBLIC_IPFS_HOST;
55
+ } catch (e) {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ class LocalKubo {
61
+ constructor(name) {
62
+ this.container = `ecld-kubo-${String(name || 'publish').replace(/[^A-Za-z0-9_.-]/g, '-')}`;
63
+ this.peers = (process.env.ECLD_IPFS_PEERS || BOOTNODE_MULTIADDR).split(',').map((p) => p.trim()).filter(Boolean);
64
+ this.apiUrl = null;
65
+ }
66
+
67
+ async api(command, params, timeout) {
68
+ const response = await axios.post(`${this.apiUrl}/api/v0/${command}`, null, { params, timeout: timeout || 30000 });
69
+ return response.data;
70
+ }
71
+
72
+ // Start the container and wait for its API; peer it with the bootnode.
73
+ // Throws when docker cannot run it or the API does not come up in time.
74
+ async start() {
75
+ const apiPort = await freePort();
76
+ this.apiUrl = `http://127.0.0.1:${apiPort}`;
77
+ try { execFileSync('docker', ['rm', '-f', this.container], { stdio: 'ignore' }); } catch (e) { /* not running */ }
78
+ const args = ['run', '-d', '--name', this.container, '-p', `127.0.0.1:${apiPort}:5001`];
79
+ if (await portFree(SWARM_PORT)) {
80
+ args.push('-p', `${SWARM_PORT}:4001`, '-p', `${SWARM_PORT}:4001/udp`);
81
+ }
82
+ args.push(KUBO_IMAGE, 'daemon', '--init', '--migrate=true');
83
+ try {
84
+ execFileSync('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
85
+ } catch (e) {
86
+ throw new Error(`docker could not start ${KUBO_IMAGE}: ${String(e.stderr || e.message).trim()}`);
87
+ }
88
+ const deadline = Date.now() + API_READY_MS;
89
+ for (;;) {
90
+ try {
91
+ await this.api('id', {}, 5000);
92
+ break;
93
+ } catch (e) {
94
+ if (Date.now() > deadline) {
95
+ this.stop();
96
+ throw new Error(`the Kubo API did not come up in ${API_READY_MS / 1000}s`);
97
+ }
98
+ await sleep(2000);
99
+ }
100
+ }
101
+ // The peering service dials each peer and keeps the connection; the
102
+ // connect is waited for, not forced, since the service's own dial and
103
+ // a swarm/connect of the same peer refuse each other.
104
+ for (const peer of this.peers) {
105
+ try {
106
+ await this.api('swarm/peering/add', { arg: peer }, 15000);
107
+ } catch (e) {
108
+ console.log(`\t⚠ could not peer with ${peer}: ${e.message}`);
109
+ continue;
110
+ }
111
+ const until = Date.now() + 30000;
112
+ while (!(await this.connectedTo(peer)) && Date.now() < until) {
113
+ await sleep(1000);
114
+ }
115
+ if (!(await this.connectedTo(peer))) {
116
+ console.log(`\t⚠ not connected to ${peer} yet; the peering service keeps trying`);
117
+ }
118
+ }
119
+ }
120
+
121
+ // How this Kubo is registered on chain: its first public address with the
122
+ // peer id, or /p2p/<id> when it has none a stranger could dial (the
123
+ // bootnode reaches it over the connection it opened).
124
+ async peerMultiaddr() {
125
+ const identity = await this.api('id', {});
126
+ const peerId = identity.ID;
127
+ for (const address of identity.Addresses || []) {
128
+ if (!PRIVATE.test(address) && !address.includes('/p2p-circuit') && address.includes(`/p2p/${peerId}`)) {
129
+ return address;
130
+ }
131
+ }
132
+ return `/p2p/${peerId}`;
133
+ }
134
+
135
+ // Announce `cid` to the DHT so a node not connected to this one can find
136
+ // it. Best effort.
137
+ async provide(cid) {
138
+ try {
139
+ await axios.post(`${this.apiUrl}/api/v0/routing/provide`, null, { params: { arg: cid }, timeout: 180000 });
140
+ } catch (e) {
141
+ console.log(`\t⚠ could not announce ${cid}: ${e.message}`);
142
+ }
143
+ }
144
+
145
+ async connectedTo(peerMultiaddr) {
146
+ const peerId = peerMultiaddr.split('/p2p/').pop();
147
+ try {
148
+ const peers = (await this.api('swarm/peers', {})).Peers || [];
149
+ return peers.some((p) => p.Peer === peerId);
150
+ } catch (e) {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ stop() {
156
+ try { execFileSync('docker', ['rm', '-f', this.container], { stdio: 'ignore' }); } catch (e) { /* already gone */ }
157
+ }
158
+ }
159
+
160
+ module.exports = { LocalKubo, ownEndpoint, PUBLIC_IPFS_HOST };