Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,78 @@ jobs:
env:
DATABASE_URL: postgres://randscan:randscan@localhost:5432/randscan

# The explorer against a real `rand-node`: tests/real_node.rs spawns the latest fullnode
# release (node + `rand` wallet) on a one-validator chain, makes a faucet mint, a proved
# transfer, a deploy and a confidential call (and an RPL-2 invoke where the build has it), and
# compares the explorer's every public number with the node's — including the REST contract
# zusd.money and randprotocol.org read (/bridge, /bridge/assets, /tokens/:index, /envelopes,
# /stats, /supply). It gates: the test skips a sub-check the released binary has no flag for
# (with a printed reason), never the whole run. The wallet proves under the chain's `test` FRI
# profile in its own release binary, so the test itself builds in the default profile.
real-node:
name: Real node
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: randscan
POSTGRES_PASSWORD: randscan
POSTGRES_DB: randscan
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-real-node-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

- name: Download the fullnode release
env:
GH_TOKEN: ${{ github.token }}
# A tag (e.g. v0.7.1) pins the run to that release; empty means the latest.
FULLNODE_TAG: ""
run: |
mkdir -p "$RUNNER_TEMP/fullnode"
gh release download $FULLNODE_TAG --repo randprotocol/fullnode \
--pattern rand-node --pattern rand --pattern SHA256SUMS --dir "$RUNNER_TEMP/fullnode"
(cd "$RUNNER_TEMP/fullnode" && sha256sum -c --ignore-missing SHA256SUMS)
chmod +x "$RUNNER_TEMP/fullnode/rand-node" "$RUNNER_TEMP/fullnode/rand"
"$RUNNER_TEMP/fullnode/rand-node" --version
echo "RAND_NODE_BIN=$RUNNER_TEMP/fullnode/rand-node" >> "$GITHUB_ENV"
echo "RAND_CLI=$RUNNER_TEMP/fullnode/rand" >> "$GITHUB_ENV"

- name: Explorer against the real node
run: cargo test -p randscan-api --test real_node -- --nocapture
env:
DATABASE_URL: postgres://randscan:randscan@localhost:5432/randscan
RANDSCAN_REQUIRE_REAL_NODE: "1"
# 1 s blocks: the anchor window is four minutes at 256 blocks (more under the
# genesis' 1 024-block proof window), room for a shared runner's proving.
RAND_BLOCK_INTERVAL_MS: "1000"

