// A simulated Ğ1 chain for the end-to-end tests (ADR 0008): never a real node, // never a real transaction. Two doors: // // - `/rpc`, the « node »: the page, in test mode, sends what the fake wallet // signed (JSON as hex, see `testExtrinsic` in src/lib/wallet/injected.ts); // each one is included in a new block, as the transfer and the remark of the // real `utility.batchAll` would be; // - `/v1/graphql`, the « indexer »: answers the server's query // `txComments(condition: {hash})` in the shape of indexer.duniter.org. // // And `/control/*` for the tests: `hide` (what is included next stays unseen by // the indexer, as an indexer late on the chain), `show` (stop hiding), `release` // (the indexer catches up), `silent` (the indexer stops answering) and `state`. // // Listens on an ephemeral port on 127.0.0.1, written to the file given as argument. import { createServer } from 'node:http'; import { writeFileSync } from 'node:fs'; import { blake2b } from '@noble/hashes/blake2.js'; // Substrate's well-known development keys as Ğ1 addresses, with the identity each // one has on this simulated chain. Bob has an identity, but is not a member. const IDENTITIES = { g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu: { index: 9001, name: 'Alice', isMember: true }, g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g: { index: 9002, name: 'Bob', isMember: false }, g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi: { index: 9003, name: 'Charlie', isMember: true }, g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true }, }; const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 }; const hex = (bytes) => Buffer.from(bytes).toString('hex'); const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`; function send(res, status, body, cors = false) { const headers = { 'content-type': 'application/json' }; if (cors) Object.assign(headers, { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type' }); res.writeHead(status, headers); res.end(body === undefined ? '' : JSON.stringify(body)); } async function read(req) { let raw = ''; for await (const chunk of req) raw += chunk; try { return JSON.parse(raw || '{}'); } catch { return {}; } } /** One signed extrinsic of the test mode: a new block, its transfer and its remark. */ function include(fields) { state.block += 1; state.submitted += 1; const comment = { id: `${String(state.block).padStart(10, '0')}-${hex(Buffer.from(String(state.block))).slice(-5)}-000002`, blockNumber: state.block, remark: fields.remark, author: { id: fields.address, identity: IDENTITIES[fields.address] ?? null }, transfersByCommentId: { nodes: [{ fromId: fields.address, toId: fields.dest, amount: String(fields.amount) }] }, hidden: state.hiding, }; state.comments.push(comment); return comment; } const server = createServer(async (req, res) => { const url = new URL(req.url ?? '/', 'http://mock'); if (req.method === 'OPTIONS') return send(res, 204, undefined, true); if (url.pathname === '/rpc' && req.method === 'POST') { const body = await read(req); if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true); let fields; try { fields = JSON.parse(Buffer.from(String(body.params?.[0] ?? '').slice(2), 'hex').toString('utf8')); } catch { fields = null; } if (!fields?.signature || !fields.remark || !fields.address) { return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: 1010, message: 'Invalid Transaction' } }, true); } const c = include(fields); return send(res, 200, { jsonrpc: '2.0', id: body.id, result: `0x${hex(blake2b(Buffer.from(c.id), { dkLen: 32 }))}` }, true); } if (url.pathname === '/v1/graphql' && req.method === 'POST') { const body = await read(req); const hash = body.variables?.hash; // Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer. if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' }); const nodes = state.comments .filter((c) => !c.hidden && remarkHash(c.remark) === hash) .sort((a, b) => a.blockNumber - b.blockNumber) .map(({ hidden: _, ...c }) => c); return send(res, 200, { data: { txComments: { nodes } } }); } if (url.pathname.startsWith('/control/') && req.method === 'POST') { const what = url.pathname.slice('/control/'.length); if (what === 'hide') state.hiding = true; else if (what === 'show') state.hiding = false; else if (what === 'release') { state.hiding = false; for (const c of state.comments) c.hidden = false; } else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0'; else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true); return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true); } send(res, 404, { error: 'not_found' }, true); }); server.listen(0, '127.0.0.1', () => { const { port } = server.address(); const file = process.argv[2]; if (file) writeFileSync(file, String(port)); console.log(`chain mock on 127.0.0.1:${port}`); });