Skip to content

Commit e2b7cc2

Browse files
author
web3blind
committed
Add Viz Magic game archive node
1 parent 8f09c51 commit e2b7cc2

11 files changed

Lines changed: 892 additions & 2 deletions

app/js/config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ var VizMagicConfig = (function() {
1919
*/
2020
var HISTORY_ARCHIVE_MIRRORS = [
2121
{
22-
url: 'https://vizmagic.web3blind.xyz/archive-mirror/{block}.json',
22+
url: 'https://vizmagic.web3blind.xyz/archive-mirror/v1/block/{block}.json',
2323
timeoutMs: 8000
2424
}
2525
];

tests/history-source-regressions.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ test('history source wraps VIZ block/account access', function () {
5353

5454
test('archive mirror config is explicit and points at production nginx path', function () {
5555
assert.ok(/HISTORY_ARCHIVE_MIRRORS/.test(configJs), 'archive mirror config missing');
56-
assert.ok(/vizmagic\.web3blind\.xyz\/archive-mirror\/\{block\}\.json/.test(configJs), 'production archive mirror URL missing');
56+
assert.ok(/vizmagic\.web3blind\.xyz\/archive-mirror\/v1\/block\/\{block\}\.json/.test(configJs), 'production archive mirror URL missing');
5757
assert.ok(/timeoutMs:\s*8000/.test(configJs), 'mirror timeout should be explicit');
5858
assert.ok(/\{block\}/.test(configJs), 'mirror URL pattern should document block placeholder');
5959
});

tools/archive-node/README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Viz Magic archive node
2+
3+
Read-only community archive for Viz Magic history.
4+
5+
It indexes public VIZ blocks, extracts only game-relevant operations, and serves them over HTTP. It never asks for private keys and never signs or broadcasts transactions.
6+
7+
## What it stores
8+
9+
- block number, `block_id`, `previous`, timestamp, source node;
10+
- raw block JSON for indexed blocks;
11+
- normalized public events for:
12+
- `custom` protocol `VM`;
13+
- `custom` protocol `V`;
14+
- `custom` protocol `VE`;
15+
- `award` operations used for blessings/rewards.
16+
17+
The current implementation is dependency-free and stores data under `data/archive-node/` as JSON/JSONL files. `sqlite-schema.sql` documents the intended SQLite schema for a future storage backend.
18+
19+
## Run locally
20+
21+
```bash
22+
cd tools/archive-node
23+
cp config.example.json config.json
24+
node indexer.js --from 1 --to 1000 --once
25+
node server.js
26+
```
27+
28+
For a small catch-up run from the saved cursor:
29+
30+
```bash
31+
node indexer.js --max-blocks 500
32+
```
33+
34+
## HTTP API
35+
36+
```text
37+
GET /health
38+
GET /v1/status
39+
GET /v1/block/:blockNum.json
40+
GET /v1/range?start=123&end=456&protocol=VM,V,VE&limit=500
41+
GET /v1/account/:account/protocol/:protocol/actions?limit=500
42+
GET /v1/account/:account/protocol/:protocol/latest
43+
```
44+
45+
When hosted behind Nginx at `/archive-mirror`, the same API is available under that prefix, for example:
46+
47+
```text
48+
GET /archive-mirror/health
49+
GET /archive-mirror/v1/status
50+
GET /archive-mirror/v1/block/123.json
51+
```
52+
53+
## Config
54+
55+
`config.json`:
56+
57+
```json
58+
{
59+
"sourceNodes": ["https://api.viz.world/", "https://node.viz.cx/"],
60+
"dataDir": "./data/archive-node",
61+
"host": "127.0.0.1",
62+
"port": 3007,
63+
"startBlock": 1,
64+
"requestDelayMs": 120,
65+
"timeoutMs": 8000,
66+
"maxBlocksPerRun": 0
67+
}
68+
```
69+
70+
Environment overrides:
71+
72+
- `ARCHIVE_NODE_DATA_DIR`
73+
- `ARCHIVE_NODE_NODES` — comma-separated RPC URLs
74+
- `ARCHIVE_NODE_START_BLOCK`
75+
- `ARCHIVE_NODE_MAX_BLOCKS`
76+
- `ARCHIVE_NODE_HOST`
77+
- `ARCHIVE_NODE_PORT`
78+
- `ARCHIVE_NODE_CONFIG`
79+
80+
## PM2 example
81+
82+
```bash
83+
pm2 start tools/archive-node/server.js --name vizmagic-game-archive --time --update-env
84+
pm2 start tools/archive-node/indexer.js --name vizmagic-game-archive-indexer --time --update-env -- --max-blocks 1000
85+
```
86+
87+
For continuous indexing, run the indexer repeatedly via cron/systemd timer, or keep a bounded PM2/cron job. The indexer reads `cursor.json` and resumes from the next block.
88+
89+
## Safety
90+
91+
- No private keys.
92+
- No write/sign/broadcast endpoint.
93+
- CORS is open because the static browser app must read public history.
94+
- The browser must still feed returned blocks/events through `BlockProcessor -> StateEngine`; mirror data is a cache of public chain history, not authoritative game state.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use strict';
2+
3+
var assert = require('assert');
4+
var http = require('http');
5+
var fs = require('fs');
6+
var os = require('os');
7+
var path = require('path');
8+
var storeMod = require('./storage');
9+
var parser = require('./parser');
10+
var indexer = require('./indexer');
11+
var archiveServer = require('./server');
12+
13+
var ACCOUNT = 'archive-mage';
14+
var TEST_BLOCK = {
15+
previous: '0000000000000000000000000000000000000000',
16+
block_id: '0000007b00000000000000000000000000000000',
17+
timestamp: '2026-06-20T00:00:00',
18+
transactions: [{
19+
operations: [[
20+
'custom',
21+
{
22+
required_regular_auths: [ACCOUNT],
23+
required_active_auths: [],
24+
id: 'VM',
25+
json: JSON.stringify({ p: 'VM', v: 1, t: 'char.attune', d: { name: 'Archive Mage' } })
26+
}
27+
], [
28+
'custom',
29+
{
30+
required_regular_auths: [ACCOUNT],
31+
required_active_auths: [],
32+
id: 'V',
33+
json: JSON.stringify({ p: 'V', t: 'post', d: { text: 'hello' } })
34+
}
35+
], [
36+
'award',
37+
{
38+
initiator: ACCOUNT,
39+
receiver: 'target-mage',
40+
energy: 1000,
41+
custom_sequence: 1,
42+
memo: 'blessing'
43+
}
44+
]]
45+
}]
46+
};
47+
48+
function listen(server) {
49+
return new Promise(function(resolve) {
50+
server.listen(0, '127.0.0.1', function() { resolve(server.address().port); });
51+
});
52+
}
53+
54+
function close(server) {
55+
return new Promise(function(resolve) { server.close(resolve); });
56+
}
57+
58+
function getJson(port, urlPath) {
59+
return new Promise(function(resolve, reject) {
60+
http.get({ host: '127.0.0.1', port: port, path: urlPath }, function(res) {
61+
var body = '';
62+
res.setEncoding('utf8');
63+
res.on('data', function(chunk) { body += chunk; });
64+
res.on('end', function() {
65+
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
66+
catch (err) { reject(err); }
67+
});
68+
}).on('error', reject);
69+
});
70+
}
71+
72+
function startRpcServer() {
73+
var server = http.createServer(function(req, res) {
74+
var body = '';
75+
req.on('data', function(chunk) { body += chunk; });
76+
req.on('end', function() {
77+
var payload = JSON.parse(body || '{}');
78+
assert.strictEqual(payload.params[1], 'get_block');
79+
res.writeHead(200, { 'Content-Type': 'application/json' });
80+
res.end(JSON.stringify({ jsonrpc: '2.0', result: TEST_BLOCK, id: payload.id }));
81+
});
82+
});
83+
return listen(server).then(function(port) { return { server: server, url: 'http://127.0.0.1:' + port + '/' }; });
84+
}
85+
86+
async function run() {
87+
var events = parser.extractGameEvents(TEST_BLOCK, 123);
88+
assert.strictEqual(events.length, 3);
89+
assert.strictEqual(events[0].protocol, 'VM');
90+
assert.strictEqual(events[0].type, 'char.attune');
91+
assert.strictEqual(events[2].protocol, 'award');
92+
93+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'viz-archive-node-test-'));
94+
var rpc = await startRpcServer();
95+
try {
96+
var result = await indexer.indexRange({
97+
config: {
98+
sourceNodes: [rpc.url],
99+
dataDir: tmp,
100+
startBlock: 1,
101+
timeoutMs: 2000,
102+
requestDelayMs: 0
103+
},
104+
from: 123,
105+
to: 123,
106+
once: true
107+
});
108+
assert.strictEqual(result.indexedBlocks, 1);
109+
assert.strictEqual(result.indexedEvents, 3);
110+
111+
var server = archiveServer.createServer({ dataDir: tmp });
112+
var port = await listen(server);
113+
try {
114+
var health = await getJson(port, '/archive-mirror/health');
115+
assert.strictEqual(health.status, 200);
116+
assert.strictEqual(health.body.service, 'viz-magic-game-archive');
117+
118+
var block = await getJson(port, '/archive-mirror/v1/block/123.json');
119+
assert.strictEqual(block.status, 200);
120+
assert.strictEqual(block.body.block.block_id, TEST_BLOCK.block_id);
121+
assert.strictEqual(block.body.eventCount, 3);
122+
123+
var range = await getJson(port, '/archive-mirror/v1/range?start=100&end=200&protocol=VM,V,VE,award');
124+
assert.strictEqual(range.status, 200);
125+
assert.strictEqual(range.body.count, 3);
126+
127+
var account = await getJson(port, '/archive-mirror/v1/account/' + ACCOUNT + '/protocol/VM/actions');
128+
assert.strictEqual(account.status, 200);
129+
assert.strictEqual(account.body.count, 1);
130+
assert.strictEqual(account.body.actions[0].type, 'char.attune');
131+
} finally {
132+
await close(server);
133+
}
134+
} finally {
135+
await close(rpc.server);
136+
fs.rmSync(tmp, { recursive: true, force: true });
137+
}
138+
console.log('PASS archive-node indexes game events and serves read-only API');
139+
}
140+
141+
run().catch(function(err) {
142+
console.error('FAIL archive-node test: ' + (err && err.stack || err));
143+
process.exit(1);
144+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"sourceNodes": [
3+
"https://api.viz.world/",
4+
"https://node.viz.cx/"
5+
],
6+
"dataDir": "./data/archive-node",
7+
"host": "127.0.0.1",
8+
"port": 3007,
9+
"startBlock": 1,
10+
"batchSize": 50,
11+
"requestDelayMs": 120,
12+
"timeoutMs": 8000,
13+
"maxBlocksPerRun": 0
14+
}

0 commit comments

Comments
 (0)