# Next.js frontend build
frontend:
name: Frontend Build
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,40 @@ deploy and a confidential call) and compares every public number. Both need `DAT
the real-node test also needs `RAND_NODE_BIN` (a build of the fullnode's `shielded-s3` branch
or later, with `rand` beside it or `RAND_CLI` set).

### Integration test against a real node

`tests/real_node.rs` is also the contract test for the two sites that read this API with no tests
of their own: zusd.money's `src/lib/balance-sheet.mjs` (rendered by `BalanceSheet.astro`) and randprotocol.org's `BridgeReserves.astro`
(`/bridge` → `enabled`, `mint_paused`; `/bridge/assets` → `index`, `chain`, `symbol`, `locked`;
`/tokens/{index}` → `total_supply`) and randprotocol.org's `balance.js`
(`/envelopes?from_leaf=&limit=` → `notes`, `total_leaves`, `next_leaf`, and per leaf `leaf_index`,
`cm`, `height`, `tx_hash`, `envelope`, `public`). On a chain without a bridge it pins what those
pages must handle (`enabled: false`, `[]`, a 404 for an unknown token); it also checks that
`/stats` carries `limits` and `gas_prices`, that every `/supply` amount is the node's decimal
string, and that the node's newer fields (`rand_getLimits.fee_rules`,
`rand_getSupply.base_fees_burned`) pass through when served and are not required when not.

```bash
gh release download --repo randprotocol/fullnode --pattern rand-node --pattern rand --dir /tmp/fullnode
chmod +x /tmp/fullnode/rand-node /tmp/fullnode/rand
DATABASE_URL=postgres://randscan:randscan@localhost:5432/randscan_it \
RAND_NODE_BIN=/tmp/fullnode/rand-node RAND_CLI=/tmp/fullnode/rand \
cargo test -p randscan-api --test real_node -- --nocapture
```

| Variable | Meaning |
|---|---|
| `DATABASE_URL` | a PostgreSQL database the test may re-index (use a scratch one: the chain tables are truncated on a chain switch) |
| `RAND_NODE_BIN` | the `rand-node` binary to spawn |
| `RAND_CLI` | the `rand` wallet; defaults to `rand` beside `RAND_NODE_BIN` |
| `RAND_BLOCK_INTERVAL_MS` | block interval, default `500`; raise to `1000`–`1500` on a slow prover |
| `RANDSCAN_REQUIRE_REAL_NODE` | fail instead of skipping when either of the first two is unset |

The wallet proves locally under the chain's `test` FRI profile; a run takes a few minutes. A
check the given build has no flag for (an RPL-2 invoke on a pre-v0.6.8 node, `fee_rules` on a
node before fee feedback) is skipped with a printed reason. CI's `real-node` job runs this against
the latest fullnode release on every push and pull request, and gates on it.

### Environment

| Variable | Default | Description |
Expand Down
9 changes: 7 additions & 2 deletions crates/randscan-api/tests/common/mock_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,10 @@ impl MockChain {
"height": self.head()["height"], "genesis_deposited": "1000000000000", "genesis_staked": "100000000000000",
"faucet_minted": "100000000000", "withdraw_deposited": "0", "fees_paid": "3000000", "burned": "0",
"pool_value": "1099997000000", "register_total": "100000003000000", "total_supply": "101100000000000",
"invariant_holds": true
"invariant_holds": true,
// Fee feedback (fullnode `feat/fee-feedback`): `"0"` on a chain without
// `fees.burn_base`. Kept so this mock stays a superset of the live shape.
"base_fees_burned": "0"
});
// RPL-2 (a v0.6.8 node): what invokes paid out of vaults and what vaults hold;
// "0" on a chain without the section.
Expand Down Expand Up @@ -450,7 +453,9 @@ impl MockChain {
"max_block_bytes": 20971520, "max_call_envelope_bytes": 65536, "max_program_public_words": 32768,
"envelope_bytes": 1860, "hardening_v6": true, "hc_auth": h("hc_auth"),
"gas_price": "100", "byte_price": "800", "gas_metering": "circuit",
"bundle_gas_limit": 20479, "adjust_bps": 1250 });
"bundle_gas_limit": 20479, "adjust_bps": 1250,
// Fee feedback: `null` on a chain without a `fees` section.
"fee_rules": null });
// RPL-2 (a v0.6.8 node): the `program_state` group, `null` without the section.
l["program_state"] = if self.program_state {
json!({ "cell_fee": "10000000", "max_reads": 8, "max_writes": 8, "max_payouts": 4 })
Expand Down
5 changes: 4 additions & 1 deletion crates/randscan-api/tests/mock_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,9 @@ async fn indexes_every_shielded_kind_and_survives_hard_forks() {
"program_state": { "cell_fee": "10000000", "max_reads": 8, "max_writes": 8, "max_payouts": 4 },
// Audit v6's fields, absent from this mock's (chain-18-shaped) reply: their defaults.
"max_gas_price": null, "max_byte_price": null, "byte_load": null, "admission_by_vote": false,
"testnet": false, "slashing": null, "binding_domain": null, "proof_window_blocks": null }));
"testnet": false, "slashing": null, "binding_domain": null, "proof_window_blocks": null,
// Fee feedback: the mock serves `null`, a chain without a `fees` section.
"fee_rules": null }));
// Off `rand_status`, refreshed every commit: the auth guest, and the tip's live prices (one
// 12.5% step above the genesis prices the limits report — a dynamic chain moves them).
assert_eq!(stats["hc_auth"], h("hc_auth"));
Expand Down Expand Up @@ -491,6 +493,7 @@ async fn indexes_every_shielded_kind_and_survives_hard_forks() {
let (_, _, supply) = call_api(&live.app, "/api/v1/supply").await;
assert_eq!(supply["invariant_holds"], true);
assert_eq!((&supply["program_rand_out"], &supply["program_rand_held"]), (&json!("300"), &json!("700")), "RPL-2 vault counters: {supply}");
assert_eq!(supply["base_fees_burned"], "0", "fee feedback's burn counter passes through: {supply}");
let (_, _, bridge) = call_api(&live.app, "/api/v1/bridge").await;
assert_eq!(bridge["enabled"], true);
assert_eq!(bridge["assets"][0]["index"], 1);
Expand Down
Loading
Loading