@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 +74 -50
- package/cas/config.js +6 -6
- package/cas/resolver.js +49 -18
- package/cas/session_registry.js +72 -24
- package/info.js +1 -1
- package/init.js +17 -2
- package/localKubo.js +160 -0
- package/network.js +51 -0
- package/nodenithy/build/securelock/src/etny_crypto.js +20 -2
- package/nodenithy/build/securelock/src/models.js +3 -5
- package/nodenithy/build/securelock/src/securelock.js.tmpl +136 -70
- package/nodenithy/build.mjs +8 -7
- package/nodenithy/ipfs.mjs +37 -13
- package/nodenithy/run/image_registry.abi +1 -1
- package/nodenithy/run/image_registry.js +258 -68
- package/nodenithy/run/public_key_service.js +10 -3
- package/nodenithy/run/public_key_service.py +12 -6
- package/nodenithy/run.js +160 -88
- package/nodenithy/src/ec_helloworld_example.js +46 -42
- package/nodenithy/src/preStart.mjs +41 -51
- package/package.json +5 -2
- package/publish.js +20 -8
- package/pynithy/build/securelock/src/securelock.py.tmpl +21 -9
- package/pynithy/build.mjs +8 -7
- package/pynithy/ipfs.mjs +37 -13
- package/pynithy/run/image_registry.abi +1 -1
- package/pynithy/run/image_registry.js +258 -68
- package/pynithy/run/public_key_service.js +10 -3
- package/pynithy/run.js +160 -88
- package/pynithy/src/ec_helloworld_example.js +46 -42
- package/pynithy/src/preStart.mjs +41 -51
- package/revert.js +76 -0
- package/run.js +1 -39
- package/nodenithy/src/preStart.js +0 -45
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
49
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
218
|
+
import './App.css';
|
|
195
219
|
import EthernityCloudRunner from "@ethernity-cloud/runner";
|
|
196
|
-
import {
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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': '
|
|
18
|
-
'etny-nodenithy-testnet': '
|
|
19
|
-
'ecld-pynithy-litvm-testnet': '
|
|
20
|
-
'ecld-nodenithy-litvm-testnet': '
|
|
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': '
|
|
25
|
-
'etny-nodenithy-testnet': '
|
|
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
|
|
7
|
-
// developer's machine with no Tor proxy.
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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 };
|
package/cas/session_registry.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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`,
|
|
116
|
-
// validators pick it up: the elected writer's poll
|
|
117
|
-
// 60 s) notices the registration and generates the
|
|
118
|
-
// transaction confirms, and the other validators' next
|
|
119
|
-
// recorded blob. The enclave cannot retry and the validator
|
|
120
|
-
// node's choice, so the wait covers the LAST validator to
|
|
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 {
|
|
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: '
|
|
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
|
-
//
|
|
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
|
-
|
|
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 };
|