@leofcoin/endpoints 0.4.11 → 0.5.0
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 +91 -1
- package/exports/binary-http.d.ts +4 -0
- package/exports/binary-http.js +54 -0
- package/exports/binary-socket.d.ts +4 -0
- package/exports/binary-socket.js +57 -0
- package/exports/explorer.d.ts +2 -0
- package/exports/explorer.js +117 -0
- package/exports/http.js +90 -69
- package/exports/manager-receipts.d.ts +43 -0
- package/exports/manager-receipts.js +117 -0
- package/exports/manager.d.ts +2 -0
- package/exports/manager.js +137 -0
- package/exports/rpc-handler.d.ts +2 -0
- package/exports/rpc-handler.js +18 -0
- package/exports/ws.d.ts +1 -4
- package/exports/ws.js +11 -9
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# Binary RPC migration
|
|
2
|
+
|
|
3
|
+
Protocol 0.5.0 uses `application/octet-stream` HTTP and binary-only WebSocket
|
|
4
|
+
frames. JSON bodies/query parameters and text socket frames are unsupported.
|
|
5
|
+
Use the matching `@leofcoin/endpoint-clients@0.4` rather than `response.json()`.
|
|
6
|
+
See [wire format, rollout and upstream release gates](../../docs/binary-wire-protocol.md).
|
|
7
|
+
|
|
1
8
|
# endpoints
|
|
2
9
|
|
|
3
10
|
```js
|
|
@@ -6,4 +13,87 @@ http(chain, port, networkVersion)
|
|
|
6
13
|
|
|
7
14
|
```js
|
|
8
15
|
wss(chain, port, networkVersion)
|
|
9
|
-
```
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Generic contract reads
|
|
19
|
+
|
|
20
|
+
POST /staticCall accepts a binary map containing `contract`, `method` and an
|
|
21
|
+
array `params` (default `[]`). Native BigInts and byte arrays retain their types;
|
|
22
|
+
there is no JSON boxing or revival. Use the matching HTTP/WebSocket clients.
|
|
23
|
+
Malformed parameters return HTTP 400; rejected read-only queries return HTTP 422.
|
|
24
|
+
All reads delegate to the chain's isolated `staticCall` path.
|
|
25
|
+
|
|
26
|
+
## Public read-only explorer
|
|
27
|
+
|
|
28
|
+
Deploy/restart the updated endpoints build before enabling anonymous browser
|
|
29
|
+
exploration. These binary POST routes never access wallets, select accounts, sign,
|
|
30
|
+
submit transactions or fetch missing content from peers. Responses carry
|
|
31
|
+
`apiVersion: 1` and `chainId`. Except status, queries must include the expected
|
|
32
|
+
`chainId`; a changed chain returns HTTP 409. Non-POST requests return HTTP 405.
|
|
33
|
+
|
|
34
|
+
| Route | Binary fields | Result |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `/explorer/status` | none | Genesis identity, source tip, connected peer IDs, sync state |
|
|
37
|
+
| `/explorer/blocks` | `from`, `count` (1–25), `chainId` | Applied block snapshot, zero-based indexes |
|
|
38
|
+
| `/explorer/block` | `hash`, `chainId` | Hash-checked local content as Uint8Array, maximum 1 MiB |
|
|
39
|
+
| `/explorer/transaction` | `hash`, `chainId` | Hash-checked local transaction/pool content as Uint8Array |
|
|
40
|
+
| `/explorer/pool` | `chainId` | Up to 101 pool hashes; clients display at most 100 |
|
|
41
|
+
| `/explorer/overview` | `chainId` | Public counters, registry, top-five holder balances and shares |
|
|
42
|
+
|
|
43
|
+
Reads time out after 10 seconds. A local content miss returns HTTP 404 rather
|
|
44
|
+
than a proxy fetch. Source sync/membership is not a proof of network finality.
|
|
45
|
+
No explorer write action is exposed; all application API responses use binary encoding.
|
|
46
|
+
|
|
47
|
+
## Chain Manager HTTP API
|
|
48
|
+
|
|
49
|
+
`POST /manager/rpc` accepts `application/octet-stream` (maximum 1 MiB). Requests must
|
|
50
|
+
include the full expected `chainId`; the node verifies its own genesis identity
|
|
51
|
+
and returns HTTP 409 on a mismatch. All application API responses use binary encoding.
|
|
52
|
+
This API never unlocks a node wallet, selects remote accounts or signs messages.
|
|
53
|
+
Deploy/restart an updated endpoints package before connecting Chain Manager.
|
|
54
|
+
|
|
55
|
+
Actions:
|
|
56
|
+
|
|
57
|
+
| Action | Additional fields | Result |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `status` | optional public `account` | API version, chain identity, sync, peer count, height, contract hashes, account balance in integer units and latest nonce |
|
|
60
|
+
| `read` | `contract`, `method`, array `params` | `{ value }`, with native BigInt/byte fields |
|
|
61
|
+
| `submit` | signed `transaction`, optional encoded `contract` | transaction hash and explicit status |
|
|
62
|
+
| `receipt` | transaction `hash` | durable status, canonical inclusion (`blockHeight`, `blockHash`) and scan progress (`scannedHeight`) when observed, or `unknown` |
|
|
63
|
+
|
|
64
|
+
Submissions require a synced node with connected peers. A contract upload must
|
|
65
|
+
match the signed factory registration, creator and content hash. The node
|
|
66
|
+
validates signatures before storing uploaded code. Duplicate transaction hashes
|
|
67
|
+
are admitted once, including concurrent duplicates.
|
|
68
|
+
|
|
69
|
+
`pending` means admitted, not confirmed. The chain's canonical completion
|
|
70
|
+
promise or verified canonical block membership produces `confirmed`; execution
|
|
71
|
+
rejection produces `failed`. Unverifiable admission
|
|
72
|
+
produces `unknown`, never a success-shaped response or an automatic resend.
|
|
73
|
+
|
|
74
|
+
Before any contract upload/admission, a reservation is durably saved in the
|
|
75
|
+
node's existing isolated `chainStore`, under `manager:receipts:v1:<full-chain-id>`.
|
|
76
|
+
Storage unavailability/corruption/write failure blocks admission explicitly.
|
|
77
|
+
Updates and duplicate checks are serialized. Restarting the HTTP server/node
|
|
78
|
+
restores admission history and does not resubmit a reserved transaction.
|
|
79
|
+
|
|
80
|
+
After restart, `receipt` scans at most 128 canonical blocks per request from the
|
|
81
|
+
submission height, persists its cursor and checks recorded inclusion/scan anchors.
|
|
82
|
+
It uses canonical worker blocks, not the mempool or raw transaction store; absent
|
|
83
|
+
inclusion is `unknown`, never inferred failure. Refresh again to continue a long
|
|
84
|
+
scan. A changing tip or unavailable canonical block returns an explicit error
|
|
85
|
+
without advancing durable progress. Canonical tip lookup avoids cached fallback
|
|
86
|
+
state when the machine exposes its strict getter.
|
|
87
|
+
|
|
88
|
+
Receipts are bounded to 512 entries per chain. Only completed entries may be
|
|
89
|
+
evicted; a full unresolved journal blocks new admission. Reservations written
|
|
90
|
+
before a crash can remain unknown if delivery never happened. Evicted entries,
|
|
91
|
+
pre-upgrade transactions and receipts from another node are also unknown; these
|
|
92
|
+
require operator reconciliation and are not proof of rejection. The journal is
|
|
93
|
+
local to a node, not shared across distinct node instances.
|
|
94
|
+
|
|
95
|
+
Use HTTPS and an operator-controlled endpoint. CORS permits the web and Electron
|
|
96
|
+
clients to connect but is not authentication. The API only accepts transactions
|
|
97
|
+
already signed by the client; it provides no privileged server-side keys.
|
|
98
|
+
Chain-ID checks protect this connection from accidental chain switching; they do
|
|
99
|
+
not add cryptographic cross-chain replay protection to the existing wire format.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const RPC_LIMIT: number;
|
|
2
|
+
export declare function readBinaryRequest(request: any, maxBytes?: number): Promise<any>;
|
|
3
|
+
/** The Koa boundary owns serialization, including errors. Domain handlers see native values. */
|
|
4
|
+
export declare const binaryHttp: (ctx: any, next: any) => Promise<any>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { encodeWire, decodeWire, BINARY_CONTENT_TYPE } from '@leofcoin/binary';
|
|
2
|
+
export const RPC_LIMIT = 4 * 1024 * 1024;
|
|
3
|
+
export async function readBinaryRequest(request, maxBytes = 1048576) {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let size = 0;
|
|
6
|
+
for await (const chunk of request) {
|
|
7
|
+
size += chunk.length;
|
|
8
|
+
if (size > maxBytes)
|
|
9
|
+
throw Object.assign(new Error('Binary request too large'), { status: 413 });
|
|
10
|
+
chunks.push(chunk);
|
|
11
|
+
}
|
|
12
|
+
return decodeWire('rpc', Buffer.concat(chunks), { maxBytes });
|
|
13
|
+
}
|
|
14
|
+
/** The Koa boundary owns serialization, including errors. Domain handlers see native values. */
|
|
15
|
+
export const binaryHttp = async (ctx, next) => {
|
|
16
|
+
if (ctx.path === '/' && ctx.method === 'GET')
|
|
17
|
+
return next();
|
|
18
|
+
try {
|
|
19
|
+
if (ctx.method === 'POST') {
|
|
20
|
+
if (ctx.get('content-type').split(';')[0] !== BINARY_CONTENT_TYPE)
|
|
21
|
+
throw Object.assign(new Error('Use application/octet-stream binary RPC'), { status: 415 });
|
|
22
|
+
try {
|
|
23
|
+
ctx.state.rpcParams = await readBinaryRequest(ctx.req);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw Object.assign(error, { status: error.status || 400 });
|
|
27
|
+
}
|
|
28
|
+
if (!ctx.state.rpcParams || typeof ctx.state.rpcParams !== 'object' || Array.isArray(ctx.state.rpcParams) || ctx.state.rpcParams instanceof Uint8Array)
|
|
29
|
+
throw Object.assign(new Error('RPC parameters must be a binary map'), { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
else if (ctx.method === 'GET' && !ctx.querystring)
|
|
32
|
+
ctx.state.rpcParams = {};
|
|
33
|
+
else
|
|
34
|
+
throw Object.assign(new Error('Use binary POST parameters; JSON/query-string payloads are unsupported'), { status: 405 });
|
|
35
|
+
await next();
|
|
36
|
+
// Koa turns a legitimate null result into HTTP 204; binary RPC must still carry its null tag.
|
|
37
|
+
if (ctx.status === 204 && ctx.body == null)
|
|
38
|
+
ctx.status = 200;
|
|
39
|
+
else if (ctx.body == null)
|
|
40
|
+
ctx.body = { error: ctx.message || 'RPC returned no value' };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
ctx.status = Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : 500;
|
|
44
|
+
ctx.body = { error: ctx.status === 500 ? 'RPC operation failed' : error.message };
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
ctx.body = Buffer.from(encodeWire('rpc', ctx.body, { maxBytes: RPC_LIMIT }));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
ctx.status = 500;
|
|
51
|
+
ctx.body = Buffer.from(encodeWire('rpc', { error: 'RPC response cannot be encoded' }));
|
|
52
|
+
}
|
|
53
|
+
ctx.type = BINARY_CONTENT_TYPE;
|
|
54
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { encodeWire, decodeWire } from '@leofcoin/binary';
|
|
3
|
+
export default function binarySocketServer({ port, protocol }, routes) {
|
|
4
|
+
const required = `${protocol}.lfc-binary-v1`;
|
|
5
|
+
const server = new WebSocketServer({ port, maxPayload: 4 * 1024 * 1024,
|
|
6
|
+
handleProtocols: protocols => protocols.has(required) ? required : false });
|
|
7
|
+
server.on('connection', socket => {
|
|
8
|
+
if (socket.protocol !== required) {
|
|
9
|
+
socket.close(1002, 'Binary protocol required');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let active = 0;
|
|
13
|
+
socket.on('error', () => { });
|
|
14
|
+
socket.on('message', async (bytes, binary) => {
|
|
15
|
+
if (!binary || !(bytes instanceof Uint8Array)) {
|
|
16
|
+
socket.close(1003, 'Binary frames required');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let request;
|
|
20
|
+
try {
|
|
21
|
+
request = decodeWire('rpc-request', bytes, { maxBytes: 1048576 });
|
|
22
|
+
if (!request || typeof request.id !== 'string' || request.id.length > 80 || !request.id ||
|
|
23
|
+
typeof request.url !== 'string' || !Object.hasOwn(routes, request.url))
|
|
24
|
+
throw new Error('Invalid RPC request');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
socket.close(1002, 'Invalid binary RPC');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const send = (value, status = 200) => {
|
|
31
|
+
if (socket.readyState === 1)
|
|
32
|
+
socket.send(encodeWire('rpc-response', { id: request.id, status, value }, { maxBytes: 4 * 1024 * 1024 }));
|
|
33
|
+
};
|
|
34
|
+
if (active >= 32) {
|
|
35
|
+
send({ error: 'Too many pending requests' }, 429);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
active++;
|
|
39
|
+
try {
|
|
40
|
+
const context = { send };
|
|
41
|
+
await routes[request.url](...(request.params === undefined ? [context] : [request.params, context]));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
try {
|
|
45
|
+
send({ error: 'RPC operation failed' }, 500);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
socket.close(1011);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
active--;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
return server;
|
|
57
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { BlockMessage, TransactionMessage } from '@leofcoin/messages';
|
|
2
|
+
import { verifyChainIdentity } from '@leofcoin/lib/chain-identity';
|
|
3
|
+
const fail = (status, message) => { throw Object.assign(new Error(message), { status }); };
|
|
4
|
+
const integer = (value, maximum) => {
|
|
5
|
+
if (!/^(0|[1-9]\d*)$/.test(String(value)) || !Number.isSafeInteger(Number(value)) || Number(value) > maximum)
|
|
6
|
+
fail(400, 'Invalid explorer range');
|
|
7
|
+
return Number(value);
|
|
8
|
+
};
|
|
9
|
+
const hashInput = value => {
|
|
10
|
+
if (typeof value !== 'string' || !/^[A-Z2-7]{32,128}$/.test(value))
|
|
11
|
+
fail(400, 'Invalid content hash');
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
/** Public data only. No wallet, account selection, signing, submission or peer fetches. */
|
|
15
|
+
export function explorerMiddleware(chain) {
|
|
16
|
+
return async (ctx, next) => {
|
|
17
|
+
if (!ctx.path.startsWith('/explorer/'))
|
|
18
|
+
return next();
|
|
19
|
+
ctx.set('Cache-Control', 'no-store');
|
|
20
|
+
if (ctx.method !== 'POST') {
|
|
21
|
+
ctx.status = 405;
|
|
22
|
+
ctx.body = { error: 'Explorer supports read-only binary POST requests' };
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let timer;
|
|
26
|
+
try {
|
|
27
|
+
const body = await Promise.race([
|
|
28
|
+
(async () => {
|
|
29
|
+
const identity = await verifyChainIdentity(await chain.getChainIdentity());
|
|
30
|
+
if (ctx.path !== '/explorer/status' && ctx.state.rpcParams.chainId !== identity.chainId)
|
|
31
|
+
fail(409, 'Explorer chain identity changed; reconnect');
|
|
32
|
+
const response = { apiVersion: 1, chainId: identity.chainId };
|
|
33
|
+
if (ctx.path === '/explorer/status') {
|
|
34
|
+
const tip = await chain.lastBlock;
|
|
35
|
+
return { ...response, identity, tip: { index: tip.index, hash: tip.hash },
|
|
36
|
+
synced: chain.state?.sync === 'synced', nativeToken: identity.genesisContracts.nativeToken,
|
|
37
|
+
peers: Object.entries(globalThis.peernet?.connections || {})
|
|
38
|
+
.filter(([, peer]) => peer?.connected).map(([id]) => id).slice(0, 100) };
|
|
39
|
+
}
|
|
40
|
+
if (ctx.path === '/explorer/blocks') {
|
|
41
|
+
const from = integer(ctx.state.rpcParams.from, Number.MAX_SAFE_INTEGER);
|
|
42
|
+
const count = integer(ctx.state.rpcParams.count, 25);
|
|
43
|
+
if (count < 1 || from > Number.MAX_SAFE_INTEGER - count)
|
|
44
|
+
fail(400, 'Invalid explorer range');
|
|
45
|
+
const blocks = await chain.getBlocks(from, from + count);
|
|
46
|
+
if (!Array.isArray(blocks))
|
|
47
|
+
fail(503, 'Canonical block data unavailable');
|
|
48
|
+
return { ...response, blocks: blocks.filter(block => block && block.loaded !== false) };
|
|
49
|
+
}
|
|
50
|
+
if (ctx.path === '/explorer/block' || ctx.path === '/explorer/transaction') {
|
|
51
|
+
const hash = hashInput(ctx.state.rpcParams.hash);
|
|
52
|
+
const stores = ctx.path === '/explorer/block' ? [globalThis.blockStore]
|
|
53
|
+
: [globalThis.transactionStore, globalThis.transactionPoolStore];
|
|
54
|
+
let bytes;
|
|
55
|
+
for (const store of stores) {
|
|
56
|
+
if (store && await store.has(hash)) {
|
|
57
|
+
bytes = await store.get(hash);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!(bytes instanceof Uint8Array) || bytes.length === 0)
|
|
62
|
+
fail(404, 'Content not available on this node');
|
|
63
|
+
if (bytes.length > 1_048_576)
|
|
64
|
+
fail(413, 'Explorer content exceeds 1 MiB');
|
|
65
|
+
const message = ctx.path === '/explorer/block' ? new BlockMessage(bytes) : new TransactionMessage(bytes);
|
|
66
|
+
if (await message.hash() !== hash)
|
|
67
|
+
fail(503, 'Stored content hash mismatch');
|
|
68
|
+
return { ...response, encoded: bytes };
|
|
69
|
+
}
|
|
70
|
+
if (ctx.path === '/explorer/pool') {
|
|
71
|
+
if (!globalThis.transactionPoolStore)
|
|
72
|
+
fail(503, 'Transaction pool unavailable');
|
|
73
|
+
return { ...response, hashes: (await globalThis.transactionPoolStore.keys(101)).slice(0, 101).map(String) };
|
|
74
|
+
}
|
|
75
|
+
if (ctx.path === '/explorer/overview') {
|
|
76
|
+
const [validators, registeredContracts, nativeCalls, nativeMints, nativeBurns, nativeTransfers, totalContracts, totalTransactions, totalSize, poolCount, poolSize, balances] = await Promise.all([
|
|
77
|
+
chain.staticCall(identity.genesisContracts.validators, 'validators', []),
|
|
78
|
+
chain.staticCall(identity.genesisContracts.contractFactory, 'totalContracts', []),
|
|
79
|
+
chain.nativeCalls, chain.nativeMints, chain.nativeBurns, chain.nativeTransfers,
|
|
80
|
+
chain.totalContracts, chain.totalTransactions, chain.totalSize,
|
|
81
|
+
globalThis.transactionPoolStore?.length(), globalThis.transactionPoolStore?.size(), chain.balances
|
|
82
|
+
]);
|
|
83
|
+
let supply = 0n;
|
|
84
|
+
let funded = 0;
|
|
85
|
+
const top = [];
|
|
86
|
+
if (!balances || typeof balances !== 'object')
|
|
87
|
+
fail(503, 'Balances unavailable');
|
|
88
|
+
for (const [address, raw] of Object.entries(balances)) {
|
|
89
|
+
const balance = BigInt(raw);
|
|
90
|
+
if (balance <= 0n)
|
|
91
|
+
continue;
|
|
92
|
+
supply += balance;
|
|
93
|
+
funded++;
|
|
94
|
+
top.push({ address, balance });
|
|
95
|
+
top.sort((a, b) => a.balance === b.balance ? a.address.localeCompare(b.address) : a.balance > b.balance ? -1 : 1);
|
|
96
|
+
if (top.length > 5)
|
|
97
|
+
top.pop();
|
|
98
|
+
}
|
|
99
|
+
return { ...response, validators, registeredContracts, nativeCalls, nativeMints, nativeBurns,
|
|
100
|
+
nativeTransfers, totalContracts, totalTransactions, totalSize, poolCount, poolSize,
|
|
101
|
+
holders: { total: funded, holders: top.map(holder => ({ ...holder, share: Number(holder.balance * 10000n / (supply || 1n)) / 100 })) } };
|
|
102
|
+
}
|
|
103
|
+
fail(404, 'Unknown read-only explorer resource');
|
|
104
|
+
})(),
|
|
105
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('Explorer read timed out'), { status: 503 })), 10000); })
|
|
106
|
+
]);
|
|
107
|
+
ctx.body = body;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
ctx.status = [400, 404, 405, 409, 413, 503].includes(error.status) ? error.status : 503;
|
|
111
|
+
ctx.body = { error: ctx.status === 503 ? 'Public explorer data unavailable; retry after node sync' : error.message };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
package/exports/http.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { binaryHttp } from './binary-http.js';
|
|
1
2
|
import koa from 'koa';
|
|
2
3
|
import cors from '@koa/cors';
|
|
3
4
|
import Router from '@koa/router';
|
|
4
|
-
import { formatUnits
|
|
5
|
+
import { formatUnits } from '@leofcoin/utils';
|
|
5
6
|
import { readFile } from 'fs/promises';
|
|
6
7
|
import Showdown from 'showdown';
|
|
7
8
|
import shared from './shared.js';
|
|
8
9
|
import { dirname, join } from 'path';
|
|
9
10
|
import { fileURLToPath } from 'url';
|
|
11
|
+
import { managerMiddleware } from './manager.js';
|
|
12
|
+
import { explorerMiddleware } from './explorer.js';
|
|
10
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
14
|
const __dirname = dirname(__filename);
|
|
12
15
|
const converter = new Showdown.Converter();
|
|
@@ -22,39 +25,39 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
22
25
|
router.get('/', (ctx) => {
|
|
23
26
|
ctx.body = converter.makeHtml(apiFile.toString());
|
|
24
27
|
});
|
|
25
|
-
router.
|
|
28
|
+
router.all('/network', (ctx) => {
|
|
26
29
|
ctx.body = {
|
|
27
30
|
version: networkVersion
|
|
28
31
|
};
|
|
29
32
|
});
|
|
30
|
-
router.
|
|
33
|
+
router.all('/chainIdentity', async (ctx) => {
|
|
31
34
|
ctx.body = await chain.getChainIdentity();
|
|
32
35
|
});
|
|
33
|
-
router.
|
|
36
|
+
router.all('/networkStats', async (ctx) => {
|
|
34
37
|
ctx.body = await shared.networkStats(chain, networkVersion);
|
|
35
38
|
});
|
|
36
|
-
router.
|
|
39
|
+
router.all('/balances', async (ctx) => {
|
|
37
40
|
ctx.body = await chain.balances;
|
|
38
41
|
});
|
|
39
|
-
router.
|
|
42
|
+
router.all('/balanceOf', async (ctx) => {
|
|
40
43
|
// @ts-ignore
|
|
41
|
-
const balance = (await chain.balances)[ctx.
|
|
44
|
+
const balance = (await chain.balances)[ctx.state.rpcParams.address];
|
|
42
45
|
if (balance)
|
|
43
|
-
ctx.body = ctx.
|
|
46
|
+
ctx.body = ctx.state.rpcParams.format ? formatUnits(balance).toString() : balance.toString();
|
|
44
47
|
else
|
|
45
48
|
ctx.body = '0n';
|
|
46
49
|
});
|
|
47
|
-
router.
|
|
50
|
+
router.all('/selectedAccount', (ctx) => {
|
|
48
51
|
if (remote)
|
|
49
52
|
ctx.body = globalThis.peernet.selectedAccount;
|
|
50
53
|
else
|
|
51
54
|
ctx.status = 405;
|
|
52
55
|
});
|
|
53
|
-
router.
|
|
56
|
+
router.all('/selectAccount', async (ctx) => {
|
|
54
57
|
if (remote) {
|
|
55
|
-
globalThis.peernet.selectedAccount = ctx.
|
|
58
|
+
globalThis.peernet.selectedAccount = ctx.state.rpcParams.address;
|
|
56
59
|
try {
|
|
57
|
-
await globalThis.walletStore.put('selected-account', ctx.
|
|
60
|
+
await globalThis.walletStore.put('selected-account', ctx.state.rpcParams.address);
|
|
58
61
|
}
|
|
59
62
|
catch (error) {
|
|
60
63
|
ctx.body = error.message;
|
|
@@ -64,97 +67,115 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
64
67
|
ctx.status = 405;
|
|
65
68
|
}
|
|
66
69
|
});
|
|
67
|
-
router.
|
|
70
|
+
router.all('/accounts', async (ctx) => {
|
|
68
71
|
if (remote)
|
|
69
72
|
ctx.body = JSON.parse(new TextDecoder().decode(await globalThis.walletStore.get('accounts')));
|
|
70
73
|
else
|
|
71
74
|
ctx.status = 405;
|
|
72
75
|
});
|
|
73
|
-
router.
|
|
76
|
+
router.all('/hasTransactionToHandle', async (ctx) => {
|
|
74
77
|
ctx.body = await chain.hasTransactionToHandle();
|
|
75
78
|
});
|
|
76
|
-
router.
|
|
77
|
-
ctx.body = await chain.getNonce(ctx.
|
|
79
|
+
router.all('/getNonce', async (ctx) => {
|
|
80
|
+
ctx.body = await chain.getNonce(ctx.state.rpcParams.address);
|
|
78
81
|
});
|
|
79
|
-
router.
|
|
80
|
-
router.
|
|
81
|
-
const limit = ctx.
|
|
82
|
+
router.all('/getBlock', async (ctx) => (ctx.body = await chain.getBlock(ctx.state.rpcParams.index)));
|
|
83
|
+
router.all('/blocks', async (ctx) => {
|
|
84
|
+
const limit = ctx.state.rpcParams.amount;
|
|
82
85
|
ctx.body = await globalThis.blockStore.values(limit);
|
|
83
86
|
});
|
|
84
|
-
router.
|
|
87
|
+
router.all('/sendTransaction', async (ctx) => {
|
|
85
88
|
try {
|
|
86
|
-
const tx = await chain.sendTransaction(ctx.
|
|
87
|
-
await tx.wait();
|
|
89
|
+
const tx = await chain.sendTransaction((ctx.state.rpcParams.transaction ?? ctx.state.rpcParams));
|
|
90
|
+
await (typeof tx.wait === 'function' ? tx.wait() : tx.wait);
|
|
88
91
|
delete tx.wait;
|
|
89
|
-
ctx.body =
|
|
92
|
+
ctx.body = tx;
|
|
90
93
|
}
|
|
91
94
|
catch (error) {
|
|
92
|
-
|
|
95
|
+
ctx.status = 422;
|
|
96
|
+
ctx.body = { error: error instanceof Error ? error.message : 'Transaction failed' };
|
|
93
97
|
}
|
|
94
98
|
});
|
|
95
|
-
router.
|
|
96
|
-
router.
|
|
97
|
-
router.
|
|
98
|
-
router.
|
|
99
|
-
ctx.body = await chain.lookup(ctx.
|
|
100
|
-
});
|
|
101
|
-
router.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
99
|
+
router.all('/peerId', (ctx) => (ctx.body = globalThis.peernet.peerId));
|
|
100
|
+
router.all('/validators', (ctx) => (ctx.body = chain.validators));
|
|
101
|
+
router.all('/peers', (ctx) => (ctx.body = globalThis.peernet.peers.map(([id, peer]) => id)));
|
|
102
|
+
router.all('/lookup', async (ctx) => {
|
|
103
|
+
ctx.body = await chain.lookup(ctx.state.rpcParams.name);
|
|
104
|
+
});
|
|
105
|
+
router.all('/staticCall', async (ctx) => {
|
|
106
|
+
let params;
|
|
107
|
+
try {
|
|
108
|
+
params = ctx.state.rpcParams.params ?? [];
|
|
109
|
+
if (!Array.isArray(params) || typeof ctx.state.rpcParams.contract !== 'string' || typeof ctx.state.rpcParams.method !== 'string')
|
|
110
|
+
throw new Error('Invalid read parameters');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
ctx.status = 400;
|
|
114
|
+
ctx.body = { error: 'staticCall params must be a binary array' };
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const value = await chain.staticCall(ctx.state.rpcParams.contract, ctx.state.rpcParams.method, params);
|
|
119
|
+
ctx.body = value ?? null;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
ctx.status = 422;
|
|
123
|
+
ctx.body = { error: error instanceof Error ? error.message : 'Read-only contract query failed' };
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
router.all('/contracts', async (ctx) => (ctx.body = await chain.contracts));
|
|
127
|
+
router.all('/totalContracts', async (ctx) => (ctx.body = await chain.totalContracts));
|
|
128
|
+
router.all('/nativeToken', async (ctx) => (ctx.body = await chain.nativeToken));
|
|
129
|
+
router.all('/nativeCalls', async (ctx) => (ctx.body = await chain.nativeCalls));
|
|
130
|
+
router.all('/nativeMints', async (ctx) => (ctx.body = await chain.nativeMints));
|
|
131
|
+
router.all('/nativeBurns', async (ctx) => (ctx.body = await chain.nativeBurns));
|
|
132
|
+
router.all('/nativeTransfers', async (ctx) => (ctx.body = await chain.nativeTransfers));
|
|
133
|
+
router.all('/totalBurnAmount', async (ctx) => (ctx.body = await chain.totalBurnAmount));
|
|
134
|
+
router.all('/totalMintAmount', async (ctx) => (ctx.body = await chain.totalMintAmount));
|
|
135
|
+
router.all('/totalTransferAmount', async (ctx) => (ctx.body = await chain.totalTransferAmount));
|
|
136
|
+
router.all('/totalTransactions', async (ctx) => (ctx.body = await chain.totalTransactions));
|
|
137
|
+
router.all('/totalSize', async (ctx) => (ctx.body = await chain.totalSize));
|
|
138
|
+
router.all('/totalBlocks', async (ctx) => (ctx.body = await chain.totalBlocks));
|
|
139
|
+
router.all('/lastBlock', async (ctx) => (ctx.body = await chain.lastBlock));
|
|
140
|
+
router.all('/lastBlockHeight', async (ctx) => (ctx.body = await chain.lastBlockHeight));
|
|
141
|
+
router.all('/poolTransactions', async (ctx) => (ctx.body = await globalThis.transactionPoolStore.get()));
|
|
142
|
+
router.all('/transactionsInPool', async (ctx) => (ctx.body = await globalThis.transactionPoolStore.length()));
|
|
143
|
+
router.all('/transactionPoolSize', async (ctx) => (ctx.body = await globalThis.transactionPoolStore.size()));
|
|
144
|
+
router.all('/participating', (ctx) => (ctx.body = chain.participating));
|
|
145
|
+
router.all('/participate', async (ctx) => {
|
|
122
146
|
if (remote)
|
|
123
|
-
ctx.body = await chain.participate(ctx.
|
|
147
|
+
ctx.body = await chain.participate(ctx.state.rpcParams.address);
|
|
124
148
|
else
|
|
125
149
|
ctx.status = 405;
|
|
126
150
|
});
|
|
127
|
-
router.
|
|
151
|
+
router.all('/deployContract', async (ctx) => {
|
|
128
152
|
try {
|
|
129
|
-
const tx = await chain.deployContract(ctx.
|
|
130
|
-
await tx.wait();
|
|
153
|
+
const tx = await chain.deployContract(ctx.state.rpcParams.contract, ctx.state.rpcParams.transaction);
|
|
154
|
+
await (typeof tx.wait === 'function' ? tx.wait() : tx.wait);
|
|
131
155
|
delete tx.wait;
|
|
132
|
-
ctx.body =
|
|
156
|
+
ctx.body = tx;
|
|
133
157
|
}
|
|
134
158
|
catch (error) {
|
|
135
|
-
ctx.
|
|
159
|
+
ctx.status = 422;
|
|
160
|
+
ctx.body = { error: error instanceof Error ? error.message : 'Deployment failed' };
|
|
136
161
|
}
|
|
137
162
|
});
|
|
138
|
-
router.
|
|
163
|
+
router.all('/createContractAddress', async (ctx) => {
|
|
139
164
|
try {
|
|
140
|
-
const address = await chain.createContractAddress(ctx.
|
|
165
|
+
const address = await chain.createContractAddress(ctx.state.rpcParams.owner, ctx.state.rpcParams.code, ctx.state.rpcParams.params);
|
|
141
166
|
ctx.body = address;
|
|
142
167
|
}
|
|
143
168
|
catch (error) { }
|
|
144
169
|
});
|
|
145
|
-
router.
|
|
146
|
-
router.
|
|
147
|
-
ctx.body =
|
|
170
|
+
router.all('/blockHashMap', async (ctx) => (ctx.body = chain.blockHashMap));
|
|
171
|
+
router.all('/bootstrap', async (ctx) => {
|
|
172
|
+
ctx.body = await shared.bootstrap();
|
|
148
173
|
});
|
|
149
174
|
// todo finish ...
|
|
150
175
|
api.use(cors({ origin: '*' }));
|
|
151
|
-
api.use(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
ctx.body = JSON.stringify(ctx.body, jsonStringifyBigInt);
|
|
155
|
-
ctx.type = 'application/json';
|
|
156
|
-
}
|
|
157
|
-
});
|
|
176
|
+
api.use(binaryHttp);
|
|
177
|
+
api.use(explorerMiddleware(chain));
|
|
178
|
+
api.use(managerMiddleware(chain));
|
|
158
179
|
api.use(router.routes());
|
|
159
180
|
return api.listen(port);
|
|
160
181
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
type Store = {
|
|
2
|
+
has(key: string): Promise<boolean>;
|
|
3
|
+
get(key: string): Promise<Uint8Array | string>;
|
|
4
|
+
put(key: string, value: Uint8Array): Promise<unknown>;
|
|
5
|
+
};
|
|
6
|
+
type Block = {
|
|
7
|
+
index: number | bigint | string;
|
|
8
|
+
hash: string;
|
|
9
|
+
transactions: string[];
|
|
10
|
+
};
|
|
11
|
+
type Chain = {
|
|
12
|
+
lastBlock: Block | Promise<Block>;
|
|
13
|
+
machine?: {
|
|
14
|
+
getCanonicalTip(): Promise<Block>;
|
|
15
|
+
};
|
|
16
|
+
getBlocks(from: number, to: number): Promise<Block[]>;
|
|
17
|
+
};
|
|
18
|
+
export type Receipt = {
|
|
19
|
+
hash: string;
|
|
20
|
+
status: string;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
startHeight: number;
|
|
24
|
+
scannedHeight?: number;
|
|
25
|
+
scannedHash?: string;
|
|
26
|
+
blockHeight?: number;
|
|
27
|
+
blockHash?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Uses the node's already isolated durable chain store, never a shared filesystem path. */
|
|
30
|
+
export declare class ManagerReceipts {
|
|
31
|
+
#private;
|
|
32
|
+
private store;
|
|
33
|
+
private chain;
|
|
34
|
+
constructor(store: () => Store, chain: Chain);
|
|
35
|
+
tip(): Promise<Block>;
|
|
36
|
+
serialized<T>(work: () => Promise<T>): Promise<T>;
|
|
37
|
+
load(chainId: string): Promise<Map<string, Receipt>>;
|
|
38
|
+
save(chainId: string, receipts: Map<string, Receipt>): Promise<void>;
|
|
39
|
+
reserve(receipts: Map<string, Receipt>, receipt: Receipt): void;
|
|
40
|
+
complete(chainId: string, hash: string, status: 'confirmed' | 'failed', error?: string): Promise<void>;
|
|
41
|
+
reconcile(receipt: Receipt): Promise<Receipt>;
|
|
42
|
+
}
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const statuses = ['submitting', 'pending', 'confirmed', 'failed', 'unknown'];
|
|
2
|
+
const LIMIT = 512;
|
|
3
|
+
/** Uses the node's already isolated durable chain store, never a shared filesystem path. */
|
|
4
|
+
export class ManagerReceipts {
|
|
5
|
+
store;
|
|
6
|
+
chain;
|
|
7
|
+
#tail = Promise.resolve();
|
|
8
|
+
constructor(store, chain) {
|
|
9
|
+
this.store = store;
|
|
10
|
+
this.chain = chain;
|
|
11
|
+
}
|
|
12
|
+
async tip() {
|
|
13
|
+
return this.chain.machine ? this.chain.machine.getCanonicalTip() : await this.chain.lastBlock;
|
|
14
|
+
}
|
|
15
|
+
async #block(index) {
|
|
16
|
+
const blocks = await this.chain.getBlocks(index, index + 1);
|
|
17
|
+
const block = blocks[0];
|
|
18
|
+
if (!block || Number(block.index) !== index || !Array.isArray(block.transactions) || typeof block.hash !== 'string')
|
|
19
|
+
throw new Error(`Canonical block ${index} is unavailable; retry reconciliation later`);
|
|
20
|
+
return block;
|
|
21
|
+
}
|
|
22
|
+
serialized(work) {
|
|
23
|
+
const next = this.#tail.then(work);
|
|
24
|
+
this.#tail = next.catch(error => { console.error('Management receipt operation failed:', error); });
|
|
25
|
+
return next;
|
|
26
|
+
}
|
|
27
|
+
async load(chainId) {
|
|
28
|
+
const store = this.store();
|
|
29
|
+
if (!store)
|
|
30
|
+
throw new Error('Durable chain store is unavailable; management writes are disabled');
|
|
31
|
+
const key = `manager:receipts:v1:${chainId}`;
|
|
32
|
+
if (!await store.has(key))
|
|
33
|
+
return new Map();
|
|
34
|
+
const raw = await store.get(key);
|
|
35
|
+
const parsed = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
|
|
36
|
+
if (!Array.isArray(parsed) || parsed.length > LIMIT || parsed.some(value => !value || typeof value.hash !== 'string' || !/^[A-Z2-7]{32,128}$/.test(value.hash) ||
|
|
37
|
+
!statuses.includes(value.status) || !Number.isSafeInteger(value.createdAt) ||
|
|
38
|
+
!Number.isSafeInteger(value.startHeight) || value.startHeight < 0 ||
|
|
39
|
+
(value.scannedHeight !== undefined && (!Number.isSafeInteger(value.scannedHeight) || value.scannedHeight < 0)) ||
|
|
40
|
+
(value.blockHeight !== undefined && (!Number.isSafeInteger(value.blockHeight) || value.blockHeight < 1)) ||
|
|
41
|
+
(value.blockHash !== undefined && typeof value.blockHash !== 'string') ||
|
|
42
|
+
(value.scannedHash !== undefined && typeof value.scannedHash !== 'string') ||
|
|
43
|
+
(value.error !== undefined && typeof value.error !== 'string')))
|
|
44
|
+
throw new Error('Stored management receipts are invalid; refusing to overwrite them');
|
|
45
|
+
return new Map(parsed.map(receipt => [receipt.hash, receipt]));
|
|
46
|
+
}
|
|
47
|
+
async save(chainId, receipts) {
|
|
48
|
+
await this.store().put(`manager:receipts:v1:${chainId}`, new TextEncoder().encode(JSON.stringify([...receipts.values()])));
|
|
49
|
+
}
|
|
50
|
+
reserve(receipts, receipt) {
|
|
51
|
+
if (receipts.size >= LIMIT) {
|
|
52
|
+
const completed = [...receipts].find(([, value]) => ['confirmed', 'failed'].includes(value.status));
|
|
53
|
+
if (!completed)
|
|
54
|
+
throw new Error('Receipt capacity reached; reconcile pending transactions first');
|
|
55
|
+
receipts.delete(completed[0]);
|
|
56
|
+
}
|
|
57
|
+
receipts.set(receipt.hash, receipt);
|
|
58
|
+
}
|
|
59
|
+
async complete(chainId, hash, status, error) {
|
|
60
|
+
return this.serialized(async () => {
|
|
61
|
+
const receipts = await this.load(chainId);
|
|
62
|
+
const receipt = receipts.get(hash);
|
|
63
|
+
if (!receipt)
|
|
64
|
+
throw new Error('Receipt disappeared before canonical completion');
|
|
65
|
+
receipt.status = status;
|
|
66
|
+
receipt.error = error;
|
|
67
|
+
await this.save(chainId, receipts);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async reconcile(receipt) {
|
|
71
|
+
if (typeof this.chain.getBlocks !== 'function')
|
|
72
|
+
throw new Error('Node does not support canonical receipt reconciliation');
|
|
73
|
+
const tip = await this.tip();
|
|
74
|
+
const height = Number(tip?.index ?? 0);
|
|
75
|
+
if (!Number.isSafeInteger(height) || height < 0)
|
|
76
|
+
throw new Error('Invalid canonical height');
|
|
77
|
+
const assertStable = async () => {
|
|
78
|
+
const current = await this.tip();
|
|
79
|
+
if (String(current?.index ?? 0) !== String(tip?.index ?? 0) || current?.hash !== tip?.hash)
|
|
80
|
+
throw new Error('Canonical tip changed during reconciliation; refresh again');
|
|
81
|
+
};
|
|
82
|
+
// Recheck recorded inclusion instead of trusting a previous process's observation.
|
|
83
|
+
if (receipt.blockHeight !== undefined) {
|
|
84
|
+
const block = receipt.blockHeight <= height ? await this.#block(receipt.blockHeight) : undefined;
|
|
85
|
+
if (block?.transactions?.includes(receipt.hash)) {
|
|
86
|
+
await assertStable();
|
|
87
|
+
return { ...receipt, status: 'confirmed', error: undefined, blockHash: block.hash };
|
|
88
|
+
}
|
|
89
|
+
receipt = { ...receipt, status: 'unknown', blockHeight: undefined, blockHash: undefined, scannedHeight: undefined, scannedHash: undefined, startHeight: 0 };
|
|
90
|
+
}
|
|
91
|
+
if (receipt.scannedHeight && receipt.scannedHash) {
|
|
92
|
+
const anchor = receipt.scannedHeight <= height ? await this.#block(receipt.scannedHeight) : undefined;
|
|
93
|
+
if (anchor?.hash !== receipt.scannedHash)
|
|
94
|
+
receipt = { ...receipt, scannedHeight: undefined, scannedHash: undefined, startHeight: 0 };
|
|
95
|
+
}
|
|
96
|
+
// Scan a bounded range per refresh; no pool membership or nonce heuristic proves confirmation.
|
|
97
|
+
const start = Math.max(1, Math.min(receipt.scannedHeight ?? (receipt.startHeight - 1), height) + 1);
|
|
98
|
+
const end = Math.min(height, start + 127);
|
|
99
|
+
let scannedHash = receipt.scannedHash;
|
|
100
|
+
for (let index = start; index <= end; index++) {
|
|
101
|
+
const block = await this.#block(index);
|
|
102
|
+
if (block.transactions.includes(receipt.hash)) {
|
|
103
|
+
await assertStable();
|
|
104
|
+
return { ...receipt, status: 'confirmed', error: undefined, blockHeight: index, blockHash: block.hash, scannedHeight: index, scannedHash: block.hash };
|
|
105
|
+
}
|
|
106
|
+
scannedHash = block.hash;
|
|
107
|
+
}
|
|
108
|
+
await assertStable();
|
|
109
|
+
// Even fully scanned absence is not failure: delivery or execution may still be pending.
|
|
110
|
+
return {
|
|
111
|
+
...receipt, scannedHeight: Math.max(receipt.scannedHeight ?? 0, end),
|
|
112
|
+
scannedHash,
|
|
113
|
+
status: receipt.status === 'failed' ? 'failed' : 'unknown',
|
|
114
|
+
error: receipt.status === 'failed' ? receipt.error : 'No canonical inclusion observed yet. Refresh to continue reconciliation; do not resend.'
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ContractMessage, TransactionMessage } from '@leofcoin/messages';
|
|
2
|
+
import { verifyChainIdentity } from '@leofcoin/lib/chain-identity';
|
|
3
|
+
import { ManagerReceipts } from './manager-receipts.js';
|
|
4
|
+
const errorMessage = (error) => error?.error?.message || error?.message || String(error?.error || error);
|
|
5
|
+
class RequestError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** No remote wallet access: only public queries and client-signed transactions. */
|
|
13
|
+
export function managerMiddleware(chain) {
|
|
14
|
+
const store = globalThis.chainStore;
|
|
15
|
+
const contractStore = globalThis.contractStore;
|
|
16
|
+
const journal = new ManagerReceipts(() => store, chain);
|
|
17
|
+
const live = new Set();
|
|
18
|
+
const peerCount = () => Object.values(globalThis.peernet?.connections || {})
|
|
19
|
+
.filter((peer) => peer.connected).length;
|
|
20
|
+
return async (ctx, next) => {
|
|
21
|
+
if (ctx.path !== '/manager/rpc')
|
|
22
|
+
return next();
|
|
23
|
+
ctx.set('Cache-Control', 'no-store');
|
|
24
|
+
if (ctx.method !== 'POST') {
|
|
25
|
+
ctx.status = 405;
|
|
26
|
+
ctx.body = { error: 'Use POST with application/octet-stream' };
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const body = ctx.state.rpcParams;
|
|
31
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
32
|
+
throw new RequestError(400, 'Invalid request');
|
|
33
|
+
const identity = await verifyChainIdentity(await chain.getChainIdentity());
|
|
34
|
+
if (body.chainId !== identity.chainId)
|
|
35
|
+
throw new RequestError(409, 'Endpoint chain identity changed; reconnect and review');
|
|
36
|
+
const { action } = body;
|
|
37
|
+
if (action === 'status') {
|
|
38
|
+
const account = body.account;
|
|
39
|
+
if (account !== undefined && (typeof account !== 'string' || account.length > 128))
|
|
40
|
+
throw new RequestError(400, 'Invalid account');
|
|
41
|
+
ctx.body = {
|
|
42
|
+
apiVersion: 1, chainId: identity.chainId, network: identity.network,
|
|
43
|
+
nativeToken: identity.genesisContracts.nativeToken, contractFactory: identity.genesisContracts.contractFactory,
|
|
44
|
+
height: String((await journal.tip())?.index ?? 0), peers: peerCount(), synced: chain.state?.sync === 'synced',
|
|
45
|
+
balance: account ? String(await chain.balanceOf(account) ?? 0) : '0',
|
|
46
|
+
nonce: account ? await chain.getNonce(account) : 0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
else if (action === 'read') {
|
|
50
|
+
if (typeof body.contract !== 'string' || !/^[A-Z2-7]{32,128}$/.test(body.contract) ||
|
|
51
|
+
typeof body.method !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(body.method) ||
|
|
52
|
+
!Array.isArray(body.params) || body.params.length > 100)
|
|
53
|
+
throw new RequestError(400, 'Invalid contract, method or parameter array');
|
|
54
|
+
ctx.body = { value: await chain.staticCall(body.contract, body.method, body.params) ?? null };
|
|
55
|
+
}
|
|
56
|
+
else if (action === 'receipt') {
|
|
57
|
+
if (typeof body.hash !== 'string' || !/^[A-Z2-7]{32,128}$/.test(body.hash))
|
|
58
|
+
throw new RequestError(400, 'Invalid transaction hash');
|
|
59
|
+
ctx.body = await journal.serialized(async () => {
|
|
60
|
+
const receipts = await journal.load(identity.chainId);
|
|
61
|
+
const receipt = receipts.get(body.hash);
|
|
62
|
+
if (!receipt)
|
|
63
|
+
return { hash: body.hash, status: 'unknown' };
|
|
64
|
+
if (live.has(`${identity.chainId}:${body.hash}`))
|
|
65
|
+
return receipt;
|
|
66
|
+
const reconciled = await journal.reconcile(receipt);
|
|
67
|
+
receipts.set(body.hash, reconciled);
|
|
68
|
+
await journal.save(identity.chainId, receipts);
|
|
69
|
+
return reconciled;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (action === 'submit') {
|
|
73
|
+
if (!chain.state || chain.state.sync !== 'synced' || peerCount() === 0)
|
|
74
|
+
throw new RequestError(503, 'Node must be synced and connected before submission');
|
|
75
|
+
const transaction = body.transaction;
|
|
76
|
+
if (!transaction || typeof transaction !== 'object' || !transaction.signature)
|
|
77
|
+
throw new RequestError(400, 'A client-signed transaction is required');
|
|
78
|
+
const message = new TransactionMessage(transaction);
|
|
79
|
+
if (message.encoded.length > 32768)
|
|
80
|
+
throw new RequestError(413, 'Transaction exceeds 32 KiB');
|
|
81
|
+
const hash = await message.hash();
|
|
82
|
+
// Validate before persisting uploaded code, including its binding to this signed registration.
|
|
83
|
+
await chain.validateTransactionSignature(message);
|
|
84
|
+
let contract;
|
|
85
|
+
if (body.contract !== undefined) {
|
|
86
|
+
if (!(body.contract instanceof Uint8Array))
|
|
87
|
+
throw new RequestError(400, 'Contract must be an encoded message');
|
|
88
|
+
contract = new ContractMessage(body.contract);
|
|
89
|
+
if (contract.encoded.length > 262144 || contract.decoded.creator !== transaction.from ||
|
|
90
|
+
transaction.to !== identity.genesisContracts.contractFactory ||
|
|
91
|
+
transaction.method !== 'registerContract' || transaction.params?.length !== 1 ||
|
|
92
|
+
transaction.params[0] !== await contract.hash())
|
|
93
|
+
throw new RequestError(400, 'Contract does not match the signed factory registration');
|
|
94
|
+
}
|
|
95
|
+
ctx.body = await journal.serialized(async () => {
|
|
96
|
+
const receipts = await journal.load(identity.chainId);
|
|
97
|
+
const known = receipts.get(hash);
|
|
98
|
+
if (known)
|
|
99
|
+
return known;
|
|
100
|
+
const height = Number((await journal.tip())?.index ?? 0);
|
|
101
|
+
if (!Number.isSafeInteger(height) || height < 0)
|
|
102
|
+
throw new Error('Invalid submission height');
|
|
103
|
+
const receipt = { hash, status: 'submitting', createdAt: Date.now(), startHeight: height };
|
|
104
|
+
journal.reserve(receipts, receipt);
|
|
105
|
+
await journal.save(identity.chainId, receipts);
|
|
106
|
+
try {
|
|
107
|
+
if (contract)
|
|
108
|
+
await contractStore.put(await contract.hash(), contract.encoded);
|
|
109
|
+
const pending = await chain.sendTransaction(transaction);
|
|
110
|
+
if (pending?.hash !== hash || !pending.wait || typeof pending.wait.then !== 'function')
|
|
111
|
+
throw new Error('Node did not return a canonical completion handle');
|
|
112
|
+
receipt.status = 'pending';
|
|
113
|
+
live.add(`${identity.chainId}:${hash}`);
|
|
114
|
+
Promise.resolve(pending.wait).then(() => journal.complete(identity.chainId, hash, 'confirmed'), error => journal.complete(identity.chainId, hash, 'failed', errorMessage(error))).catch(error => console.error('Could not persist canonical management completion:', hash, error))
|
|
115
|
+
.finally(() => live.delete(`${identity.chainId}:${hash}`));
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
// The node may already have admitted the transaction. Never silently auto-resend.
|
|
119
|
+
receipt.status = 'unknown';
|
|
120
|
+
receipt.error = errorMessage(error);
|
|
121
|
+
console.error('Management submission could not be verified:', hash, error);
|
|
122
|
+
}
|
|
123
|
+
await journal.save(identity.chainId, receipts);
|
|
124
|
+
return receipt;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else
|
|
128
|
+
throw new RequestError(400, 'Unsupported management action');
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
ctx.status = error instanceof RequestError ? error.status : 400;
|
|
132
|
+
ctx.body = { error: errorMessage(error) };
|
|
133
|
+
if (!(error instanceof RequestError))
|
|
134
|
+
console.error('Management request failed:', error);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Socket route dispatch does not await rejected promises; contain failures here. */
|
|
2
|
+
export const guardRpcHandler = handler => async (...args) => {
|
|
3
|
+
const index = args.findIndex(value => value && typeof value.send === 'function');
|
|
4
|
+
if (index < 0)
|
|
5
|
+
return;
|
|
6
|
+
const response = args[index];
|
|
7
|
+
const send = (value, status = 200) => response.send(value ?? null, status);
|
|
8
|
+
args[index] = { ...response, send };
|
|
9
|
+
try {
|
|
10
|
+
return await handler(...args);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
try {
|
|
14
|
+
send({ error: error instanceof Error ? error.message : 'RPC operation failed' }, 422);
|
|
15
|
+
}
|
|
16
|
+
catch { /* The socket may already be closed; never reject the detached route. */ }
|
|
17
|
+
}
|
|
18
|
+
};
|
package/exports/ws.d.ts
CHANGED
|
@@ -1,5 +1,2 @@
|
|
|
1
|
-
declare const _default: (chain: any, port: any, networkVersion: any, remote?: boolean) =>
|
|
2
|
-
close: () => void;
|
|
3
|
-
connections: import("socket-request-server/connection").SocketRequestConnection[];
|
|
4
|
-
}>;
|
|
1
|
+
declare const _default: (chain: any, port: any, networkVersion: any, remote?: boolean) => any;
|
|
5
2
|
export default _default;
|
package/exports/ws.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import Server from 'socket
|
|
1
|
+
import Server from './binary-socket.js';
|
|
2
2
|
import { formatUnits } from '@leofcoin/utils';
|
|
3
3
|
import shared from './shared.js';
|
|
4
|
+
import { guardRpcHandler } from './rpc-handler.js';
|
|
4
5
|
export default (chain, port, networkVersion, remote = false) => {
|
|
5
|
-
|
|
6
|
+
const routes = {
|
|
6
7
|
network: async ({ send }) => {
|
|
7
8
|
send({ version: networkVersion });
|
|
8
9
|
},
|
|
@@ -50,19 +51,19 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
50
51
|
sendTransaction: async (transaction, { send }) => {
|
|
51
52
|
try {
|
|
52
53
|
const tx = await chain.sendTransaction(transaction);
|
|
53
|
-
await tx.wait();
|
|
54
|
+
await (typeof tx.wait === 'function' ? tx.wait() : tx.wait);
|
|
54
55
|
delete tx.wait;
|
|
55
56
|
send(tx);
|
|
56
57
|
}
|
|
57
58
|
catch (error) {
|
|
58
|
-
send(
|
|
59
|
+
send({ error: error instanceof Error ? error.message : 'Transaction failed' }, 422);
|
|
59
60
|
}
|
|
60
61
|
},
|
|
61
62
|
peerId: ({ send }) => send(globalThis.peernet.peerId),
|
|
62
63
|
peers: ({ send }) => send(globalThis.peernet.peers.map(([id, peer]) => id)),
|
|
63
64
|
validators: ({ send }) => send(chain.validators),
|
|
64
65
|
lookup: async ({ name }, { send }) => send(await chain.lookup(name)),
|
|
65
|
-
staticCall: async ({ contract, method, params }, { send }) => send(await chain.staticCall(contract, method, params)),
|
|
66
|
+
staticCall: async ({ contract, method, params }, { send }) => send(await chain.staticCall(contract, method, params ?? [])),
|
|
66
67
|
contracts: async ({ send }) => send(await chain.contracts),
|
|
67
68
|
totalContracts: async ({ send }) => send(await chain.totalContracts),
|
|
68
69
|
nativeToken: async ({ send }) => send(await chain.nativeToken),
|
|
@@ -93,12 +94,12 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
93
94
|
deployContract: async ({ transaction, contract }, { send }) => {
|
|
94
95
|
try {
|
|
95
96
|
const tx = await chain.deployContract(contract, transaction);
|
|
96
|
-
await tx.wait();
|
|
97
|
+
await (typeof tx.wait === 'function' ? tx.wait() : tx.wait);
|
|
97
98
|
delete tx.wait;
|
|
98
99
|
send(tx);
|
|
99
100
|
}
|
|
100
101
|
catch (error) {
|
|
101
|
-
send(error.message);
|
|
102
|
+
send({ error: error instanceof Error ? error.message : 'Deployment failed' }, 422);
|
|
102
103
|
}
|
|
103
104
|
},
|
|
104
105
|
createContractAddress: async ({ owner, code, params }, { send }) => {
|
|
@@ -107,12 +108,13 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
107
108
|
send(address);
|
|
108
109
|
}
|
|
109
110
|
catch (error) {
|
|
110
|
-
send(error);
|
|
111
|
+
send({ error: error instanceof Error ? error.message : 'Contract address failed' }, 422);
|
|
111
112
|
}
|
|
112
113
|
},
|
|
113
114
|
blockHashMap: async ({ send }) => send(await chain.blockHashMap),
|
|
114
115
|
bootstrap: async ({ send }) => {
|
|
115
116
|
send(await shared.bootstrap());
|
|
116
117
|
}
|
|
117
|
-
}
|
|
118
|
+
};
|
|
119
|
+
return Server({ port, protocol: networkVersion }, Object.fromEntries(Object.entries(routes).map(([name, handler]) => [name, guardRpcHandler(handler)])));
|
|
118
120
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leofcoin/endpoints",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,12 +34,15 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://github.com/leofcoin/monorepo/tree/main/packages/endpoints#readme",
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@leofcoin/binary": "^1.1.0",
|
|
38
|
+
"ws": "^8.22.0",
|
|
39
|
+
"@leofcoin/lib": "^1.4.0",
|
|
40
|
+
"@leofcoin/messages": "^2.0.0",
|
|
37
41
|
"@koa/cors": "^5.0.0",
|
|
38
42
|
"@koa/router": "^13.1.1",
|
|
39
43
|
"@leofcoin/utils": "^1.1.45",
|
|
40
44
|
"koa": "^3.0.1",
|
|
41
|
-
"showdown": "^2.1.0"
|
|
42
|
-
"socket-request-server": "^1.6.17"
|
|
45
|
+
"showdown": "^2.1.0"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|
|
45
48
|
"@leofcoin/types": "^1.0.22",
|