@leofcoin/endpoints 0.4.12 → 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 +40 -4
- 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 +88 -69
- package/exports/manager.js +3 -22
- 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 -5
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
|
|
@@ -8,11 +15,40 @@ http(chain, port, networkVersion)
|
|
|
8
15
|
wss(chain, port, networkVersion)
|
|
9
16
|
```
|
|
10
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
|
+
|
|
11
47
|
## Chain Manager HTTP API
|
|
12
48
|
|
|
13
|
-
`POST /manager/rpc` accepts `application/
|
|
49
|
+
`POST /manager/rpc` accepts `application/octet-stream` (maximum 1 MiB). Requests must
|
|
14
50
|
include the full expected `chainId`; the node verifies its own genesis identity
|
|
15
|
-
and returns HTTP 409 on a mismatch.
|
|
51
|
+
and returns HTTP 409 on a mismatch. All application API responses use binary encoding.
|
|
16
52
|
This API never unlocks a node wallet, selects remote accounts or signs messages.
|
|
17
53
|
Deploy/restart an updated endpoints package before connecting Chain Manager.
|
|
18
54
|
|
|
@@ -21,7 +57,7 @@ Actions:
|
|
|
21
57
|
| Action | Additional fields | Result |
|
|
22
58
|
| --- | --- | --- |
|
|
23
59
|
| `status` | optional public `account` | API version, chain identity, sync, peer count, height, contract hashes, account balance in integer units and latest nonce |
|
|
24
|
-
| `read` | `contract`, `method`,
|
|
60
|
+
| `read` | `contract`, `method`, array `params` | `{ value }`, with native BigInt/byte fields |
|
|
25
61
|
| `submit` | signed `transaction`, optional encoded `contract` | transaction hash and explicit status |
|
|
26
62
|
| `receipt` | transaction `hash` | durable status, canonical inclusion (`blockHeight`, `blockHash`) and scan progress (`scannedHeight`) when observed, or `unknown` |
|
|
27
63
|
|
|
@@ -60,4 +96,4 @@ Use HTTPS and an operator-controlled endpoint. CORS permits the web and Electron
|
|
|
60
96
|
clients to connect but is not authentication. The API only accepts transactions
|
|
61
97
|
already signed by the client; it provides no privileged server-side keys.
|
|
62
98
|
Chain-ID checks protect this connection from accidental chain switching; they do
|
|
63
|
-
not add cryptographic cross-chain replay protection to the existing wire format.
|
|
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,13 +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';
|
|
10
11
|
import { managerMiddleware } from './manager.js';
|
|
12
|
+
import { explorerMiddleware } from './explorer.js';
|
|
11
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
14
|
const __dirname = dirname(__filename);
|
|
13
15
|
const converter = new Showdown.Converter();
|
|
@@ -23,39 +25,39 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
23
25
|
router.get('/', (ctx) => {
|
|
24
26
|
ctx.body = converter.makeHtml(apiFile.toString());
|
|
25
27
|
});
|
|
26
|
-
router.
|
|
28
|
+
router.all('/network', (ctx) => {
|
|
27
29
|
ctx.body = {
|
|
28
30
|
version: networkVersion
|
|
29
31
|
};
|
|
30
32
|
});
|
|
31
|
-
router.
|
|
33
|
+
router.all('/chainIdentity', async (ctx) => {
|
|
32
34
|
ctx.body = await chain.getChainIdentity();
|
|
33
35
|
});
|
|
34
|
-
router.
|
|
36
|
+
router.all('/networkStats', async (ctx) => {
|
|
35
37
|
ctx.body = await shared.networkStats(chain, networkVersion);
|
|
36
38
|
});
|
|
37
|
-
router.
|
|
39
|
+
router.all('/balances', async (ctx) => {
|
|
38
40
|
ctx.body = await chain.balances;
|
|
39
41
|
});
|
|
40
|
-
router.
|
|
42
|
+
router.all('/balanceOf', async (ctx) => {
|
|
41
43
|
// @ts-ignore
|
|
42
|
-
const balance = (await chain.balances)[ctx.
|
|
44
|
+
const balance = (await chain.balances)[ctx.state.rpcParams.address];
|
|
43
45
|
if (balance)
|
|
44
|
-
ctx.body = ctx.
|
|
46
|
+
ctx.body = ctx.state.rpcParams.format ? formatUnits(balance).toString() : balance.toString();
|
|
45
47
|
else
|
|
46
48
|
ctx.body = '0n';
|
|
47
49
|
});
|
|
48
|
-
router.
|
|
50
|
+
router.all('/selectedAccount', (ctx) => {
|
|
49
51
|
if (remote)
|
|
50
52
|
ctx.body = globalThis.peernet.selectedAccount;
|
|
51
53
|
else
|
|
52
54
|
ctx.status = 405;
|
|
53
55
|
});
|
|
54
|
-
router.
|
|
56
|
+
router.all('/selectAccount', async (ctx) => {
|
|
55
57
|
if (remote) {
|
|
56
|
-
globalThis.peernet.selectedAccount = ctx.
|
|
58
|
+
globalThis.peernet.selectedAccount = ctx.state.rpcParams.address;
|
|
57
59
|
try {
|
|
58
|
-
await globalThis.walletStore.put('selected-account', ctx.
|
|
60
|
+
await globalThis.walletStore.put('selected-account', ctx.state.rpcParams.address);
|
|
59
61
|
}
|
|
60
62
|
catch (error) {
|
|
61
63
|
ctx.body = error.message;
|
|
@@ -65,97 +67,114 @@ export default (chain, port, networkVersion, remote = false) => {
|
|
|
65
67
|
ctx.status = 405;
|
|
66
68
|
}
|
|
67
69
|
});
|
|
68
|
-
router.
|
|
70
|
+
router.all('/accounts', async (ctx) => {
|
|
69
71
|
if (remote)
|
|
70
72
|
ctx.body = JSON.parse(new TextDecoder().decode(await globalThis.walletStore.get('accounts')));
|
|
71
73
|
else
|
|
72
74
|
ctx.status = 405;
|
|
73
75
|
});
|
|
74
|
-
router.
|
|
76
|
+
router.all('/hasTransactionToHandle', async (ctx) => {
|
|
75
77
|
ctx.body = await chain.hasTransactionToHandle();
|
|
76
78
|
});
|
|
77
|
-
router.
|
|
78
|
-
ctx.body = await chain.getNonce(ctx.
|
|
79
|
+
router.all('/getNonce', async (ctx) => {
|
|
80
|
+
ctx.body = await chain.getNonce(ctx.state.rpcParams.address);
|
|
79
81
|
});
|
|
80
|
-
router.
|
|
81
|
-
router.
|
|
82
|
-
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;
|
|
83
85
|
ctx.body = await globalThis.blockStore.values(limit);
|
|
84
86
|
});
|
|
85
|
-
router.
|
|
87
|
+
router.all('/sendTransaction', async (ctx) => {
|
|
86
88
|
try {
|
|
87
|
-
const tx = await chain.sendTransaction(ctx.
|
|
88
|
-
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);
|
|
89
91
|
delete tx.wait;
|
|
90
|
-
ctx.body =
|
|
92
|
+
ctx.body = tx;
|
|
91
93
|
}
|
|
92
94
|
catch (error) {
|
|
93
|
-
|
|
95
|
+
ctx.status = 422;
|
|
96
|
+
ctx.body = { error: error instanceof Error ? error.message : 'Transaction failed' };
|
|
94
97
|
}
|
|
95
98
|
});
|
|
96
|
-
router.
|
|
97
|
-
router.
|
|
98
|
-
router.
|
|
99
|
-
router.
|
|
100
|
-
ctx.body = await chain.lookup(ctx.
|
|
101
|
-
});
|
|
102
|
-
router.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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) => {
|
|
123
146
|
if (remote)
|
|
124
|
-
ctx.body = await chain.participate(ctx.
|
|
147
|
+
ctx.body = await chain.participate(ctx.state.rpcParams.address);
|
|
125
148
|
else
|
|
126
149
|
ctx.status = 405;
|
|
127
150
|
});
|
|
128
|
-
router.
|
|
151
|
+
router.all('/deployContract', async (ctx) => {
|
|
129
152
|
try {
|
|
130
|
-
const tx = await chain.deployContract(ctx.
|
|
131
|
-
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);
|
|
132
155
|
delete tx.wait;
|
|
133
|
-
ctx.body =
|
|
156
|
+
ctx.body = tx;
|
|
134
157
|
}
|
|
135
158
|
catch (error) {
|
|
136
|
-
ctx.
|
|
159
|
+
ctx.status = 422;
|
|
160
|
+
ctx.body = { error: error instanceof Error ? error.message : 'Deployment failed' };
|
|
137
161
|
}
|
|
138
162
|
});
|
|
139
|
-
router.
|
|
163
|
+
router.all('/createContractAddress', async (ctx) => {
|
|
140
164
|
try {
|
|
141
|
-
const address = await chain.createContractAddress(ctx.
|
|
165
|
+
const address = await chain.createContractAddress(ctx.state.rpcParams.owner, ctx.state.rpcParams.code, ctx.state.rpcParams.params);
|
|
142
166
|
ctx.body = address;
|
|
143
167
|
}
|
|
144
168
|
catch (error) { }
|
|
145
169
|
});
|
|
146
|
-
router.
|
|
147
|
-
router.
|
|
148
|
-
ctx.body =
|
|
170
|
+
router.all('/blockHashMap', async (ctx) => (ctx.body = chain.blockHashMap));
|
|
171
|
+
router.all('/bootstrap', async (ctx) => {
|
|
172
|
+
ctx.body = await shared.bootstrap();
|
|
149
173
|
});
|
|
150
174
|
// todo finish ...
|
|
151
175
|
api.use(cors({ origin: '*' }));
|
|
152
|
-
api.use(
|
|
153
|
-
|
|
154
|
-
if (ctx.body !== null && typeof ctx.body === 'object') {
|
|
155
|
-
ctx.body = JSON.stringify(ctx.body, jsonStringifyBigInt);
|
|
156
|
-
ctx.type = 'application/json';
|
|
157
|
-
}
|
|
158
|
-
});
|
|
176
|
+
api.use(binaryHttp);
|
|
177
|
+
api.use(explorerMiddleware(chain));
|
|
159
178
|
api.use(managerMiddleware(chain));
|
|
160
179
|
api.use(router.routes());
|
|
161
180
|
return api.listen(port);
|
package/exports/manager.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { ContractMessage, TransactionMessage } from '@leofcoin/messages';
|
|
2
2
|
import { verifyChainIdentity } from '@leofcoin/lib/chain-identity';
|
|
3
3
|
import { ManagerReceipts } from './manager-receipts.js';
|
|
4
|
-
const MAX_BODY = 1_048_576;
|
|
5
4
|
const errorMessage = (error) => error?.error?.message || error?.message || String(error?.error || error);
|
|
6
5
|
class RequestError extends Error {
|
|
7
6
|
status;
|
|
@@ -10,22 +9,6 @@ class RequestError extends Error {
|
|
|
10
9
|
this.status = status;
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
|
-
async function readBody(request) {
|
|
14
|
-
let size = 0;
|
|
15
|
-
const chunks = [];
|
|
16
|
-
for await (const chunk of request) {
|
|
17
|
-
size += chunk.length;
|
|
18
|
-
if (size > MAX_BODY)
|
|
19
|
-
throw new RequestError(413, 'Management request exceeds 1 MiB');
|
|
20
|
-
chunks.push(chunk);
|
|
21
|
-
}
|
|
22
|
-
try {
|
|
23
|
-
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
throw new RequestError(400, 'Management request must contain JSON');
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
12
|
/** No remote wallet access: only public queries and client-signed transactions. */
|
|
30
13
|
export function managerMiddleware(chain) {
|
|
31
14
|
const store = globalThis.chainStore;
|
|
@@ -40,13 +23,11 @@ export function managerMiddleware(chain) {
|
|
|
40
23
|
ctx.set('Cache-Control', 'no-store');
|
|
41
24
|
if (ctx.method !== 'POST') {
|
|
42
25
|
ctx.status = 405;
|
|
43
|
-
ctx.body = { error: 'Use POST with application/
|
|
26
|
+
ctx.body = { error: 'Use POST with application/octet-stream' };
|
|
44
27
|
return;
|
|
45
28
|
}
|
|
46
29
|
try {
|
|
47
|
-
|
|
48
|
-
throw new RequestError(415, 'Content-Type must be application/json');
|
|
49
|
-
const body = await readBody(ctx.req);
|
|
30
|
+
const body = ctx.state.rpcParams;
|
|
50
31
|
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
51
32
|
throw new RequestError(400, 'Invalid request');
|
|
52
33
|
const identity = await verifyChainIdentity(await chain.getChainIdentity());
|
|
@@ -102,7 +83,7 @@ export function managerMiddleware(chain) {
|
|
|
102
83
|
await chain.validateTransactionSignature(message);
|
|
103
84
|
let contract;
|
|
104
85
|
if (body.contract !== undefined) {
|
|
105
|
-
if (
|
|
86
|
+
if (!(body.contract instanceof Uint8Array))
|
|
106
87
|
throw new RequestError(400, 'Contract must be an encoded message');
|
|
107
88
|
contract = new ContractMessage(body.contract);
|
|
108
89
|
if (contract.encoded.length > 262144 || contract.decoded.creator !== transaction.from ||
|
|
@@ -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,14 +34,15 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://github.com/leofcoin/monorepo/tree/main/packages/endpoints#readme",
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@leofcoin/
|
|
38
|
-
"
|
|
37
|
+
"@leofcoin/binary": "^1.1.0",
|
|
38
|
+
"ws": "^8.22.0",
|
|
39
|
+
"@leofcoin/lib": "^1.4.0",
|
|
40
|
+
"@leofcoin/messages": "^2.0.0",
|
|
39
41
|
"@koa/cors": "^5.0.0",
|
|
40
42
|
"@koa/router": "^13.1.1",
|
|
41
43
|
"@leofcoin/utils": "^1.1.45",
|
|
42
44
|
"koa": "^3.0.1",
|
|
43
|
-
"showdown": "^2.1.0"
|
|
44
|
-
"socket-request-server": "^1.6.17"
|
|
45
|
+
"showdown": "^2.1.0"
|
|
45
46
|
},
|
|
46
47
|
"devDependencies": {
|
|
47
48
|
"@leofcoin/types": "^1.0.22",
|