Two defects that both fail silently, which is worse than erroring.
Every wallet emits EIP-55 checksummed addresses. The hex pattern accepts
mixed case, so a checksummed address passed validation and then matched no
row — the route answered 404, and the page rendered WLUX, a contract holding
149M LUX, as an empty EOA. The stored hash is lowercase, so the two spellings
have to fold to one key. That fold now happens once, in j()/csvHandler(),
which every route already passes through; handlers keep doing lookups and
stay in their lane. Only {hash} and {addr} fold — {id}, {pair}, {prefix} and
{item} are not hex and are case-significant.
formatToken emitted address/holders/trust_score while the SPA's TokenInfo
(luxfi/explore types/api/token.ts) reads address_hash/holders_count, so
TokensTable keyed every row on undefined and the page crashed. Renamed to
the contract rather than shimming the client, and fixed the second producer
of the same object in formatTokenTransfer, which had drifted to its own
spelling. trust_score had no consumer anywhere: TokenReputation is
'ok' | 'scam', a fraud flag, not the 0-100 completeness heuristic that
computed it — deriving one from the other would have called any token
lacking an icon a scam. reputation is null until there is a real oracle, and
the dead scorer is gone. Its toBool/toInt helpers were generic coercion
braided into a scoring file; they move to convert.go with their tests.
Co-authored-by: Hanzo Dev <[email protected]>
luxd serves exactly one HTTP prefix -- server/http/server.go:33 sets
baseURL = "/v1" and every route derives from it -- so /ext/* 404s on any
luxd. Avalanche is the mirror image: api.avax.network really does serve
/ext/bc/C/rpc, and pointing it at /v1/ breaks it.
31a1ee7 converted README.md only. This finishes the job and corrects the
overshoot in the same pass:
config/chains.yaml sparklepony, testnet.sparklepony, pars,
testnet.pars -> /v1/ (luxd hosts)
avax stays on /ext/
chains.example.yaml avax stays on /ext/
docs/configuration.mdx LUX_{C,P,X}_RPC and the c/p/x_chain block, all
127.0.0.1:9630 and :9650 luxd -> /v1/
docs/index.mdx localhost:9650 and api.lux.network -> /v1/
Measured, positive and negative control per host:
api.lux.network/v1/bc/C/rpc -> 200 0x17871 /ext -> 404
api.pars.network/v1/bc/C/rpc -> 200 0x78d65 /ext -> 404
api.lux-test.network/v1/... -> 200 0x17870 /ext -> 404
evm/transport/transport_test.go is deliberately left at HEAD. Its
isLuxdEndpoint gate is literally strings.Contains(endpoint, "/v1/"), so
the table is /v1/ throughout by design.
go test ./evm/transport/... ok 15.429s. Both YAML files parse. This repo
does NOT build before this change -- cmd/indexerd fails at link with
duplicate sqlite3 symbols, verified identical with these changes stashed
-- so nothing here is tagged.
Co-authored-by: Hanzo Dev <[email protected]>
Currency bump: golang builder images -> 1.26.5 (latest stable).
Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.
No go.mod go directive was changed. Builder images move; directives stay.
Co-authored-by: Hanzo Dev <[email protected]>
Both workflows here named labels no runner on this forge carries. docker.yml
used the bare `[self-hosted, linux, amd64]` triple and docs.yml the same; the
pool is named `lux-build-linux-amd64`, nothing answers to the triple, and a job
naming a label nothing carries does not fail — it waits. Note that
.github/actionlint.yaml in this repo has registered `lux-build-linux-amd64` as
the self-hosted label for some time; the workflows had simply never followed it.
The image build moves to .hanzo/workflows/release.yml. Two further things had
to change or it would have failed on the first runner that claimed it:
* ghcr login used the instance GITHUB_TOKEN, which on this forge is a Hanzo
Git token with no rights at ghcr.io. It now uses GHCR_USER and GHCR_TOKEN,
the org Actions secrets sourced from KMS.
* the layer cache was `type=gha`, the GitHub-hosted Actions cache service,
now the backend-independent registry cache.
Dropped with it: the linux/arm64 half of the image. It was produced by QEMU
emulation on an amd64 runner, which is hours rather than minutes. This image is
amd64-only until we own an arm64 runner and it can return as a native matrix
entry.
docs.yml's build survives as .hanzo/workflows/docs.yml; its publish does not.
configure-pages, upload-pages-artifact and deploy-pages all speak to the GitHub
Pages API, which this forge does not have. Building still fails on the commit
that breaks the docs, and it stays path-filtered to docs/**, but the site is no
longer published from CI — that returns when it is served from the PaaS.
Co-authored-by: Hanzo Dev <[email protected]>
These hashes were recorded from direct VCS fetches made while GOPRIVATE covered
github.com/luxfi/*, which switches off the module proxy and checksum
verification together. Tags moved afterwards, so what is written down here no
longer matches what sum.golang.org holds and the build refuses to verify.
proxy.golang.org still serves the originally-published bytes and those verify
against the log, so dropping only the disagreeing lines and re-tidying restores
a verified module graph with no version change. Lines that agree are untouched;
this is not a regenerated go.sum.
The failure followed the machine rather than the repository — the same commit
built wherever the module cache was clean. Any machine or CI runner that
fetched luxfi modules under the old GOPRIVATE holds the same poisoned entries.
Co-authored-by: Hanzo Dev <[email protected]>
Every figure below was rendered as 0 on explore.lux.network while the
chain said otherwise. None of them was missing data — the queries asked
for columns that are not there, or read columns nothing writes.
/addresses answered {"items":[]} while /stats reported 72 addresses:
the ORDER BY named `transactions_count`, which is the Blockscout-legacy
spelling; luxfi/indexer's evm_addresses spells it `tx_count`. SQLite
raised "no such column", the handler swallowed it into an empty page,
and it stayed empty forever. Same guess broke evm_tokens' address
column. detectColumn now asks the database instead.
Token "Holders 0" sat directly above a populated holder list:
evm_tokens.holder_count is never written by the indexer. Holders and
transfers are now counted from evm_token_balances / evm_token_transfers
in the same statement that selects the token — one query, not N+1 — and
the token list is ranked by that count instead of by a column of zeros.
Address balance read 0 for every address, WLUX's 159,126,795.518 LUX
included: evm_addresses.balance is written as the literal '0' on first
sight and never updated. Current state belongs to the chain, not to an
index of it, so balances now come from the node — one batched
eth_getBalance per page. No node configured, or no answer, means the
balance is unavailable, and unavailable is null.
"Burnt fees 100%" on every block: burnt_fees was gas_used x base_fee,
which is the Ethereum EIP-1559 burn. Lux does not burn it. creditTxFee
credits the whole fee to the coinbase (0x0100..0000, no code, no key)
unless the fee split is active, and it has never fired on mainnet.
average_block_time was structurally 0 — subtracting two TEXT datetimes
in SQLite yields 0 — and gas_used_today compared those TEXT timestamps
lexically against datetime('now'), matching nothing. Both go through
strftime('%s') now, and transactions_today is counted rather than
hardcoded nil.
Co-authored-by: Hanzo Dev <[email protected]>
Chasing these one at a time was whack-a-mole — every repair surfaced the next
moved tag (age, pq, threshold, zap, consensus, constants, sdk, keys…). This is
the systematic pass instead: every distinct luxfi/hanzoai module@version in the
workspace (692 of them) was resolved against proxy.golang.org + a direct fetch,
and any go.sum line disagreeing with what is actually served was corrected.
Not an attack, and checked rather than assumed: for each case the proxy and a
direct fetch AGREE with each other and differ from the recorded hash. Two
independent transports agreeing means nothing is rewriting bytes in flight —
the tags moved at source. sum.golang.org still holds the original, but
GOPRIVATE covers github.com/luxfi/* with GOSUMDB=off, so our own modules never
consult the checksum DB and a moved tag splits consumers silently instead of
failing at publish time.
Only the recorded hash changes. No selected version moves, and `go mod tidy`
was deliberately NOT run, so nothing is upgraded as a side effect.
Verified: `go list -m all` resolves with no checksum error.
The durable fix is upstream: treat a published version as immutable. Cut
x.y.z+1 instead of moving a tag — with GOSUMDB off, no consumer can detect it.
Several luxfi versions carry TWO different contents across this workspace,
because published tags were moved instead of a new patch being cut:
[email protected], [email protected], [email protected], [email protected], [email protected].
Adjudicated before editing, since "checksum mismatch / SECURITY ERROR" is also
what a real supply-chain attack looks like. It is not one here: sum.golang.org
holds the OLD hash while proxy.golang.org and a direct fetch BOTH serve the same
NEW bytes. Two independent transports agreeing means nothing is rewriting
content in flight — the tag moved at source. The sumdb entry is a fossil:
GOPRIVATE covers github.com/luxfi/* with GOSUMDB=off, so our own modules never
consult the checksum DB and a moved tag splits consumers silently.
The old bytes are served by nothing now, so a stale pin can never build.
Corrected to the only content that exists, in BOTH line forms (h1: and
/go.mod h1:) — Go reports these one at a time, so a partial fix just relocates
the error.
Deliberately no `go mod tidy`: this changes no selected version, only the
recorded hash of versions already chosen.
Verified: `go list -m all` resolves with no checksum error.
The durable fix is upstream: never move a published tag, cut x.y.z+1 instead.
Standalone explorer API now serves a P-Chain variant when a pchain_blocks
table is present: GET /blocks, /blocks/{id}, /main-page/blocks read the
linear block table, and a new GET /validators returns the synced validator
set (weight/stake, start/end time, uptime, delegation fee, BLS signer
pubkey+PoP, potential reward, connected, per-node delegator_count).
Adapter fixes required for the default SQLite query backend:
- SyncValidators no longer uses Postgres-only to_timestamp(); timestamps are
bound as time.Time, which both the SQLite and Postgres drivers accept for a
TIMESTAMP column (regression covered by a real RPC->SQLite test).
- Capture the validator BLS signer (publicKey + proofOfPossession); new
bls_public_key / bls_proof_of_possession columns on pchain_validators.
- Fall back to weight when the RPC omits the deprecated stakeAmount.
EVM path untouched: block/main-page routes branch on the platform flag; all
other routes are unchanged.
Co-authored-by: Hanzo Dev <[email protected]>
Drop the Avalanche-heritage /ext prefix; /v1 is the single canonical route
surface (one way, no backward compat). The node's baseURL is the source of
truth; clients, SDKs, CLI, indexer, maker, genesis, netrunner, and the
k8s/compose/gateway/explorer configs are updated to match.
Co-authored-by: Hanzo Dev <[email protected]>
Two distinct tests (ParseGovernanceEvents vs ParseTeleportEvents empty-log
handling) collided on the name TestEmptyLogs, breaking compilation of the
evm/defi test package. Rename to TestGovernanceEmptyLogs / TestTeleportEmptyLogs
— no coverage change, package compiles + both pass.
Co-authored-by: Hanzo Dev <[email protected]>
Replaces non-canonical scale-set / org-prefixed labels with the
existing labels every arcd host registers with. Matches evo for
amd64 and spark for arm64. No new labels added.
The 4 explorer page components hard-coded '' as the fallback
when VITE_COIN is unset. Per workspace rule the lux tree must not
default to the token symbol — switch the fallback to LUX
(the actual Lux Network coin).
Real verifier + table-init + RW DB open. Replaces the previous no-op stub that left evm_smart_contracts empty on every chain. See PR for the JSON submission shape.
Six fixes for the Blockscout-derived SPA against the luxfi/indexer + luxfi/explorer backend, batched together as one squash.
* `formatAddress` aliases `tx_count`⇆`transactions_count` and `balance`⇆`coin_balance` so the SPA's `i.tx_count.toLocaleString()` and `Number(i.balance)/1e18` stop crashing.
* `/smart-contracts/{addr}` returns 200 `{is_verified:false}` on no-row instead of 404 — the SPA fetches that endpoint unconditionally on every address page, including EOAs, and the 404 was console noise.
* `evm/indexer.go` now indexes the `receipt.Logs` slice into `evm_logs`, detects ERC-20 (3-topic) and ERC-721 (4-topic) Transfer events, writes `evm_token_transfers`, and best-effort resolves token metadata (`name`/`symbol`/`decimals`/`totalSupply` via `eth_call`) into `evm_tokens`. Previously these tables stayed empty.
* `formatToken` + `formatTokenTransfer` fall through both column-spelling variants — luxfi schema (`address`, `token_type`, `tx_hash`, `value`) and Blockscout-legacy (`contract_address`, `type`, `transaction_hash`, `amount`).
* `fmtNum` detects `0x`-prefixed strings (Transfer event `data`, RPC `gas_price`/`value`/`base_fee`) and converts to decimal via `math/big`. Pure decimal strings pass through unchanged.
* `getToken` + `tokenCounters` use a per-schema `tokenAddrCol` (`address` for evm_*, `contract_address` for Blockscout-legacy) instead of hard-coding `WHERE contract_addr = ?` — fixes `/v1/indexer/tokens/{addr}` 404 on the luxfi schema.
Verified live against devnet 8675312 (anvil) with 10010 blocks indexed: 9 tokens, 33+ transfers, address-detail + token-detail pages render.
Co-authored-by: Darkhorse7stars <[email protected]>
formatAddress was inferring is_contract from `length(code) > 0`, but
the SQLite address schema in evm/indexer.go writes `code` as the
default empty string and tracks contract status in a separate
boolean `is_contract` column. Net result: every address — including
real deployed contracts — returned is_contract: false in the API,
because empty code → derived isContract=false.
Switch to reading the boolean column directly. Fall back to the
code-length heuristic only when the boolean is absent (legacy
Postgres shape).
Verified against live devnet 8675312 — DB has:
pool 0x58a86a… is_contract=1, code_len=0
USDL 0xc65ea8… is_contract=1, code_len=0
The API was returning false for both; with this change, true.
* evm: honor Config.StartBlock when DB is fresh (skip historical backfill)
When running against a mainnet-fork devnet (e.g. an anvil
--fork-url=... mainnet snapshot), the chain's reported latest block is
in the tens of millions. The existing behavior was to backfill from
block 0, which is wasteful (~14 days at ~21 blocks/sec) AND surfaces
millions of irrelevant pre-fork txs in the explorer UI.
Add StartBlock to evm.Config. When the indexer's database has no
existing rows (MAX(number) == 0), the indexer now honors StartBlock as
the lowest block to index. Once any block is stored, StartBlock is
ignored and the indexer resumes from MAX+1 as before, so this is
backward-compatible for fresh-genesis chains where StartBlock is left
zero.
Wire-up in luxfi/explorer is a separate change — supervisor.go reads
the value from `chains[].indexer.start_block` in chains.yaml.
* explorer: never emit null for address counters (FE crash fix)
The Blockscout-derived SPA renders the address page with:
<span>{address.transactions_count.toLocaleString()}</span>
— no null guard. When the indexer returned JSON null for
`transactions_count` (or `token_transfers_count`), the SPA crashed
with "Cannot read properties of undefined (reading 'toLocaleString')"
and rendered its global ErrorBoundary instead of the address page.
Two reasons the indexer was returning null:
1. Schema drift. The SQLite path in evm/indexer.go writes addresses
with the trimmed columns `tx_count` and `code`. formatAddress and
addrCounters were reading the legacy column names
`transactions_count` and `contract_code`, so SQLite-backed installs
always returned null + is_contract=false even for real deployed
contracts.
2. No fallback. Even on the Postgres path, an address that hasn't
sent or received a tracked event yet has NULL counters in some
schemas; we returned that NULL as JSON null instead of zero.
Fix both:
- formatAddress and addrCounters now check both column-name
variants and coerce nil to int64(0).
- is_contract now also honors the `code` column (treats "" / "0x"
as no code, anything else as a contract).
Repros locally against the live cluster: GET /v1/indexer/evm/addresses/
0x58a86aafb6cdd0989c799353c891e420fb530e0a returned
transactions_count: null, token_transfers_count: null, is_contract: false
for a real deployed AMM pool contract → after this change → integer
counters + is_contract: true.
* evm: upsert addresses with tx_count increment + eth_getCode lookup
Two bugs the previous fix (#9) couldn't reach from the format layer:
1. `transactions_count` was always 0 for every address. The address
upsert SQL was `INSERT OR IGNORE` / `ON CONFLICT DO NOTHING`, so
the first time we saw an address we inserted tx_count=0 and every
subsequent tx referencing that address was a no-op. The counter
never moved.
2. `is_contract` was wrong for contracts not created via top-level
tx (e.g. a SimplePool deployed via `new SimplePool(...)` inside
a foundry script): the indexer only flagged is_contract=true when
tx.ContractAddress matched, which is empty for inner CREATEs.
Fix:
• Change the upsert to ON CONFLICT DO UPDATE with
`tx_count = old + delta` and `is_contract = OR-merge`. Each
block contributes a per-address delta (count of distinct txs
in that block touching the address; self-sends dedup to 1).
• After collecting per-block addresses, call eth_getCode on every
address that wasn't already pinned as a contract by
tx.ContractAddress. If the chain returns non-empty code,
mark is_contract=true at upsert time. Cost is bounded by
unique addresses per block (~5 typical).
• Same logic in backfillAddresses for one-shot bootstrap.
Manual repro on the live cluster after deploying this fix:
GET /v1/indexer/evm/addresses/<pool>
transactions_count: 4 is_contract: true
(was: 0 / false despite indexer having logged the deploy + 3 swaps)
* evm: honor Config.StartBlock when DB is fresh (skip historical backfill)
When running against a mainnet-fork devnet (e.g. an anvil
--fork-url=... mainnet snapshot), the chain's reported latest block is
in the tens of millions. The existing behavior was to backfill from
block 0, which is wasteful (~14 days at ~21 blocks/sec) AND surfaces
millions of irrelevant pre-fork txs in the explorer UI.
Add StartBlock to evm.Config. When the indexer's database has no
existing rows (MAX(number) == 0), the indexer now honors StartBlock as
the lowest block to index. Once any block is stored, StartBlock is
ignored and the indexer resumes from MAX+1 as before, so this is
backward-compatible for fresh-genesis chains where StartBlock is left
zero.
Wire-up in luxfi/explorer is a separate change — supervisor.go reads
the value from `chains[].indexer.start_block` in chains.yaml.
* explorer: never emit null for address counters (FE crash fix)
The Blockscout-derived SPA renders the address page with:
<span>{address.transactions_count.toLocaleString()}</span>
— no null guard. When the indexer returned JSON null for
`transactions_count` (or `token_transfers_count`), the SPA crashed
with "Cannot read properties of undefined (reading 'toLocaleString')"
and rendered its global ErrorBoundary instead of the address page.
Two reasons the indexer was returning null:
1. Schema drift. The SQLite path in evm/indexer.go writes addresses
with the trimmed columns `tx_count` and `code`. formatAddress and
addrCounters were reading the legacy column names
`transactions_count` and `contract_code`, so SQLite-backed installs
always returned null + is_contract=false even for real deployed
contracts.
2. No fallback. Even on the Postgres path, an address that hasn't
sent or received a tracked event yet has NULL counters in some
schemas; we returned that NULL as JSON null instead of zero.
Fix both:
- formatAddress and addrCounters now check both column-name
variants and coerce nil to int64(0).
- is_contract now also honors the `code` column (treats "" / "0x"
as no code, anything else as a contract).
Repros locally against the live cluster: GET /v1/indexer/evm/addresses/
0x58a86aafb6cdd0989c799353c891e420fb530e0a returned
transactions_count: null, token_transfers_count: null, is_contract: false
for a real deployed AMM pool contract → after this change → integer
counters + is_contract: true.
When running against a mainnet-fork devnet (e.g. an anvil
--fork-url=... mainnet snapshot), the chain's reported latest block is
in the tens of millions. The existing behavior was to backfill from
block 0, which is wasteful (~14 days at ~21 blocks/sec) AND surfaces
millions of irrelevant pre-fork txs in the explorer UI.
Add StartBlock to evm.Config. When the indexer's database has no
existing rows (MAX(number) == 0), the indexer now honors StartBlock as
the lowest block to index. Once any block is stored, StartBlock is
ignored and the indexer resumes from MAX+1 as before, so this is
backward-compatible for fresh-genesis chains where StartBlock is left
zero.
Wire-up in luxfi/explorer is a separate change — supervisor.go reads
the value from `chains[].indexer.start_block` in chains.yaml.
Add a Subscriber() method so embedders (e.g. luxfi/explorer) can
install Subscriber.OnBroadcast callbacks to bridge block events into
their own pub/sub fabric without exposing the unexported field.
Companion to PR #6 (Subscriber.OnBroadcast callback).
Adds an optional `OnBroadcast func(eventType string, data any)` field
to the EVM indexer's Subscriber. Fired alongside the existing
WebSocket broadcast on every BroadcastBlock — lets an enclosing
process (e.g. luxfi/explorer, which embeds this indexer as a library)
re-publish the same events to its own pub/sub fabric (SSE channels,
gRPC streams, NATS) without forking the indexer or touching the
internal WebSocket flow.
Wire shape:
- New field is zero-default nil → existing standalone behavior
unchanged. No breaking changes for current callers.
- Invoked synchronously from the indexing goroutine, so consumers
must NOT block. luxfi/explorer's SSE registry uses 64-deep
buffered channels with drop-on-full; recommended pattern for
any consumer.
- Event-name string `"blocks"` (plural) matches the channel name
the explorer SPA expects.
Verified:
- `go build ./...` → clean
- `go test ./evm/...` → passes (existing tests don't touch this field;
nil-default keeps them happy)
Cross-org runs-on dispatch failure: hanzo-build-linux-amd64 scale set is
registered to github.com/hanzoai and silently rejects luxfi/* jobs.
Switch to lux-build (githubConfigUrl=github.com/luxfi, max 20).
Imaginary runner label `lux-build-linux-amd64` does not exist. The live
amd64 pool is `hanzo-build-linux-amd64`.
- docker.yml: docker job
- docs.yml: build + deploy jobs