Technical design & implementation guide: XEN (Ethereum) snapshot → authenticated off-chain claim → XCORE payout
| Field | Value |
|---|---|
| Document status | DRAFT, design only. Nothing here is deployed. |
| Audience | Small engineering team (2–4 people), project owner, reviewer/auditor |
| Source token (eligibility) | XEN Crypto, Ethereum mainnet ERC-20 0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8 |
| Claimed token (payout) | XCORE (Solana-side in prior work; the design here works on any payout chain) |
| Snapshot | ASSUMPTION: 2026-10-01 00:00:00 UTC. You must freeze the year, timestamp and block in the Snapshot Manifest before launch (§2.2). |
| Claim page (concept) | wxnt-sale-sim.vercel.app/claim came from earlier concept work. This document does not change any live site. |
Conventions
<<PLACEHOLDER>>: a value the owner must decide or fill in. Never ship one.- EXAMPLE ONLY: illustrative numbers or formulas. They are not a decision.
- ASSUMPTION: something this document assumes. Confirm it before launch.
- VERIFY: a fact to check against chain data or primary sources before you rely on it.
- No real secrets, keys or live endpoints appear here. All env vars are listed by name only.
Table of Contents
- Overview & Architecture
- Snapshot Production
- Eligibility & Allocation Rules
- Off-Chain Database Design
- Wallet Authentication
- Claim API
- Payout Fulfillment Options
- Webpage UX Flow
- Security Threat Model & Controls
- Operations
- Testing & Acceptance Criteria
- Build Sequence & Open Decisions
- Alternatives Considered
- Appendix: Assumptions Register & Glossary
1. Overview & Architecture
1.1 What this system does
- Snapshot. Records every Ethereum address that held a non-zero XEN balance at one fixed snapshot block (the first block at or after 2026-10-01 00:00:00 UTC, per the ASSUMPTION above).
- Allocation. Turns each balance into an XCORE allocation using a pluggable, versioned formula. The result is a fixed, hashed allocation table.
- Database. Loads the table into an off-chain Postgres database, which is the system of record for who can claim and how much.
- Claim webpage. A holder connects their Ethereum wallet and proves ownership with a SIWE / EIP-4361 signed message. They then bind a destination address on the payout chain (on Solana, signed with Phantom or the X1 wallet) and submit a claim.
- Payout. A fulfillment worker pays XCORE to the bound destination. It is either a rate-limited backend hot wallet or a batched multisig export. Each claim is paid at most once, enforced by DB constraints plus an on-chain idempotency reference.
- Audit. Every state change goes to an append-only, hash-chained audit log. A daily reconciliation job compares DB "paid" records with on-chain transfers.
The design is off-chain trust-based: users trust the operator to apply the published snapshot and formula honestly and to pay out. Transparency comes from publishing the snapshot artifacts, their SHA-256 manifest and the allocation formula, so anyone can recompute them. §13 compares this with an on-chain Merkle distributor.
1.2 Architecture diagram
Diagram source (Mermaid)
flowchart LR
subgraph ETH["Ethereum mainnet (read-only)"]
XEN["XEN ERC-20<br/>0x0645...6Fb8"]
ARCH["Archive node(s)<br/>2 independent providers"]
end
subgraph SNAP["Snapshot pipeline (offline, deterministic)"]
BLK["Block selector<br/>first block ≥ T_snapshot"]
SCAN["Transfer log scanner<br/>checkpointed"]
REC["Balance reconstruction<br/>+ reconciliation"]
ALLOC["Allocation engine<br/>formula vN"]
MAN["Snapshot manifest<br/>SHA-256 + signature"]
end
subgraph BACK["Backend (Vercel functions + Postgres/Neon)"]
ETL["ETL loader<br/>idempotent import"]
DB[("Postgres<br/>allocations, claims,<br/>nonces, jobs, audit")]
API["Claim API<br/>/auth/* /claims/*"]
Q["Payout queue<br/>payout_jobs table"]
W["Fulfillment worker<br/>(hot wallet signer<br/>or batch export)"]
RECON["Daily reconciliation<br/>DB vs chain"]
AUD["Append-only audit log<br/>hash-chained"]
end
subgraph WEB["Claim webpage"]
UI["/claim UI"]
EW["Ethereum wallet<br/>(SIWE sign)"]
SW["Solana wallet<br/>Phantom / X1 (bind sign)"]
end
subgraph PAY["Payout chain (e.g. Solana)"]
XC["XCORE token / mint"]
end
XEN --> ARCH --> BLK --> SCAN --> REC --> ALLOC --> MAN
MAN --> ETL --> DB
UI <--> API
EW -. signature .-> UI
SW -. signature .-> UI
API <--> DB
API --> Q
Q --> W --> XC
W --> DB
XC --> RECON --> DB
DB --> AUD
API --> AUD
W --> AUD1.3 Components and their trust boundaries
| Component | Runs where | Holds secrets? | Writes to DB? | Notes |
|---|---|---|---|---|
| Snapshot pipeline | Offline workstation or CI job | Only RPC API keys | No (writes files) | Must be deterministic and re-runnable |
| ETL loader | One-off CLI, admin-run | DB migration role | Yes (allocations, manifest only) | Refuses to load if manifest hash ≠ artifact hash |
| Claim API | Vercel serverless functions | Session signing key, DB app role | Yes (nonces, claims, jobs, audit) | Cannot sign payouts |
| Fulfillment worker | Separate isolated runtime (not the web function) | Hot-wallet key (option a) or none (option c) | Yes (payout_jobs, claims status) | Key never reachable from the API process |
| Reconciliation job | Scheduled job | Read-only RPC key, DB read role | Yes (recon results only) | Alerts on any mismatch |
| Webpage | Browser | None | No (via API only) | Wallets sign messages; the page never sees private keys |
1.4 Non-goals
- No smart contract changes to XEN. XEN is only read.
- No sybil resistance beyond what the snapshot gives (see §3.6).
- No custody of user funds, and no fiat.
- No PII collection (§9.8).
2. Snapshot Production
2.1 Token facts (verify before running)
| Property | Value | Status |
|---|---|---|
| Chain | Ethereum mainnet, chainId 1 |
Fixed |
| Contract | 0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8 |
Given |
| Decimals | 18 |
VERIFY by calling decimals() at the snapshot block |
| Deployment block | about block 15.7M (October 2022) | VERIFY: get the exact creation tx/block from an explorer or with eth_getCode binary search. Record it in the manifest. |
| Supply model | Minted on claimMintReward (Transfer from 0x0). Burns reduce supply (Transfer to 0x0). |
VERIFY by reading the verified source |
| Events used | Transfer(address indexed from, address indexed to, uint256 value), topic0 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef |
Standard ERC-20 |
2.2 Snapshot timestamp and block selection
ASSUMPTION: "Oct 1st" means 2026-10-01 00:00:00 UTC, Unix timestamp T = 1790812800.
(Check it: date -u -d '2026-10-01T00:00:00Z' +%s → 1790812800.)
⚠️ Owner action required: confirm the year (2026), timestamp and timezone, then freeze them together with the resulting block number and block hash in
snapshot_manifestbefore any public announcement or launch. If they change after publication, every allocation is invalid.
Rule: the snapshot block B is the first block whose timestamp ≥ T. Balances are taken after all transactions in block B are applied (end-of-block state). That is the state balanceOf(addr, blockTag=B) returns.
Alternative rule: last block with
timestamp < T. Pick one and write it in the manifest fieldblock_rule. This document usesfirst_block_gte_timestamp.
Algorithm (binary search on an archive node):
def find_first_block_gte(rpc, T):
lo, hi = DEPLOY_BLOCK, rpc.eth_blockNumber()
assert rpc.get_block(hi).timestamp >= T, "snapshot time not reached yet"
while lo < hi:
mid = (lo + hi) // 2
if rpc.get_block(mid).timestamp >= T:
hi = mid
else:
lo = mid + 1
B = lo
assert rpc.get_block(B).timestamp >= T
assert rpc.get_block(B - 1).timestamp < T
return B
Recording and verification:
- Run the search against two independent archive providers (e.g. a self-hosted Erigon/Reth node plus one hosted provider). Both must return the same
Band the sameblock_hash. - Wait for finality: only take the snapshot once
Bis finalized (eth_getBlockByNumber("finalized")≥ B; post-Merge this is usually about 2 epochs, ~13 minutes). In practice the snapshot runs days later, so this is automatic, but the check still runs. - Record
block_number,block_hash,block_timestamp,parent_hashandblock_rulein the manifest. - Anyone can verify with a public explorer or any archive RPC: block
Bhas the recorded hash,ts(B) ≥ T, andts(B-1) < T.
2.3 Holder enumeration by Transfer-log scan
ERC-20 balances cannot be enumerated on-chain, so the holder set is rebuilt from Transfer events between DEPLOY_BLOCK and B inclusive.
Scan procedure:
CHUNK = 2_000 # adapt per provider log limits; shrink on "too many results"
state = load_checkpoint() or {"next_block": DEPLOY_BLOCK, "balances": {}, "log_count": 0}
while state["next_block"] <= B:
start = state["next_block"]
end = min(start + CHUNK - 1, B)
logs = rpc.eth_getLogs(address=XEN, topics=[TRANSFER_TOPIC],
fromBlock=start, toBlock=end)
logs.sort(key=lambda l: (l.blockNumber, l.logIndex)) # deterministic ordering
for l in logs:
if l.removed: raise ReorgError(l) # should never happen below finality
frm = to_checksum("0x" + l.topics[1][-40:])
to = to_checksum("0x" + l.topics[2][-40:])
val = int(l.data, 16)
if frm != ZERO: state["balances"][frm] = state["balances"].get(frm, 0) - val
if to != ZERO: state["balances"][to] = state["balances"].get(to, 0) + val
append_raw_log(l) # raw_logs.jsonl, for replay
state["log_count"] += len(logs)
state["next_block"] = end + 1
save_checkpoint(state, chunk_hash=sha256(logs_serialized)) # atomic write: tmp + rename
Implementation notes:
| Topic | Rule |
|---|---|
| Integer math | Use arbitrary-precision integers (Python int, JS BigInt, Postgres NUMERIC(78,0)). Never use floats. |
| Ordering | Sort by (blockNumber, logIndex). Order doesn't change the final sum, but it matters for detecting negative intermediate balances. |
| Negative balances | Any balance below 0 at any point means missing logs. Abort and re-scan that range from both providers. |
| Chunking | Adaptive: halve the range when the provider returns a "too many results" or size error, and grow again after successes. |
| Checkpointing | After each chunk, atomically write checkpoint.json (next_block, balances digest, cumulative log count, rolling hash of chunk hashes). Resume reads the checkpoint. |
| Raw log archive | Keep raw_logs.jsonl.zst so anyone can rebuild balances offline without RPC. |
| Cross-provider | Run the full scan against provider A and B. The per-chunk log counts and rolling hashes must match. |
| Performance | XEN has a very large Transfer history. Expect tens of millions of logs (VERIFY). Budget hours, use a local archive node where possible, and stream to disk instead of holding everything in memory. A tool like cryo or an indexer (e.g. your own Postgres) works too, as long as output is deterministic. |
2.4 Special address handling
| Category | Example | Default policy | Owner decision? |
|---|---|---|---|
Zero address 0x000…000 |
Mint source / burn sink | Never a holder. Mints and burns adjust supply only. | No |
| Dead/burn addresses | 0x000…dEaD and similar |
Excluded from allocation. Recorded in excluded_addresses with reason burn |
Confirm the list |
| XEN contract itself | 0x0645…6Fb8 holding XEN |
Excluded (self) |
Confirm |
| DEX liquidity pools (Uniswap V2/V3 pairs, etc.) | XEN/WETH pairs | Excluded by default: a pool contract can't claim, and LPs are the real economic owners. Option: look through the pool to LP holders (complex, out of scope for MVP). | YES |
| Exchange hot/cold wallets (CEX) | Known exchange addresses | Policy decision. An EOA can sign SIWE, but the claim would go to the exchange, not its users. Default: include, flagged cex_suspect, and handle manually |
YES |
| Other contracts (multisigs, vaults, staking, XENFT-related contracts, bridges) | eth_getCode(addr, B) != 0x |
Classified contract. Smart-contract wallets (Safe, etc.) can't use EIP-191 personal_sign directly. They need EIP-1271 verification (§5.6) or a manual process. Protocol contracts holding pooled user funds should be excluded or looked through. |
YES |
| Zero final balance | Sold everything before B | Not eligible. Kept in holders_all for audit, not loaded as allocations. |
No |
| Dust balance | Below threshold | See §3.4 | YES |
Classification step: for every address with balance > 0, call eth_getCode(addr, B) and store is_contract. Tag known addresses (pools, CEX, bridges) from a reviewed, versioned labels.csv whose hash goes in the manifest.
2.5 Reconciliation (mandatory before you publish)
| Check | Method | Pass criterion |
|---|---|---|
| Total supply | Σ balances (all non-zero, including excluded) vs totalSupply() at block B |
Exactly equal (integer) |
| Mint/burn consistency | Σ value(from=0x0) − Σ value(to=0x0) vs totalSupply(B) |
Exactly equal |
| Random sampling | Sample ≥ 1,000 random holders + top 500 by balance + every special address. Compare to balanceOf(addr, blockTag=B) via eth_call on both providers |
100% exact match |
| Zero-balance sampling | Sample 200 addresses that hit zero | balanceOf = 0 |
| Cross-provider | Full scan on provider A vs B | Identical holders.csv SHA-256 |
| Re-run determinism | Re-run from the raw log archive with no network | Identical artifacts byte for byte (§10.6) |
If XEN had any non-standard balance change without a
Transferevent (it shouldn't; VERIFY in the source), the supply check fails. Never "fix" this by adjusting numbers. Investigate.
2.6 Output artifacts
All files are UTF-8 with LF line endings, sorted by address ascending (lowercase hex compare), no trailing whitespace, and decimal integers in base units (wei-like, 18 decimals).
| File | Contents |
|---|---|
snapshot_manifest.json |
Metadata, parameters, file hashes (below) |
holders_all.csv |
address,balance_raw,is_contract,label: every address with balance > 0 at B |
excluded_addresses.csv |
address,balance_raw,reason: burn, self, pool, policy |
allocations.csv |
address,balance_raw,eligible_balance_raw,allocation_raw,formula_version: output of §3 |
labels.csv |
Reviewed address labels used for classification |
raw_logs.jsonl.zst |
Every Transfer log DEPLOY_BLOCK..B (optional to publish, required to keep) |
reconciliation_report.json |
Results of each §2.5 check |
SHA256SUMS |
sha256sum output for all of the above |
SHA256SUMS.sig |
Detached signature by the project's publishing key (GPG/minisign or an Ethereum EIP-191 signature by a published project address) |
snapshot_manifest.json (example structure; values are placeholders):
{
"manifest_version": 1,
"project": "XCORE claim from XEN snapshot",
"source_chain_id": 1,
"token_address": "0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8",
"token_decimals": 18,
"deploy_block": "<<VERIFY_DEPLOY_BLOCK>>",
"snapshot_time_utc": "2026-10-01T00:00:00Z",
"snapshot_time_unix": 1790812800,
"block_rule": "first_block_gte_timestamp",
"block_number": "<<FROZEN_BLOCK_NUMBER>>",
"block_hash": "<<FROZEN_BLOCK_HASH>>",
"block_timestamp": "<<TS_OF_BLOCK>>",
"total_supply_raw_at_block": "<<FROM_totalSupply()>>",
"holder_count_nonzero": "<<N>>",
"excluded_count": "<<N>>",
"eligible_count": "<<N>>",
"allocation": {
"formula_id": "<<e.g. pro_rata_capped>>",
"formula_version": "<<semver>>",
"params": { "pool_raw": "<<POOL>>", "min_eligible_raw": "<<DUST>>", "cap_raw": "<<CAP>>", "xcore_decimals": "<<9_OR_6_VERIFY>>" },
"sum_allocation_raw": "<<SUM>>",
"remainder_raw": "<<REMAINDER>>"
},
"providers": ["<<provider A name>>", "<<provider B name>>"],
"tooling": { "repo_commit": "<<git sha>>", "runtime": "<<python 3.x / node 2x>>" },
"files": {
"holders_all.csv": "<<sha256>>",
"excluded_addresses.csv": "<<sha256>>",
"allocations.csv": "<<sha256>>",
"labels.csv": "<<sha256>>",
"raw_logs.jsonl.zst": "<<sha256>>",
"reconciliation_report.json": "<<sha256>>"
},
"manifest_sha256": "computed over canonical JSON with this field omitted",
"frozen_at_utc": "<<ISO8601>>",
"signers": ["<<publishing key fingerprint / address>>"]
}
Canonicalization: compute manifest_sha256 over RFC 8785 JCS (JSON Canonicalization Scheme) serialization with manifest_sha256 removed. The DB stores this hash (§4), and every API allocation response returns it so clients can check which snapshot they're looking at.
3. Eligibility & Allocation Rules
3.1 Principles
- Deterministic. Same inputs and formula version give byte-identical
allocations.csv. - Integer-only. All math in base units with explicit rounding (floor), plus a defined policy for the remainder.
- Versioned and pluggable.
formula_id+formula_version+paramslive in the manifest. Changing any of them means a new manifest, never a change in place. - Published before claims open. Users can recompute their own allocation.
3.2 Pluggable formula interface
interface AllocationFormula {
id: string; // e.g. "pro_rata_capped"
version: string; // semver
// Input: eligible holders (after exclusions + dust filter), params.
// Output: allocation per address in XCORE base units; MUST satisfy invariants in §3.5.
allocate(holders: Array<{ address: string; eligibleBalanceRaw: bigint }>,
params: Record<string, string>): Map<string, bigint>;
}
3.3 Example formulas (EXAMPLE ONLY, not decisions)
⚠️ EXAMPLE ONLY. All numbers below are made up to show the arithmetic. Pool size, caps, thresholds and the formula itself are open owner decisions (§12.3).
(A) Pure pro-rata.
alloc_i = floor(POOL × b_i / Σb)
(B) Pro-rata with per-wallet cap (iterative water-filling).
Allocate pro-rata. Any wallet over CAP is set to CAP. Redistribute the excess pro-rata among the uncapped wallets and repeat until nothing is over the cap.
(C) Flat + pro-rata hybrid.
alloc_i = FLAT + floor((POOL − N×FLAT) × b_i / Σb)
(D) Tiered / sqrt-weighted (reduces whale concentration):
w_i = isqrt(b_i), alloc_i = floor(POOL × w_i / Σw)
Worked example (EXAMPLE ONLY), formula (B):
Settings: POOL = 1,000,000 XCORE, CAP = 400,000 XCORE, dust threshold 1,000 XEN. Four holders (balances in whole XEN for readability; the real computation uses raw units):
| Holder | XEN balance | Dust? | Pass 1 (pool 1,000,000 over 10M XEN) | Pass 2 (remaining 600,000 over 4M XEN) | Pass 3 (remaining 200,000 over 1M XEN) | Final XCORE |
|---|---|---|---|---|---|---|
| 0xAAA… | 6,000,000 | no | 600,000 → capped 400,000 | fixed | fixed | 400,000 |
| 0xBBB… | 3,000,000 | no | 300,000 | 450,000 → capped 400,000 | fixed | 400,000 |
| 0xCCC… | 1,000,000 | no | 100,000 | 150,000 | 200,000 (≤ CAP) | 200,000 |
| 0xDDD… | 500 | yes, excluded | — | — | — | 0 |
| Sum | 1,000,000 ✓ (remainder 0) |
This example shows why caps need an iterative algorithm and why you have to define what happens if
N_eligible × CAP < POOL: the pool can't be fully distributed, and the excess goes toremainder_rawand is handled by the unclaimed/remainder policy.
Worked example (EXAMPLE ONLY), formula (A) with rounding:
POOL = 100 base units, balances [1, 1, 1] → each gets floor(100/3) = 33. Sum = 99, remainder_raw = 1.
3.4 Thresholds, caps, rounding
| Parameter | Meaning | Default proposal |
|---|---|---|
min_eligible_raw (dust) |
Holders below this XEN balance are ineligible | <<OWNER>>. Pick it so payout tx cost < value. Also cuts DB and spam. |
min_allocation_raw |
Allocations that compute below this round to 0 | <<OWNER>>, e.g. one XCORE minimum unit × N |
cap_raw |
Max XCORE per Ethereum address | <<OWNER>>, optional |
| Rounding | Always floor per wallet |
Fixed |
| Remainder | POOL − Σ alloc |
Choose one: (i) stays in treasury, (ii) goes to largest-remainder holders by deterministic tie-break (address ascending), (iii) burned. Record in manifest. |
| Decimal conversion | XEN has 18 decimals. XCORE has <<VERIFY: SPL mint decimals>> |
All math in XCORE base units. Never round-trip through floats. |
3.5 Pool accounting invariant
These must always hold, and are checked in the ETL, in a DB constraint/trigger, and in reconciliation:
(I1) Σ allocation_raw (all eligible rows) + remainder_raw == POOL_raw
(I2) Σ paid_raw + Σ in_flight_raw + Σ unclaimed_raw == Σ allocation_raw
(I3) for every claim c: c.amount_raw == allocations[c.eth_address].allocation_raw (no partial/custom amounts)
(I4) count(claims per eth_address) <= 1
(I5) on-chain XCORE sent by payout wallet(s) tagged with claim refs == Σ paid_raw (daily recon)
(I6) payout wallet balance + Σ paid_raw (+ fees paid in XCORE, if any) == initial funding (+ top-ups logged in admin_actions)
3.6 Sybil / multi-wallet note
A balance snapshot gives no sybil resistance:
- One person with 50 wallets gets 50 allocations. Under pure pro-rata that's neutral, since splitting doesn't change their total share. Splitting does game per-wallet caps, flat components (formula C), dust thresholds (more wallets just over the line) and sqrt weighting (formula D).
- Anyone who expected an airdrop could have split balances before Oct 1. After the snapshot, splitting does nothing.
- Mitigations, all optional, each with a cost: favor pro-rata-style formulas that are split-neutral; avoid flat components; cluster analysis (funding-source heuristics) applied before freezing, which is subjective and needs a published rule; KYC (out of scope, conflicts with "no PII").
- Document clearly on the claim page: "Allocation is per Ethereum address at the snapshot block."
4. Off-Chain Database Design
4.1 Platform and roles
- Postgres 15+ (Neon fits the prior Vercel stack; any managed Postgres works).
- Separate DB roles, each with least privilege:
| Role | Used by | Privileges |
|---|---|---|
xc_migrator |
Migrations + ETL (admin, manual) | DDL; INSERT into snapshot_manifest, allocations |
xc_api |
Claim API | SELECT allocations/manifest; INSERT/UPDATE auth_nonces, sessions, claims (limited columns), INSERT payout_jobs, INSERT audit_events |
xc_worker |
Fulfillment worker | SELECT/UPDATE payout_jobs, UPDATE claims.status via function only, INSERT audit_events, payout_attempts |
xc_readonly |
Reconciliation, dashboards, read replica | SELECT only |
xc_admin |
Admin console (MFA-gated) | Calls SECURITY DEFINER functions for pause/retry/void. No direct UPDATE on allocations. |
No role has UPDATE/DELETE on allocations, snapshot_manifest or audit_events after load (§4.6).
4.2 Full SQL schema
-- =========================================================
-- Extensions & types
-- =========================================================
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid(), digest()
CREATE TYPE claim_status AS ENUM (
'reserved', -- claim row created, destination bound, awaiting queueing
'queued', -- payout job enqueued
'signed', -- payout tx signed/submitted (signature recorded), awaiting confirmation
'paid', -- tx landed (processed/confirmed)
'confirmed', -- tx finalized + reconciled
'failed', -- terminal failure requiring admin action (no funds moved, verified)
'voided' -- admin-voided (e.g. policy exclusion, fraud); allocation not payable
);
-- Note: "unclaimed" is not a claim status; it is the absence of a claims row for an allocation.
CREATE TYPE job_status AS ENUM ('pending','leased','submitted','succeeded','retry_wait','dead');
CREATE TYPE dest_chain AS ENUM ('solana','x1','evm'); -- extend as needed; chain-agnostic
-- =========================================================
-- Snapshot manifest (immutable after insert)
-- =========================================================
CREATE TABLE snapshot_manifest (
snapshot_id SMALLINT PRIMARY KEY, -- 1 for launch; new ids for any re-issue
manifest_sha256 CHAR(64) NOT NULL UNIQUE,
manifest_json JSONB NOT NULL, -- full canonical manifest
source_chain_id INTEGER NOT NULL CHECK (source_chain_id = 1),
token_address CHAR(42) NOT NULL,
block_number BIGINT NOT NULL,
block_hash CHAR(66) NOT NULL,
snapshot_time_utc TIMESTAMPTZ NOT NULL,
formula_id TEXT NOT NULL,
formula_version TEXT NOT NULL,
pool_raw NUMERIC(78,0) NOT NULL CHECK (pool_raw > 0),
sum_allocation_raw NUMERIC(78,0) NOT NULL,
remainder_raw NUMERIC(78,0) NOT NULL,
eligible_count INTEGER NOT NULL,
signature TEXT NOT NULL, -- detached sig over manifest_sha256
is_active BOOLEAN NOT NULL DEFAULT FALSE, -- exactly one active
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (sum_allocation_raw + remainder_raw = pool_raw) -- invariant I1
);
CREATE UNIQUE INDEX one_active_snapshot ON snapshot_manifest ((is_active)) WHERE is_active;
-- =========================================================
-- Allocations (one row per eligible Ethereum address; immutable)
-- =========================================================
CREATE TABLE allocations (
snapshot_id SMALLINT NOT NULL REFERENCES snapshot_manifest(snapshot_id),
eth_address CHAR(42) NOT NULL, -- stored LOWERCASE 0x-hex; checksum applied at display
balance_raw NUMERIC(78,0) NOT NULL CHECK (balance_raw > 0), -- XEN at block B
eligible_balance_raw NUMERIC(78,0) NOT NULL,
allocation_raw NUMERIC(78,0) NOT NULL CHECK (allocation_raw > 0), -- XCORE base units
is_contract BOOLEAN NOT NULL,
label TEXT, -- e.g. 'cex_suspect', null for normal
flags TEXT[] NOT NULL DEFAULT '{}', -- e.g. {'requires_manual','eip1271'}
row_sha256 CHAR(64) NOT NULL, -- sha256 of canonical CSV line, for tamper checks
PRIMARY KEY (snapshot_id, eth_address),
CHECK (eth_address ~ '^0x[0-9a-f]{40}$')
);
-- Excluded addresses kept for transparency/support answers
CREATE TABLE excluded_addresses (
snapshot_id SMALLINT NOT NULL REFERENCES snapshot_manifest(snapshot_id),
eth_address CHAR(42) NOT NULL CHECK (eth_address ~ '^0x[0-9a-f]{40}$'),
balance_raw NUMERIC(78,0) NOT NULL,
reason TEXT NOT NULL, -- 'burn','self','lp_pool','dust','policy_cex','contract_policy'
PRIMARY KEY (snapshot_id, eth_address)
);
-- =========================================================
-- Auth nonces (SIWE challenges)
-- =========================================================
CREATE TABLE auth_nonces (
nonce TEXT PRIMARY KEY, -- >= 128-bit random, base58/alnum, >= 16 chars
eth_address CHAR(42), -- optional pre-binding (lowercase)
purpose TEXT NOT NULL CHECK (purpose IN ('siwe_eth','bind_dest')),
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ, -- set exactly once; replay => reject
ip_hash CHAR(64), -- salted hash, for rate limiting only
CHECK (expires_at > issued_at)
);
CREATE INDEX auth_nonces_expiry ON auth_nonces (expires_at) WHERE consumed_at IS NULL;
-- =========================================================
-- Sessions (server-side record; cookie carries opaque id or JWT with jti)
-- =========================================================
CREATE TABLE sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- = JWT jti
eth_address CHAR(42) NOT NULL,
siwe_message_sha256 CHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE INDEX sessions_addr ON sessions (eth_address);
-- =========================================================
-- Destination bindings (Ethereum address -> payout address), proof stored
-- =========================================================
CREATE TABLE destination_bindings (
binding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_id SMALLINT NOT NULL,
eth_address CHAR(42) NOT NULL,
dest_chain dest_chain NOT NULL,
dest_address TEXT NOT NULL, -- base58 for Solana/X1; validated
eth_sig TEXT NOT NULL, -- EIP-191 sig over binding message
dest_sig TEXT, -- ed25519 sig from Phantom/X1 (nullable if not provided)
binding_message TEXT NOT NULL, -- exact signed text
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
superseded_at TIMESTAMPTZ, -- only allowed before claim is queued
FOREIGN KEY (snapshot_id, eth_address) REFERENCES allocations(snapshot_id, eth_address)
);
CREATE UNIQUE INDEX one_live_binding ON destination_bindings (snapshot_id, eth_address)
WHERE superseded_at IS NULL;
-- =========================================================
-- Claims (at most ONE per Ethereum address per snapshot)
-- =========================================================
CREATE TABLE claims (
claim_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
snapshot_id SMALLINT NOT NULL,
eth_address CHAR(42) NOT NULL,
amount_raw NUMERIC(78,0) NOT NULL, -- copied from allocations; enforced by trigger (I3)
binding_id UUID NOT NULL REFERENCES destination_bindings(binding_id),
dest_chain dest_chain NOT NULL,
dest_address TEXT NOT NULL, -- denormalized snapshot of binding at claim time
status claim_status NOT NULL DEFAULT 'reserved',
idempotency_key TEXT NOT NULL, -- client-supplied (UUIDv4) for POST /claims
payout_ref CHAR(32) NOT NULL UNIQUE, -- deterministic ref embedded on-chain (memo)
payout_tx TEXT UNIQUE, -- chain tx signature/hash once submitted
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
version INTEGER NOT NULL DEFAULT 0, -- optimistic locking
CONSTRAINT one_claim_per_address UNIQUE (snapshot_id, eth_address), -- invariant I4
CONSTRAINT idem_unique UNIQUE (eth_address, idempotency_key),
FOREIGN KEY (snapshot_id, eth_address) REFERENCES allocations(snapshot_id, eth_address)
);
CREATE INDEX claims_status ON claims (status, updated_at);
CREATE INDEX claims_dest ON claims (dest_address);
-- payout_ref = first 32 hex chars of sha256(snapshot_id || ':' || eth_address || ':' || PAYOUT_REF_SALT_PUBLIC)
-- It is deterministic per (snapshot, address), so the same claim can never get two different refs.
-- =========================================================
-- Payout jobs (queue) and attempts
-- =========================================================
CREATE TABLE payout_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
claim_id UUID NOT NULL UNIQUE REFERENCES claims(claim_id), -- one job per claim, ever
status job_status NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 8,
next_run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
lease_owner TEXT,
lease_expires_at TIMESTAMPTZ,
batch_id UUID, -- for option (c) batch exports
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payout_jobs_ready ON payout_jobs (next_run_at) WHERE status IN ('pending','retry_wait');
CREATE TABLE payout_attempts (
attempt_id BIGSERIAL PRIMARY KEY,
job_id UUID NOT NULL REFERENCES payout_jobs(job_id),
attempt_no INTEGER NOT NULL,
tx_signature TEXT, -- may be set even if later dropped
recent_blockhash TEXT, -- Solana: lets you tell "expired" from "unknown"
last_valid_block_height BIGINT,
outcome TEXT NOT NULL, -- 'submitted','confirmed','expired','error'
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (job_id, attempt_no)
);
-- =========================================================
-- Append-only audit log (hash-chained)
-- =========================================================
CREATE TABLE audit_events (
seq BIGSERIAL PRIMARY KEY,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
actor_type TEXT NOT NULL CHECK (actor_type IN ('user','api','worker','admin','system')),
actor_id TEXT NOT NULL, -- eth address, worker id, admin id
event_type TEXT NOT NULL, -- 'auth.verified','claim.reserved','payout.submitted',...
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload JSONB NOT NULL,
prev_hash CHAR(64) NOT NULL,
row_hash CHAR(64) NOT NULL -- sha256(prev_hash || canonical(payload,…))
);
-- =========================================================
-- Admin actions (also mirrored into audit_events)
-- =========================================================
CREATE TABLE admin_actions (
action_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id TEXT NOT NULL, -- IdP subject; MFA asserted
action TEXT NOT NULL, -- 'pause','unpause','retry_job','void_claim','fund_topup','rebind_dest'
target_id TEXT,
reason TEXT NOT NULL,
approved_by TEXT, -- second admin for 2-person actions
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (action NOT IN ('void_claim','rebind_dest','unpause') OR approved_by IS NOT NULL),
CHECK (approved_by IS NULL OR approved_by <> admin_id)
);
-- =========================================================
-- System flags (pause switch etc.)
-- =========================================================
CREATE TABLE system_flags (
key TEXT PRIMARY KEY, -- 'claims_paused','payouts_paused','claim_window_end'
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL
);
-- =========================================================
-- Reconciliation results
-- =========================================================
CREATE TABLE recon_runs (
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
db_paid_raw NUMERIC(78,0),
chain_paid_raw NUMERIC(78,0),
mismatches JSONB NOT NULL DEFAULT '[]',
ok BOOLEAN
);
4.3 Integrity triggers (sketch)
-- I3: claim amount must equal allocation; claims only for the active snapshot
CREATE FUNCTION trg_claim_amount() RETURNS trigger AS $$
DECLARE a NUMERIC(78,0);
BEGIN
SELECT allocation_raw INTO a FROM allocations
WHERE snapshot_id = NEW.snapshot_id AND eth_address = NEW.eth_address;
IF a IS NULL OR a <> NEW.amount_raw THEN
RAISE EXCEPTION 'amount mismatch for %', NEW.eth_address;
END IF;
IF NOT EXISTS (SELECT 1 FROM snapshot_manifest WHERE snapshot_id = NEW.snapshot_id AND is_active) THEN
RAISE EXCEPTION 'inactive snapshot';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER claim_amount BEFORE INSERT OR UPDATE OF amount_raw ON claims
FOR EACH ROW EXECUTE FUNCTION trg_claim_amount();
-- Immutability of allocations / manifest / audit
CREATE FUNCTION trg_immutable() RETURNS trigger AS $$
BEGIN RAISE EXCEPTION '% is append-only/immutable', TG_TABLE_NAME; END $$ LANGUAGE plpgsql;
CREATE TRIGGER alloc_immutable BEFORE UPDATE OR DELETE ON allocations FOR EACH ROW EXECUTE FUNCTION trg_immutable();
CREATE TRIGGER audit_immutable BEFORE UPDATE OR DELETE ON audit_events FOR EACH ROW EXECUTE FUNCTION trg_immutable();
CREATE TRIGGER manifest_immut BEFORE DELETE ON snapshot_manifest FOR EACH ROW EXECUTE FUNCTION trg_immutable();
-- (manifest UPDATE allowed only for is_active toggle via SECURITY DEFINER admin function)
-- Status transitions enforced (see 4.4)
CREATE FUNCTION trg_claim_transition() RETURNS trigger AS $$
BEGIN
IF NOT (
(OLD.status='reserved' AND NEW.status IN ('queued','voided')) OR
(OLD.status='queued' AND NEW.status IN ('signed','failed','voided')) OR
(OLD.status='signed' AND NEW.status IN ('paid','queued','failed')) OR -- 'queued' only after proven expiry
(OLD.status='paid' AND NEW.status IN ('confirmed')) OR
(OLD.status='failed' AND NEW.status IN ('queued','voided')) OR -- admin retry
(OLD.status = NEW.status)
) THEN
RAISE EXCEPTION 'illegal transition % -> %', OLD.status, NEW.status;
END IF;
NEW.updated_at := now(); NEW.version := OLD.version + 1;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER claim_transition BEFORE UPDATE OF status ON claims
FOR EACH ROW EXECUTE FUNCTION trg_claim_transition();
Also: the xc_api role cannot set any status beyond queued. Only xc_worker (through a SECURITY DEFINER function) can move a claim to signed/paid/confirmed.
4.4 Claim state machine
Diagram source (Mermaid)
stateDiagram-v2
[*] --> unclaimed : allocation loaded
unclaimed --> reserved : POST /claims (auth + binding)
reserved --> queued : payout_job created (same tx)
queued --> signed : worker signs + submits tx (signature stored BEFORE broadcast)
signed --> paid : tx observed (confirmed commitment)
paid --> confirmed : finalized + recon matched
signed --> queued : tx PROVEN expired/dropped (blockhash expired AND sig not found)
queued --> failed : max attempts / permanent error
signed --> failed : permanent error, proven not landed
failed --> queued : admin retry (2-person, audited)
reserved --> voided : admin (policy/fraud)
failed --> voided : admin
confirmed --> [*]
voided --> [*]| State | Meaning | Who sets it | User-facing label |
|---|---|---|---|
| (unclaimed) | Allocation exists, no claim row | — | "Eligible – ready to claim" |
reserved |
Claim row created, destination bound | API | "Claim received" |
queued |
Payout job exists | API (same DB tx as reserved, in practice) | "Queued for payout" |
signed |
Tx signed and broadcast. The signature is persisted before broadcast. | Worker | "Sending…" + tx link |
paid |
Tx seen on-chain (confirmed) | Worker | "Paid" + tx link |
confirmed |
Finalized and matched by reconciliation | Worker / recon | "Paid ✓ (finalized)" |
failed |
Needs operator action. No funds moved (verified). | Worker | "Delayed – team notified" |
voided |
Not payable by policy | Admin (2-person) | "Not payable – contact support" |
Critical rule: signed → queued (re-send) is allowed only after the worker proves the earlier tx can no longer land. On Solana, that means the tx's lastValidBlockHeight has passed and getSignatureStatuses (with searchTransactionHistory: true) returns null. Without that proof, you risk paying twice.
4.5 Idempotency keys
| Layer | Key | Effect |
|---|---|---|
HTTP POST /claims |
Idempotency-Key header (client UUIDv4), stored in claims.idempotency_key |
Same key + same address returns the original response. A different key for an address that already has a claim returns 409 ALREADY_CLAIMED with the existing claim. |
| DB | UNIQUE (snapshot_id, eth_address) |
Hard guarantee of one claim per address |
| Queue | payout_jobs.claim_id UNIQUE |
One job per claim |
| On-chain | payout_ref in the Solana Memo instruction (xcore:<snapshot_id>:<payout_ref>) |
Reconciliation and the worker can find existing payouts for a claim on-chain |
| Tx | payout_tx UNIQUE |
Can't attribute one tx to two claims |
4.6 Append-only audit and hash chain
- Every API, worker and admin mutation inserts an
audit_eventsrow in the same DB transaction as the change. row_hash = sha256(prev_hash || seq || occurred_at || actor_type || actor_id || event_type || entity_type || entity_id || jcs(payload)). The genesisprev_hashis themanifest_sha256.- Concurrency: compute it in a
BEFORE INSERTtrigger that takespg_advisory_xact_lock(<const>), so the chain is serial. The volume is low, so that's fine. - Anchoring: every hour, publish the latest
(seq, row_hash)to an external append-only sink: an object store with object lock/WORM, plus optionally a public post or an on-chain memo. That makes silent rewrites detectable. - Verification job: recompute the chain nightly and alert on any break.
4.7 Core transactional flow for POST /claims (pseudocode)
BEGIN ISOLATION LEVEL READ COMMITTED;
-- 0) global flags
SELECT value FROM system_flags WHERE key='claims_paused'; -- abort if 'true'
-- 1) allocation must exist for active snapshot
SELECT allocation_raw, flags FROM allocations a JOIN snapshot_manifest m USING (snapshot_id)
WHERE m.is_active AND a.eth_address = $addr;
-- abort if none (NOT_ELIGIBLE) or 'requires_manual' flag (MANUAL_REVIEW)
-- 2) live binding must exist
SELECT * FROM destination_bindings WHERE eth_address=$addr AND superseded_at IS NULL;
-- 3) insert claim; unique constraint does the race-safety
INSERT INTO claims (snapshot_id, eth_address, amount_raw, binding_id, dest_chain, dest_address,
idempotency_key, payout_ref, status)
VALUES (...,'queued')
ON CONFLICT (snapshot_id, eth_address) DO NOTHING
RETURNING claim_id;
-- if no row returned: SELECT existing claim; if same idempotency_key -> 200 replay; else 409
-- 4) job + audit in same tx
INSERT INTO payout_jobs (claim_id) VALUES ($claim_id);
INSERT INTO audit_events (...) VALUES (... 'claim.queued' ...);
COMMIT;
5. Wallet Authentication
5.1 Goals
- Prove that the requester controls the private key of the eligible Ethereum address (
personal_sign, EIP-191, carrying an EIP-4361 SIWE message). - Prove the user intends to send their payout to a specific destination address (Solana/X1). Ideally the user also proves control of that address, so they can't fat-finger or paste a hijacked address.
- Make every signature single-use, time-boxed and bound to this domain.
5.2 SIWE challenge flow
Diagram source (Mermaid)
sequenceDiagram
participant B as Browser (/claim)
participant EW as Ethereum wallet
participant API as Claim API
participant DB as Postgres
B->>API: POST /auth/nonce {address}
API->>DB: INSERT auth_nonces (nonce, expires_at=now()+5m)
API-->>B: {nonce, issuedAt, expirationTime, domain, uri, chainId:1, statement}
B->>B: build EIP-4361 message
B->>EW: personal_sign(message)
EW-->>B: signature (65 bytes)
B->>API: POST /auth/verify {message, signature}
API->>API: parse; check domain, uri, chainId, version, times; recover signer
API->>DB: UPDATE auth_nonces SET consumed_at=now() WHERE nonce=$n AND consumed_at IS NULL AND expires_at>now()
alt 1 row updated
API->>DB: INSERT sessions; INSERT audit_events('auth.verified')
API-->>B: 200 Set-Cookie: xc_session=...; HttpOnly; Secure; SameSite=Strict
else 0 rows
API-->>B: 401 NONCE_INVALID_OR_REPLAYED
end5.3 Exact message format (EIP-4361)
<<CLAIM_DOMAIN>> wants you to sign in with your Ethereum account:
0xAbC0000000000000000000000000000000000001
Sign in to check and claim your XCORE allocation from the XEN snapshot (block <<FROZEN_BLOCK_NUMBER>>). This does not authorize any transaction or token approval.
URI: https://<<CLAIM_DOMAIN>>/claim
Version: 1
Chain ID: 1
Nonce: 8f3kQz1LmN0pRt6V
Issued At: 2026-10-20T18:00:00.000Z
Expiration Time: 2026-10-20T18:05:00.000Z
Resources:
- urn:xcore-claim:snapshot:<<MANIFEST_SHA256>>
5.4 Server-side verification checklist
| # | Check | Failure code |
|---|---|---|
| 1 | Parse strictly with a maintained SIWE library (e.g. siwe / viem/siwe). Reject anything unparseable. |
SIWE_MALFORMED |
| 2 | domain == CLAIM_DOMAIN exactly, and the request Origin header matches |
SIWE_DOMAIN_MISMATCH |
| 3 | uri starts with https://CLAIM_DOMAIN/ |
SIWE_URI_MISMATCH |
| 4 | version == 1; chainId == 1. Eligibility comes from mainnet, so the session binds to chainId 1 whatever network the wallet is on. Signing is chain-independent, so a wallet on another network can still sign a chainId-1 message. |
SIWE_CHAIN_MISMATCH |
| 5 | issuedAt ≤ now + 60s skew; expirationTime > now; expirationTime − issuedAt ≤ 10 min |
SIWE_EXPIRED |
| 6 | Resource urn:xcore-claim:snapshot:<hash> == the active manifest hash |
SIWE_SNAPSHOT_MISMATCH |
| 7 | Recover the signer (ecrecover on the EIP-191 hash "\x19Ethereum Signed Message:\n" + len + msg). Reject high-s / malleable signatures. Signer == message address (case-insensitive). |
SIG_INVALID |
| 8 | If the address has code (eth_getCode at latest ≠ 0x), try EIP-1271 isValidSignature (§5.6) |
SIG_INVALID |
| 9 | Atomically consume the nonce (single conditional UPDATE). 0 rows means replayed, expired or unknown. | NONCE_INVALID_OR_REPLAYED |
| 10 | Nonce was issued for the same address (if pre-bound) | NONCE_ADDRESS_MISMATCH |
5.5 Address normalization
- Storage: lowercase
0x+ 40 hex (CHECKconstraint in the schema). - Input: accept any case. If the input is mixed case, validate the EIP-55 checksum and reject a bad one (
ADDRESS_BAD_CHECKSUM). That catches typos. All-lowercase and all-uppercase inputs skip the checksum check. - Display: always EIP-55 checksummed.
- Solana/X1 addresses: base58 decode must give exactly 32 bytes. Optionally require the address to be on the ed25519 curve (
PublicKey.isOnCurve), which rejects PDAs that no wallet controls. Store the canonical base58 string.
5.6 Smart-contract wallets (Safe, etc.)
- EIP-191
ecrecoverdoesn't work for contract wallets. Support EIP-1271: callisValidSignature(bytes32 hash, bytes sig)at the latest block and require magic value0x1626ba7e. - Caveats: the contract's signer set may have changed since the snapshot (that's expected, since the current owners control the funds). Counterfactual (ERC-6492) wallets weren't holders at the snapshot unless deployed. Some Safes need an on-chain signing transaction.
- MVP recommendation: flag
is_contract=trueallocations asrequires_manual. Support EIP-1271 in the hardening phase.
5.7 Sessions
| Property | Value |
|---|---|
| Transport | Cookie xc_session, HttpOnly; Secure; SameSite=Strict; Path=/api; Max-Age=1800 |
| Format | Either an opaque random ID looked up in sessions, or a JWT (EdDSA/HS256) with sub=eth_address, jti=session_id, exp, aud=CLAIM_DOMAIN, snap=<manifest hash>. The server checks jti against sessions so revocation works. |
| Lifetime | 30 min, no refresh. Re-sign afterwards. Claiming is a one-time action. |
| CSRF | SameSite=Strict, plus require header X-Requested-With: xcore-claim (or a double-submit token), plus Origin check on all state-changing calls |
| Logout | POST /auth/logout revokes the row and clears the cookie |
5.8 Destination binding (Solana: Phantom / X1 wallet)
The user binds a payout address with two signatures over the same binding message:
- Ethereum signature (EIP-191
personal_sign) by the eligible address. It proves the eligible holder chose this destination, and is required. - Destination-chain signature by the Solana/X1 key (
signMessagein Phantom or the X1 wallet, ed25519). It proves the user controls the destination, which prevents typos or paying an address nobody controls. Strongly recommended; require it unless the wallet can't sign messages.
Recommended wallets: Phantom or the X1 wallet for the Solana/X1 side. Any wallet-standard wallet that supports signMessage works technically, but the UI should feature Phantom and X1.
Binding message (both keys sign the exact same bytes, UTF-8):
XCORE claim destination binding
Domain: <<CLAIM_DOMAIN>>
Snapshot: <<MANIFEST_SHA256>>
Ethereum address: 0xAbC0000000000000000000000000000000000001
Destination chain: solana
Destination address: <<BASE58_PUBKEY>>
Nonce: Zr8pQ2wX4yT7uV1a
Issued At: 2026-10-20T18:01:00.000Z
Expiration Time: 2026-10-20T18:06:00.000Z
I authorize sending my XCORE allocation to the destination address above. This replaces any earlier destination until my claim is queued.
Verification:
noncefromauth_nonceswithpurpose='bind_dest', consumed atomically.- Ethereum signature recovers to the session's address.
nacl.sign.detached.verify(utf8(message), base58decode(dest_sig), base58decode(dest_address))is true.- Store both signatures and the exact message in
destination_bindings.
Fallback when the wallet can't sign a message (some hardware wallets/Ledger on Solana): the user sends a tiny self-transfer or a Memo-only transaction from the destination address with memo xcore-bind:<nonce>, and the backend verifies the signer and memo on-chain. It costs a small fee and takes longer. Document it as advanced.
Rebinding: allowed while the claim is reserved or before any claim exists. Once a claim is queued, the destination is locked. Changing it then needs a 2-person admin action, and only for a claim that is failed with funds proven unsent.
5.9 What authentication does NOT prove
| Not proven | Implication | Mitigation |
|---|---|---|
| That the signer is the "real" owner | A compromised Ethereum key (drained wallet, phished seed) lets the attacker claim | None at protocol level. Publish this risk. Optionally hold claims from addresses flagged by public drainer lists for manual review. |
| Unique human identity | Sybil (§3.6) | Policy only |
| That the signer still holds XEN | Eligibility is snapshot-based by design | Not needed |
| That the user understands the destination chain | Wrong-network address | Validation + destination signature + clear UI |
| Origin of the request in a phishing context | A clone site could ask for SIWE signatures for its own domain. Our server rejects them (domain check), but a clone could still trick users into signing other things. | Domain check, user education, publish the official URL from verified channels |
6. Claim API
Base path: https://<<CLAIM_DOMAIN>>/api/v1. JSON only (Content-Type: application/json). All amounts are strings in base units (*_raw), with a display string alongside. Times are ISO 8601 UTC.
6.1 Endpoint summary
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /snapshot |
none | Active manifest summary + hash |
| GET | /allocations/{ethAddress} |
none (rate-limited) or session (see privacy) | Eligibility lookup |
| POST | /auth/nonce |
none | Issue a SIWE nonce |
| POST | /auth/verify |
none | Verify SIWE, set session cookie |
| POST | /auth/logout |
session | Revoke session |
| GET | /me |
session | Session info + allocation + claim |
| POST | /destination/nonce |
session | Nonce for the binding message |
| POST | /destination |
session | Bind/replace the payout destination |
| POST | /claims |
session + Idempotency-Key |
Create the claim (reserve + queue) |
| GET | /claims/me |
session | Claim status + tx link |
| POST | /claims/me/retry |
session | Ask to re-check a stalled payout (rules in §6.6) |
| GET | /health |
none | Liveness, paused flags (no secrets) |
| Admin | /admin/* |
separate IdP + MFA, IP allowlist | Pause, retry, void, export batch (not public) |
6.2 Allocation lookup: privacy trade-off
| Mode | Behavior | Pros | Cons |
|---|---|---|---|
Public lookup (GET /allocations/{addr} without auth) |
Returns eligible flag + amount for any address | Users can check before connecting. Same data as the published allocations.csv. |
Anyone can query anyone. That's already public if you publish the CSV, but you're also leaking claim status if that's included. |
Authed lookup (GET /me) |
Only for your own address after SIWE | Claim status and destination stay private | Extra step before the user knows they're eligible |
Recommendation: the public lookup returns eligibility and amount only (that data is public once the snapshot is published, which transparency requires). Claim status, destination address and tx come only from authed endpoints. Note that payout transactions are public on-chain anyway, so this is obfuscation, not secrecy.
6.3 Request/response examples
GET /snapshot
200 OK
{
"snapshotId": 1,
"manifestSha256": "<<MANIFEST_SHA256>>",
"sourceChainId": 1,
"token": "0x06450dEe7FD2Fb8E39061434BAbCFC05599a6Fb8",
"blockNumber": "<<FROZEN_BLOCK_NUMBER>>",
"blockHash": "<<FROZEN_BLOCK_HASH>>",
"snapshotTimeUtc": "2026-10-01T00:00:00Z",
"formula": { "id": "<<formula_id>>", "version": "<<semver>>" },
"eligibleCount": "<<N>>",
"claimWindow": { "opensAt": "<<ISO>>", "closesAt": "<<ISO>>" },
"artifactsUrl": "<<PUBLIC_ARTIFACTS_URL_PLACEHOLDER>>",
"claimsPaused": false
}
GET /allocations/0xAbC0000000000000000000000000000000000001
200 OK
{
"address": "0xAbC0000000000000000000000000000000000001",
"eligible": true,
"xenBalanceRaw": "12500000000000000000000000",
"xenBalanceDisplay": "12,500,000 XEN",
"allocationRaw": "4200000000000",
"allocationDisplay": "4,200 XCORE",
"requiresManualReview": false,
"manifestSha256": "<<MANIFEST_SHA256>>"
}
Not eligible (address unknown, zero balance, or below dust):
200 OK
{ "address": "0x…", "eligible": false, "reason": "NOT_IN_SNAPSHOT", "manifestSha256": "…" }
reason ∈ NOT_IN_SNAPSHOT | BELOW_DUST | EXCLUDED_POLICY. Excluded addresses return EXCLUDED_POLICY with a short public reason (lp_pool, burn, …).
POST /auth/nonce
// request
{ "address": "0xAbC0000000000000000000000000000000000001" }
// 200
{
"nonce": "8f3kQz1LmN0pRt6V",
"domain": "<<CLAIM_DOMAIN>>",
"uri": "https://<<CLAIM_DOMAIN>>/claim",
"chainId": 1,
"version": "1",
"issuedAt": "2026-10-20T18:00:00.000Z",
"expirationTime": "2026-10-20T18:05:00.000Z",
"statement": "Sign in to check and claim your XCORE allocation from the XEN snapshot (block <<FROZEN_BLOCK_NUMBER>>). This does not authorize any transaction or token approval.",
"resources": ["urn:xcore-claim:snapshot:<<MANIFEST_SHA256>>"]
}
POST /auth/verify
// request
{ "message": "<full EIP-4361 text>", "signature": "0x…130 hex…" }
// 200 (Set-Cookie: xc_session=…; HttpOnly; Secure; SameSite=Strict; Path=/api)
{ "address": "0xAbC0000000000000000000000000000000000001", "expiresAt": "2026-10-20T18:35:00.000Z" }
// 401
{ "error": { "code": "NONCE_INVALID_OR_REPLAYED", "message": "Nonce expired or already used. Request a new one." } }
POST /destination/nonce → { "nonce": "Zr8pQ2wX4yT7uV1a", "message": "<binding message text>", "expiresAt": "…" }
(The server builds the canonical message, so the client can't tamper with its format. The client passes destChain and destAddress in the request.)
POST /destination
// request
{
"destChain": "solana",
"destAddress": "<<BASE58_PUBKEY>>",
"message": "<exact binding message>",
"ethSignature": "0x…",
"destSignature": "<base58 ed25519 sig>"
}
// 200
{ "bindingId": "b1f…", "destChain": "solana", "destAddress": "<<BASE58_PUBKEY>>", "destVerified": true, "locked": false }
// 409
{ "error": { "code": "DESTINATION_LOCKED", "message": "Your claim is already queued; destination cannot be changed." } }
POST /claims
POST /api/v1/claims
Idempotency-Key: 6c0b8e7e-0f5e-4b8f-9a0a-2a0c7d1d3e11
X-Requested-With: xcore-claim
Cookie: xc_session=…
{ "acceptTermsVersion": "<<TERMS_VERSION>>" }
// 201 Created
{
"claimId": "c9a…",
"status": "queued",
"amountRaw": "4200000000000",
"amountDisplay": "4,200 XCORE",
"destChain": "solana",
"destAddress": "<<BASE58_PUBKEY>>",
"payoutRef": "3f9c0d…(32 hex)",
"createdAt": "2026-10-20T18:02:10.000Z"
}
// 200 (same Idempotency-Key replayed) -> identical body to the original 201
// 409
{ "error": { "code": "ALREADY_CLAIMED", "message": "This address has already claimed.", "claimId": "c9a…", "status": "paid" } }
GET /claims/me
200 OK
{
"claimId": "c9a…",
"status": "paid",
"amountRaw": "4200000000000",
"destChain": "solana",
"destAddress": "<<BASE58_PUBKEY>>",
"payoutTx": "<<SOLANA_TX_SIGNATURE>>",
"explorerUrl": "<<EXPLORER_BASE>>/tx/<<SOLANA_TX_SIGNATURE>>",
"history": [
{"status":"queued","at":"2026-10-20T18:02:10Z"},
{"status":"signed","at":"2026-10-20T18:02:41Z"},
{"status":"paid","at":"2026-10-20T18:02:44Z"}
]
}
404 NO_CLAIM if the user hasn't claimed yet.
6.4 Error codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST |
Schema validation failed |
| 400 | ADDRESS_INVALID / ADDRESS_BAD_CHECKSUM |
Malformed Ethereum address |
| 400 | DEST_ADDRESS_INVALID |
Not a valid 32-byte base58 key / off-curve |
| 400 | SIWE_MALFORMED / SIWE_DOMAIN_MISMATCH / SIWE_URI_MISMATCH / SIWE_CHAIN_MISMATCH / SIWE_SNAPSHOT_MISMATCH |
SIWE content invalid |
| 401 | SIG_INVALID |
Signature doesn't recover to the address |
| 401 | SIWE_EXPIRED / NONCE_INVALID_OR_REPLAYED / NONCE_ADDRESS_MISMATCH |
Freshness/replay failure |
| 401 | UNAUTHENTICATED |
No/expired session |
| 403 | NOT_ELIGIBLE |
No allocation for this address |
| 403 | MANUAL_REVIEW_REQUIRED |
Contract/CEX/flagged allocation |
| 403 | CLAIM_WINDOW_CLOSED / CLAIM_WINDOW_NOT_OPEN |
Outside the window |
| 403 | ORIGIN_FORBIDDEN |
Bad Origin / CSRF header |
| 409 | ALREADY_CLAIMED |
Claim exists. Body includes claimId and status. |
| 409 | DESTINATION_REQUIRED |
No live binding |
| 409 | DESTINATION_LOCKED |
Binding can't change |
| 409 | IDEMPOTENCY_CONFLICT |
Same key, different body |
| 422 | TERMS_NOT_ACCEPTED |
Terms version missing or old |
| 429 | RATE_LIMITED |
Includes a Retry-After header |
| 503 | CLAIMS_PAUSED / PAYOUTS_PAUSED |
Pause switch on |
| 500 | INTERNAL |
Generic. Correlation id in the X-Request-Id header. |
Error envelope: { "error": { "code": "…", "message": "…", "requestId": "…" } }. Never echo stack traces or SQL.
6.5 Rate limiting and CORS
| Scope | Limit (starting point) | Store |
|---|---|---|
GET /allocations/* per IP |
30/min, 500/day | Redis/Upstash or Postgres token bucket |
POST /auth/nonce per IP |
10/min; per address 5/min | same |
POST /auth/verify per IP |
10/min; 5 consecutive failures → 15 min cooldown | same |
POST /destination* per session |
10/hour | same |
POST /claims per session |
5/min (idempotent anyway) | same |
| Global | Circuit breaker if the error rate is > X% or the nonce issuance rate spikes | metrics |
- Hash IPs with a rotating salt (
IP_HASH_SALT). Don't store raw IPs (§9.8). - Add bot protection (e.g. Vercel firewall rules / a challenge such as Turnstile) on
/auth/nonceif abuse shows up. - CORS: API and page are same-origin, so send no
Access-Control-Allow-Origin(deny cross-origin). If they must be split, allowlist exactlyhttps://<<CLAIM_DOMAIN>>withcredentials: true, never*. - Security headers:
Content-Security-Policy(strict, nonce-based scripts;connect-srclimited to the API and the RPC endpoints the page uses),Strict-Transport-Security,X-Frame-Options: DENY/frame-ancestors 'none',Referrer-Policy: no-referrer.
6.6 Payout retry rules (POST /claims/me/retry)
The user can't force a re-send. This endpoint only asks the worker to re-check the claim:
| Current status | Effect |
|---|---|
queued older than 10 min |
Bumps job next_run_at = now() (no-op if leased) |
signed older than the chain expiry window |
Worker checks the signature status. If proven expired and not landed → queued with the same claim, ref and destination. Otherwise no-op. |
paid / confirmed |
409 ALREADY_PAID |
failed |
202 accepted, opens an admin review ticket. Only admins (2-person) can move failed → queued. |
reserved |
Enqueues the job if it's missing (repair path) |
Limit: 3 retry requests per claim per hour. Every request goes into the audit log.
7. Payout Fulfillment Options
7.1 Comparison
| Criterion | (a) Backend hot-wallet signer | (b) Admin-signed voucher redeemed on-chain | (c) Manual/batch multisig payout from DB export |
|---|---|---|---|
| How it works | Worker holds a funded payout key and sends XCORE per job | Backend signs (dest, amount, nonce) tickets. A claim program on-chain verifies the signature and pays from a vault. |
Admins export a batch CSV from the DB. A multisig (e.g. Squads on Solana) runs a batched transfer. The worker records the results. |
| On-chain code needed | None (SPL transfer + Memo) | Yes: a custom program/contract (audit needed) | None (multisig tooling) |
| UX latency | Seconds–minutes | User pays gas and redeems themselves | Hours–days (batch cadence) |
| Who pays fees | Project | User | Project |
| Key risk | Hot key compromise drains the hot balance | Signer key compromise lets anyone mint vouchers up to the vault balance. Program bugs. | Lowest: multisig threshold, human review |
| Double-pay protection | DB unique + memo ref + signature-before-broadcast | On-chain nonce bitmap (strongest) | DB unique + batch manifest + memo ref |
| Fits "off-chain DB" goal | ✅ yes | ⚠️ partly. It's half on-chain and needs a program. | ✅ yes |
| Team effort | Medium | High | Low–medium |
| Recommendation | MVP default for scale, with strict limits | Not recommended for this design | Recommended for launch week and high-value claims, and as fallback |
Recommended hybrid: run (a) for allocations ≤ HOT_WALLET_MAX_PER_CLAIM_RAW with a small, regularly topped-up hot balance. Route larger allocations, contract-wallet holders and failed claims to (c) batches. Fund the hot wallet from a cold multisig treasury, and log every top-up as an admin_actions fund_topup entry.
7.2 Option (a): worker design
def worker_loop(worker_id):
while True:
if flag('payouts_paused'): sleep(10); continue
job = lease_one_job(worker_id, lease_seconds=120) # SELECT … FOR UPDATE SKIP LOCKED
if not job: sleep(2); continue
claim = load_claim(job.claim_id) # status must be 'queued'
try:
# 1) Pre-flight: has this claim already been paid on-chain? (memo ref search / local index)
existing = find_onchain_payout(claim.payout_ref)
if existing: return mark_paid(claim, existing.signature, source='preflight')
# 2) Limits
enforce_limits(claim.amount_raw) # per-claim, hourly, daily caps
assert hot_wallet_balance() >= claim.amount_raw + FEE_BUFFER
# 3) Build tx: (idempotent ATA create) + transferChecked(mint, decimals) + Memo("xcore:1:<payout_ref>")
tx, blockhash, last_valid = build_transfer(claim.dest_address, claim.amount_raw, memo=ref(claim))
sig = sign(tx) # signature is deterministic for this tx
# 4) Persist BEFORE broadcast (crash-safe)
with db.tx():
insert_attempt(job, sig, blockhash, last_valid, outcome='submitted')
set_claim(claim, status='signed', payout_tx=sig)
send_raw(tx, skip_preflight=False, max_retries=0) # rebroadcast loop below
outcome = confirm_or_expire(sig, last_valid, commitment='confirmed')
if outcome == 'confirmed':
mark_paid(claim, sig)
elif outcome == 'expired_not_found': # PROVEN not landed
with db.tx():
update_attempt(sig, 'expired'); set_claim(claim, status='queued', payout_tx=None)
schedule_retry(job, backoff(job.attempts))
else: # unknown -> do NOT resend; recheck later
schedule_recheck(job, delay=60)
except PermanentError as e:
fail_job(job, claim, e) # -> 'failed', alert
except TransientError as e:
schedule_retry(job, backoff(job.attempts))
Key rules:
- One worker lease per job (
FOR UPDATE SKIP LOCKED+ lease expiry). Run a single worker process at first. - Persist the signature before broadcast. On restart, any
signedclaim is checked, never re-signed blindly. - Re-send only after proven expiry (§4.4).
- Use
transferCheckedwith the mint decimals. Create the recipient's associated token account idempotently (the project pays rent, so budget for it). If XCORE uses Token-2022 extensions (transfer fees/hooks), VERIFY amounts and hooks. - Memo:
xcore:<snapshot_id>:<payout_ref>. Index hot-wallet transactions so preflight can search by ref. - Backoff:
min(2^attempts × 15s, 30min)with jitter. Aftermax_attempts→failed+ alert.
7.3 Hot-wallet limits and monitoring
| Control | Setting (placeholder) |
|---|---|
HOT_WALLET_MAX_PER_CLAIM_RAW |
<<OWNER>>. Above this, route to batch (c). |
HOT_WALLET_MAX_PER_HOUR_RAW / ..._PER_DAY_RAW |
<<OWNER>>. The worker auto-pauses when hit. |
| Hot balance target | ≈ 1 day of expected outflow. Top up from the multisig. |
| Low-balance alert | < 25% of target (XCORE) or < N SOL for fees/rent |
| Unexpected outflow alert | Any hot-wallet tx without a known payout_ref memo → page on-call, auto-pause |
| Key storage | KMS/HSM-backed signer or a dedicated secrets manager. Never in the web/API runtime or the repo. |
7.4 Option (c): batch flow
- Admin (MFA) runs
export_batch(max_rows, max_total_raw). It selectsqueuedjobs, assigns abatch_id, and writesbatch_<id>.csv(claim_id,dest,amount_raw,payout_ref) plus its SHA-256 intoadmin_actions. - A second admin independently re-derives the batch from the DB read replica and compares hashes.
- Multisig signers execute transfers with memos (
xcore:1:<payout_ref>), in chunks that fit the tx size limit. - The worker ingests the results by scanning the multisig vault's transactions for memos, then moves the claims to
paid/confirmed. - Rows not found on-chain after the batch window → back to
queued(proven not paid) and into the next batch.
7.5 Pause switch
system_flags.claims_paused→ API returns503 CLAIMS_PAUSEDforPOST /claimsand/destination. Lookups keep working.system_flags.payouts_paused→ the worker stops leasing.- One admin can pause. Unpausing needs 2 people. Automatic pause on: unexpected outflow, recon mismatch, audit chain break, or error rate above threshold.
8. Webpage UX Flow
8.1 Flow
Diagram source (Mermaid)
flowchart TD
A[Landing /claim<br/>snapshot info + manifest hash] --> B{Connect Ethereum wallet}
B --> C[GET /allocations/addr]
C -->|not eligible| X1[Ineligible screen<br/>reason + how snapshot works]
C -->|eligible| D[Sign-in: SIWE personal_sign]
D -->|fail| D
D --> E[GET /me]
E -->|claim exists| S[Status screen]
E -->|manual review| M[Manual review screen<br/>contact path]
E -->|no claim| F[Connect Solana wallet<br/>Phantom / X1]
F --> G[Bind destination:<br/>sign with ETH + sign with Phantom/X1]
G --> H[Review: amount, destination, terms,<br/>'destination locks after claim']
H --> I[POST /claims w/ Idempotency-Key]
I -->|201/200| S
I -->|409 ALREADY_CLAIMED| S
S --> S2{status}
S2 -->|queued/signed| P[Pending: poll GET /claims/me every 5–10s]
S2 -->|paid/confirmed| Q[Paid: tx link + amount]
S2 -->|failed| R[Delayed: team notified, no action needed]8.2 Screen states
| State | Trigger | Copy (draft) | Actions |
|---|---|---|---|
| Not connected | Initial | "Check your XCORE allocation from the XEN snapshot (block <<B>>, 2026-10-01 00:00 UTC)." |
Connect Ethereum wallet / or paste an address for a read-only check |
| Ineligible | eligible:false |
"This address had no eligible XEN balance at the snapshot block." + reason | Try another wallet |
| Eligible, not signed in | eligible:true |
"You're eligible for N XCORE. Sign a message to continue. Signing is free and approves no transaction." | Sign in |
| Manual review | requiresManualReview |
"This address needs a manual check (e.g. contract wallet)." | Support path <<SUPPORT_CHANNEL>> |
| Bind destination | Signed in, no claim | "Choose where to receive XCORE. Connect Phantom or the X1 wallet." | Connect, sign twice |
| Review | Binding OK | Amount, destination (full address + first/last 4 highlighted), "Destination cannot be changed after you claim." | Claim |
| Pending | queued/signed |
"Your claim is being processed." + tx link once signed | — |
| Paid | paid/confirmed |
"Sent N XCORE to <addr>." + explorer link |
— |
| Already claimed | 409 | Same as status screen | — |
| Paused | 503 | "Claims are temporarily paused. Your allocation is safe." | — |
| Window closed | 403 | "The claim window closed on <<DATE>>." + unclaimed policy |
— |
8.3 Front-end rules
- The page never asks for, handles or stores a seed phrase or private key. The only wallet calls are
personal_sign(Ethereum) andsignMessage(Solana). Noeth_sendTransaction, no token approvals, no Solana transaction signing (except the optional bind-by-memo fallback, which is clearly labeled). - Show the full message the user is about to sign before you trigger the wallet.
- Wallet connectors: Ethereum through standard injected/WalletConnect providers. Solana through the Wallet Standard adapter, featuring Phantom and X1 wallet as recommended options.
- Show the manifest hash and a link to the artifacts so users can verify independently.
- Generate a fresh
Idempotency-Keyper claim attempt and keep it insessionStorageuntil success, so retrying after a network error reuses it. - Accessibility: keyboard navigable, readable errors, nothing that relies on color alone.
9. Security Threat Model & Controls
9.1 Assets and adversaries
Assets: the XCORE payout funds (hot wallet + treasury), integrity of the allocation table, the claim → destination mapping, the audit log, user trust. Adversaries: external attackers (API abuse, phishing clones), holders with compromised keys, malicious or compromised insiders/admins, supply-chain compromise (npm deps, CI), infrastructure provider compromise.
9.2 Threat → control matrix
| # | Threat | Impact | Controls |
|---|---|---|---|
| T1 | DB tampering (editing allocations to inflate an amount or add an address) | Theft from the pool | Allocations immutable via trigger + no UPDATE grant; row_sha256 per row; the worker re-verifies amount_raw against the signed published allocations.csv (bundled with the worker image or fetched by hash) before paying; hash-chained audit with external anchoring; nightly recompute of the allocation table hash vs manifest |
| T2 | Claim stealing: attacker claims someone else's allocation | Victim loses allocation | SIWE signature required; nonce single-use; domain binding; session bound to address; destination change requires an Ethereum signature |
| T3 | Destination hijack (XSS/MITM swaps the Solana address) | Funds to the attacker | Destination signed by the Ethereum key and the Solana key; strict CSP; no third-party scripts on /claim; review screen shows the full address; lock after queue |
| T4 | Replay of a SIWE or binding signature | Unauthorized session/binding | Nonce consumed atomically; expiry ≤ 10 min; domain/URI/chainId/snapshot-hash checks; signatures stored and checked for reuse (unique on signature hash) |
| T5 | Front-running | — | Not applicable in the usual sense. No public mempool transaction carries a claimable secret or open voucher. The user never broadcasts anything. The backend sends payouts straight to the bound address, so copying a payout tx does nothing. The remaining risk is a stolen SIWE signature (T4) or session (T7). (Option (b) vouchers would bring front-running back unless the voucher is bound to dest.) |
| T6 | Double payout (worker crash, retries, concurrent workers, batch + hot both paying) | Pool loss | UNIQUE claim per address; UNIQUE job per claim; signature persisted before broadcast; re-send only after proven expiry; memo preflight search; batch_id and hot-path exclusive (a job is either batch or hot, set once); daily recon (I5) |
| T7 | Session theft (XSS, cookie leakage) | Attacker acts as user | HttpOnly/Secure/SameSite=Strict cookie; CSP; 30-min TTL; destination change requires a fresh Ethereum signature anyway |
| T8 | Hot-wallet key compromise | Loss up to hot balance | Small balance; per-claim/hour/day caps; KMS-held key; auto-pause on unexpected outflow; treasury in multisig |
| T9 | Insider/admin abuse (void a claim and rebind to self) | Theft | RBAC; MFA; 2-person rule on void/rebind/unpause; admin actions audited and anchored; admins have no direct SQL write in prod; break-glass access logged |
| T10 | Snapshot manipulation (wrong block, edited CSV) | Unfair allocation | Two-provider determinism; signed manifest published before claims open; public recomputation tooling |
| T11 | Phishing clone site | Users sign for an attacker | SIWE domain check (our server); official URL from verified channels; the page tells users "we never ask for a seed phrase or a transaction approval" |
| T12 | API abuse/DoS, enumeration | Cost, availability | Rate limits; edge firewall; cached public lookups; public data only |
| T13 | Supply-chain (malicious npm dep injects a destination swap) | Theft | Lockfile + pinned versions; minimal deps on /claim; SRI for any static assets; CI dependency audit; T3 dual-signature makes silent swaps detectable |
| T14 | Compromised holder Ethereum key | Attacker claims | Not preventable (§5.9). Optional review of known-drainer-swept addresses. |
| T15 | Data loss | Lost claim state → double pay or nonpayment | PITR backups; on-chain memos let you rebuild the paid set (§9.9) |
9.3 DB tampering defenses in depth
- Signed snapshot manifest published publicly. ETL refuses to load unless the artifact SHA-256 matches the manifest and the manifest signature verifies.
- Immutable tables (triggers + grants). Migrations that touch
allocationsneed a new snapshot_id. - Worker-side verification: before signing a payout, the worker checks
(eth_address, amount_raw)against an allocation set it loads from the published artifact by hash, not only from the DB. - Append-only audit with hash chain + hourly external anchor.
- Read-replica separation: dashboards, recon and support tooling use the replica /
xc_readonly. Only the API and worker hold write roles. - Admin MFA + RBAC through an IdP (SSO). No shared DB passwords. Short-lived credentials where the platform supports them.
9.4 Secrets management
| Secret | Location | Rotation |
|---|---|---|
| DB credentials (per role) | Platform secret store (Vercel env, encrypted) / Neon role passwords | 90 days or on staff change |
| Session signing key | Secret store | 90 days. Keep the previous key for verification during rollover. |
| Payout signer key | KMS/HSM or dedicated signer service, never in Vercel env for the web project | On suspicion. Rotate by moving funds to a new key. |
| RPC API keys | Secret store | 180 days |
| Manifest publishing key | Offline / hardware wallet | Per snapshot |
Never commit secrets. Run secret scanning in CI. Never print secrets in logs.
9.5 Hot-wallet limits
See §7.3. Caps are enforced in the worker and in the DB (a payout_limits check function sums paid+signed amounts in the last hour/day before leasing).
9.6 Abuse and rate limits
See §6.5. Add anomaly alerts for nonce-to-verify ratio, verify failure rate, and repeated lookups of many addresses from one IP.
9.7 Logging
Structured JSON logs carry the request id, route, status and eth address (public). Never log signatures in full (log a hash), cookies, raw IPs or secrets.
9.8 PII
- Store no PII: no emails, names, or KYC.
- Blockchain addresses are public, pseudonymous data. Linking an Ethereum address to a Solana address creates a new public-ish linkage. Disclose it in the terms/privacy notice, keep binding data out of public endpoints, and know that the payout tx on-chain doesn't reveal the Ethereum address (only the memo ref, which is a salted hash).
- IPs: salted hash only, kept ≤ 30 days for rate limiting.
- If a support channel collects contact details, keep them outside this DB.
9.9 Backup and restore
- Managed Postgres PITR (Neon branching/history or equivalent), plus a nightly logical dump (
pg_dump) encrypted to a separate account/bucket with object lock. - Restore drill before launch and monthly. RPO ≤ 5 min, RTO ≤ 4 h.
- After any restore: pause payouts, rebuild the paid set from on-chain memos for the hot wallet and multisig, reconcile claims (on-chain paid but DB not → mark paid; DB signed but no tx → prove expiry), then unpause with 2 people.
9.10 Incident response and pause
| Severity | Example | First action | Owner |
|---|---|---|---|
| SEV1 | Unexpected hot-wallet outflow, key compromise suspected | Auto/manual payouts_paused=true; move remaining hot funds to treasury (multisig); rotate key |
On-call + owner |
| SEV1 | Audit chain break / allocation hash mismatch | Pause claims and payouts; freeze DB writes; forensic snapshot | On-call |
| SEV2 | Recon mismatch (small) | Pause payouts; investigate the specific claims | On-call |
| SEV3 | Elevated errors, RPC outage | Worker backs off; status banner | On-call |
Run a post-incident review and publish a public statement for anything that affects users.
10. Operations
10.1 Environment variables (names only, no values)
| Name | Component | Description |
|---|---|---|
DATABASE_URL_API |
API | Postgres URL for the xc_api role (pooled) |
DATABASE_URL_WORKER |
Worker | xc_worker role |
DATABASE_URL_READONLY |
Recon/dashboards | xc_readonly / replica |
DATABASE_URL_MIGRATOR |
CI migrations only | xc_migrator. Not set in runtime environments. |
CLAIM_DOMAIN |
API/Web | Exact domain for the SIWE check, e.g. <<CLAIM_DOMAIN>> |
SIWE_NONCE_TTL_SECONDS |
API | Default 300 |
SESSION_SIGNING_KEY / SESSION_SIGNING_KEY_PREVIOUS |
API | Session/JWT key (rotation pair) |
SESSION_TTL_SECONDS |
API | Default 1800 |
ACTIVE_MANIFEST_SHA256 |
API/Worker | Must equal the DB active manifest. Startup check. |
IP_HASH_SALT |
API | Rate-limit hashing salt |
RATE_LIMIT_REDIS_URL |
API | Optional if not using Postgres buckets |
ETH_RPC_URL_PRIMARY / ETH_RPC_URL_SECONDARY |
Snapshot, API (EIP-1271), recon | Archive-capable for snapshot |
PAYOUT_CHAIN |
Worker | solana | x1 | … |
PAYOUT_RPC_URL_PRIMARY / PAYOUT_RPC_URL_SECONDARY |
Worker/recon | Payout chain RPC |
XCORE_MINT_ADDRESS |
Worker/recon | <<VERIFY>> mint/contract address |
XCORE_DECIMALS |
Worker | <<VERIFY>>. Must match the on-chain mint (startup check). |
PAYOUT_SIGNER_KMS_KEY_ID |
Worker | Reference to the KMS key (not the key) |
PAYOUT_HOT_WALLET_ADDRESS |
Worker/recon | Public address |
PAYOUT_MULTISIG_ADDRESS |
Recon | Public address of the batch payer |
PAYOUT_REF_SALT_PUBLIC |
API/Worker | Salt for payout_ref. Can be published. |
HOT_WALLET_MAX_PER_CLAIM_RAW / HOT_WALLET_MAX_PER_HOUR_RAW / HOT_WALLET_MAX_PER_DAY_RAW |
Worker | Limits |
CLAIM_WINDOW_OPENS_AT / CLAIM_WINDOW_CLOSES_AT |
API | ISO times (mirrored in system_flags) |
TERMS_VERSION |
API/Web | Current terms id |
ALERT_WEBHOOK_URL |
All | Alert sink (pager/chat) |
ADMIN_OIDC_ISSUER / ADMIN_OIDC_CLIENT_ID / ADMIN_OIDC_CLIENT_SECRET |
Admin | SSO with MFA |
AUDIT_ANCHOR_BUCKET |
Worker/cron | WORM storage for audit anchors |
10.2 Deployment topology
Diagram source (Mermaid)
flowchart LR
U[User browser] -->|HTTPS| V[Vercel project: claim web + /api/v1 functions]
V -->|xc_api pooled| N[(Neon Postgres primary)]
N --> R[(Read replica)]
W[Payout worker<br/>isolated runtime: container/VM<br/>or separate Vercel project w/ cron] -->|xc_worker| N
W --> K[KMS signer]
W --> SOL[Payout chain RPC x2]
C[Cron: recon, audit-anchor, nonce GC] --> R
C --> SOL
C --> ETH[Ethereum RPC x2]
A[Admin console<br/>SSO+MFA, IP allowlist] --> N- Web + API: one Vercel project (consistent with the prior stack). Production and Preview environments use separate databases (Neon branches). Preview must never point at production data or payout keys.
- Worker: separate from the web project, so the signer key is never in the same runtime as public HTTP handlers. Prefer a small always-on container (Fly/Render/VM) or a separate Vercel project triggered by cron with no public routes.
- Admin console: separate deployment with SSO, or a CLI that runs only from an allowlisted environment.
- Note: this document makes no changes to
wxnt-sale-sim.vercel.appor any existing project. Integrating with it is a future, separately approved step.
10.3 Migrations
- Tool: one of
node-pg-migrate, Prisma Migrate, Drizzle Kit, orsqitch. Forward-only, numbered files in git. - Run from CI with
DATABASE_URL_MIGRATORafter review. Apply to a Neon branch first and run the integration suite there. - Allocation loads are ETL operations, not migrations:
etl load --manifest snapshot_manifest.json --artifacts ./out/. That command verifies the signature and hashes, loads in one transaction, checks invariant I1 and counts, and setsis_activeonly through a separate 2-person admin action.
10.4 Monitoring and alerts
| Metric | Alert threshold (initial) |
|---|---|
| API 5xx rate | > 2% over 5 min |
auth/verify failure ratio |
> 50% over 10 min (abuse/phishing signal) |
| Claims queued > 15 min | > 0 (when not paused) |
Claims signed > 10 min without resolution |
> 0 |
Jobs dead / claims failed |
any |
| Hot wallet XCORE balance | < 25% target |
| Hot wallet SOL (fees/rent) | < <<N>> SOL |
| Unknown outflow from hot wallet | any → auto-pause |
| Recon mismatch | any → pause payouts |
| Audit chain verify | any break → SEV1 |
| Paid vs pool (I2) | drift ≠ 0 |
Dashboards: claims by status, cumulative paid vs pool, claims per hour, worker latency p50/p95, RPC error rates.
10.5 Daily reconciliation job
def daily_recon():
db_paid = sql("SELECT payout_ref, payout_tx, amount_raw, dest_address FROM claims WHERE status IN ('paid','confirmed')")
chain = index_outflows([HOT_WALLET, MULTISIG], since=LAUNCH) # parse SPL transfers + memos
by_ref_chain = group(chain, key=lambda t: t.memo_ref)
mismatches = []
for c in db_paid:
txs = by_ref_chain.get(c.payout_ref, [])
if len(txs) == 0: mismatches.append(("db_paid_not_onchain", c))
elif len(txs) > 1: mismatches.append(("DOUBLE_PAY", c, txs)) # SEV1
elif txs[0].amount != c.amount_raw or txs[0].dest_owner != c.dest_address:
mismatches.append(("amount_or_dest_mismatch", c, txs[0]))
for ref, txs in by_ref_chain.items():
if ref not in {c.payout_ref for c in db_paid}:
mismatches.append(("onchain_not_db_paid", ref, txs)) # includes 'signed' claims that landed
for t in chain:
if t.memo_ref is None and not t.is_known_admin_movement:
mismatches.append(("UNTAGGED_OUTFLOW", t)) # SEV1
totals_ok = sum(db_paid.amount) == sum(t.amount for t in chain if t.memo_ref)
record_recon_run(db_paid_raw=..., chain_paid_raw=..., mismatches, ok=not mismatches and totals_ok)
if mismatches: alert(); set_flag('payouts_paused','true')
Also: promote paid → confirmed for matched, finalized txs. Check invariants I1–I6.
10.6 Snapshot re-run determinism test
- CI job (nightly before launch, then weekly): rebuild
holders_all.csv,excluded_addresses.csvandallocations.csvfromraw_logs.jsonl.zstwith no network, then compare SHA-256 to the manifest. Must match exactly. - Pre-launch (once per provider): full re-scan from both RPC providers, artifact hashes equal.
- Formula property tests: invariants I1, non-negativity, cap respected, dust excluded, deterministic tie-break.
- Platform pinning: pin runtime versions and the lockfile. Sort with byte-wise lowercase-hex comparison, not locale-aware sorting.
10.7 Routine jobs
| Job | Schedule |
|---|---|
| Nonce GC (delete expired, unconsumed nonces > 1 day old) | hourly |
| Session GC | hourly |
| Audit anchor | hourly |
| Audit chain verify | nightly |
| Reconciliation | daily (+ on demand) |
| Allocation hash verify vs manifest | nightly |
| Backup restore drill | monthly |
11. Testing & Acceptance Criteria
11.1 Unit tests
- Block selector: synthetic timestamps; boundary equal to T; T between blocks; T before deploy (error).
- Log decoder: topic parsing, zero address handling, mint/burn, self-transfer (from == to, net zero).
- Balance reconstruction: negative balance detection triggers abort.
- Allocation formulas: invariants I1; cap water-filling; dust; rounding remainder; deterministic tie-break; huge numbers (78-digit).
- Address normalization: lowercase/uppercase accepted, bad mixed-case checksum rejected, Solana base58 32-byte + on-curve.
- SIWE verification: every row of the §5.4 table.
- Ed25519 binding verification.
- State machine trigger: every illegal transition rejected.
-
payout_refderivation stable and unique.
11.2 Integration tests (Neon branch / local Postgres + local chain)
- ETL refuses a tampered artifact (1 byte changed) and a bad manifest signature.
- Concurrent
POST /claims(50 parallel, same address, different idempotency keys) → exactly 1 claim, 49 × 409. - Same idempotency key replay → identical 200 body.
- Worker crash after persisting the signature but before broadcast → restart → no double send. The claim resolves via signature status.
- Worker crash after broadcast before mark_paid → preflight finds the memo → marked paid, no re-send.
- Expired-blockhash path → re-queued with the same ref → paid once.
- Payout caps → worker pauses at the hourly cap.
- Pause flags stop claims and payouts respectively.
- Recon detects: injected double pay, untagged outflow, DB-paid-not-on-chain.
- Audit chain: a manual UPDATE attempt fails; a simulated row edit (superuser) is detected by verify.
- Payout on a Solana local validator/devnet with a test mint (never mainnet in CI).
11.3 End-to-end (Playwright with test wallets)
- Eligible flow: connect → SIWE → bind (Phantom/X1-compatible test signer) → review → claim → pending → paid.
- Ineligible address screen.
- Already-claimed address goes straight to the status screen.
- Paused banner.
- Window closed.
- Network drop during POST /claims → retry reuses the idempotency key → single claim.
11.4 Test vectors
Use a dedicated test snapshot (snapshot_id = 900) built on a fork or synthetic data. These are placeholder fixture addresses, not real holders.
| ID | Input | Expected |
|---|---|---|
| TV1 Known holder | 0x1111…1111 balance 1,000,000 XEN (fixture) |
eligible:true, allocation per formula fixture; claim 201 |
| TV2 Zero balance | 0x2222…2222 received then sent all before B |
eligible:false, NOT_IN_SNAPSHOT; POST /claims → 403 NOT_ELIGIBLE |
| TV3 Dust | 0x3333…3333 below min_eligible_raw |
eligible:false, BELOW_DUST |
| TV4 Already claimed | TV1 after a successful claim, new idempotency key | 409 ALREADY_CLAIMED with existing claimId/status |
| TV5 Bad signature | Valid message, signature from another key | 401 SIG_INVALID |
| TV6 Expired nonce | Nonce issued at t0, verify at t0+6 min (TTL 5) | 401 NONCE_INVALID_OR_REPLAYED / SIWE_EXPIRED |
| TV7 Replay | Re-submit a successful (message, signature) pair | 401 NONCE_INVALID_OR_REPLAYED |
| TV8 Wrong domain | Message with domain: evil.example |
400 SIWE_DOMAIN_MISMATCH |
| TV9 Wrong snapshot resource | Resource hash ≠ active | 400 SIWE_SNAPSHOT_MISMATCH |
| TV10 Bad checksum | 0xAbC… with one wrong-case character |
400 ADDRESS_BAD_CHECKSUM |
| TV11 Dest sig missing/invalid | Binding with a corrupted ed25519 sig | 401 SIG_INVALID (or destVerified:false if policy allows) |
| TV12 Excluded LP | Fixture pool address | eligible:false, EXCLUDED_POLICY (lp_pool) |
| TV13 Contract wallet | is_contract=true fixture |
requiresManualReview:true / 403 MANUAL_REVIEW_REQUIRED |
| TV14 Destination change after queue | POST /destination with status queued | 409 DESTINATION_LOCKED |
Snapshot test vectors (pre-launch, real chain): pick 20 well-known addresses (top holders, a known burn, a known LP, a random sample) and check the reconstructed balance against balanceOf(addr, B) from both providers. Store them in reconciliation_report.json.
11.5 Acceptance criteria (launch gate)
- Manifest frozen, signed, published. The determinism test passes across two providers.
- Total supply check exact. Sample reconciliation 100%.
- Every §11.1–11.3 test is green in CI.
- Load test: 50 claims/s sustained at the API with p95 < 500 ms. The worker handles the expected peak.
- External security review of the auth, claim and worker code.
- Backup restore drill done, with paid-set rebuild from chain demonstrated on devnet.
- Runbooks: pause, key rotation, restore, recon mismatch.
- All
<<PLACEHOLDER>>values resolved.grep '<<' config/is empty.
12. Build Sequence & Open Decisions
12.1 Phased checklist
Phase 0: Decisions & freeze (owner)
- Confirm the snapshot time (2026-10-01 00:00 UTC?) and block rule
- Decide everything in §12.3
- Choose the payout chain (Solana vs X1) and confirm the XCORE mint + decimals
Phase 1: Snapshot (1–2 weeks)
- Find and verify the deploy block
- Scanner with checkpointing; raw log archive
- Reconstruction + classification + labels review
- Reconciliation report; two-provider match
- Allocation engine + manifest + signature
- Publish artifacts for public review (comment period recommended)
Phase 2: MVP backend + page (2–3 weeks)
- Schema + migrations + roles
- ETL loader with hash/signature verification
- Auth endpoints (SIWE), sessions
- Destination binding (Phantom / X1 signMessage)
- Claims endpoint with idempotency
- Batch export payout (option c) as the first payout path (no hot key yet)
- Claim page states
- Audit log + hash chain
Phase 3: Hardening (1–2 weeks)
- Hot-wallet worker (option a) with KMS, caps, memo, preflight, proven-expiry retries
- Reconciliation job + alerts + auto-pause
- Rate limits, CSP, firewall
- Admin console with SSO/MFA + 2-person actions
- EIP-1271 support (or a documented manual path)
- Backup/restore drill, load test, external review
Phase 4: Launch
- Soft launch: allowlist of test holders / small cap per hour
- Public launch: announce the official URL + manifest hash from verified channels
- Daily recon review. Weekly public status (claimed count / amount).
- Claim window close → apply the unclaimed policy → final report + final audit anchor
12.2 RACI (lightweight)
| Area | Owner | Builder | Reviewer |
|---|---|---|---|
| Snapshot & formula | Project owner | Data eng | Independent recompute (community/auditor) |
| API/auth | — | Backend eng | Security reviewer |
| Payout/keys | Project owner | Backend eng | 2nd signer |
| Ops/alerts | — | DevOps | Owner |
12.3 Open decisions (owner must decide before freeze)
| # | Decision | Options | Notes |
|---|---|---|---|
| D1 | Snapshot timestamp/year/rule | 2026-10-01 00:00 UTC, first block ≥ T (assumed) | Freeze in the manifest |
| D2 | Allocation formula | Pro-rata / capped / hybrid / sqrt | §3.3. Consider sybil effects. |
| D3 | Pool size (XCORE) | <<POOL>> |
Plus remainder policy |
| D4 | Dust threshold & min allocation | <<…>> |
Payout cost vs inclusion |
| D5 | Per-wallet cap | none / <<CAP>> |
Caps invite splitting only if set before snapshot. After snapshot, they're merely redistributive. |
| D6 | Destination chain & address format | Solana / X1 / other | Wallets: Phantom, X1 wallet. Base58 ed25519. |
| D7 | Require destination signature? | Required / optional with warning | Recommend required, with memo fallback |
| D8 | Claim window | opens <<>>, closes <<>> |
Communicate clearly |
| D9 | Unclaimed funds policy | return to treasury / burn / redistribute in round 2 | Publish before launch |
| D10 | Contract-wallet holders | EIP-1271 / manual / exclude | §5.6 |
| D11 | LP pools & protocol contracts | exclude / look-through | §2.4 |
| D12 | CEX addresses | include-flagged / exclude / coordinate with exchanges | §2.4 |
| D13 | Payout mode | (a) / (c) / hybrid + thresholds | §7 recommends the hybrid |
| D14 | Public lookup privacy | public amounts vs auth-only | §6.2 |
| D15 | Terms of claim & jurisdiction restrictions | legal review | Out of scope technically, but blocks launch |
13. Alternatives Considered
13.1 On-chain Merkle distributor vs off-chain DB
In the Merkle design, the allocation table becomes a Merkle tree and the root is committed to a distributor program/contract on the payout chain. Each user submits a proof to claim. To keep Ethereum-address eligibility, the program has to verify an Ethereum signature (secp256k1 recover; Solana has a secp256k1_recover syscall / secp256k1 program) binding eth_address → solana_recipient, or a relayer submits on the user's behalf.
| Dimension | Off-chain DB (this doc) | On-chain Merkle distributor |
|---|---|---|
| Trust | Users trust the operator to pay honestly and not to tamper with the DB. Transparency comes from published artifacts and recon. | Trust-minimized: once the root is committed and funded, the operator can't change amounts (unless there's an admin "clawback"). |
| Correctness verifiability | Users can verify their allocation from the artifacts, but not that they'll be paid | Verifiable on-chain; claim status public |
| Engineering cost | Low–medium: Postgres + API + worker. No custom on-chain code. | Medium–high: custom program + cross-curve signature verification + audit (expensive) |
| Operational cost | Project pays all payout fees and ATA rent; ongoing infra | Users pay their own claim fees (or a relayer does); minimal infra after launch |
| UX | Very smooth: sign 2 messages and the payout arrives. No gas needed on Ethereum or Solana. | User needs SOL for fees on the destination and a more complex flow (Ethereum signature + Solana tx) |
| Censorship | Operator can refuse or delay payouts | Operator can't block claims (if no pause/admin key) |
| Recoverability / fixes | Easy to fix mistakes: void, re-issue a snapshot, manual review, help users who lost keys (policy permitting) | Hard: a wrong root needs a new deployment; funds can be locked by bugs |
| Key-compromise blast radius | Hot wallet balance (capped) | Distributor vault (whole pool) if the program has a bug; admin key if any |
| Privacy | Ethereum ↔ Solana link stays in the DB (payout memo shows only a salted ref) | Link is public on-chain |
| Speed to launch | Weeks | Weeks + audit time |
| Sybil resistance | Same: none from the snapshot alone | Same |
| Unclaimed funds | Simple: they stay in the treasury | Needs a sweep/clawback function (which reintroduces admin trust) |
Verdict: for a small team, the off-chain DB with published artifacts, a hybrid hot-wallet/multisig payout and daily public reconciliation gets to launch fastest with the best UX. The price is more operator trust. If the community wants trust minimization, the snapshot and allocation pipeline here carries over unchanged to a Merkle approach later: the same allocations.csv becomes the Merkle leaves.
13.2 Other alternatives briefly
| Alternative | Why not (for now) |
|---|---|
| Admin-signed vouchers (option b) | Needs on-chain code anyway; voucher theft/front-running unless bound to dest; signer compromise risk |
| Bridge/burn-to-claim (users burn or lock XEN) | Changes the eligibility basis away from the snapshot; costs gas on Ethereum; not requested |
| Push airdrop to all holders without claims | No destination address is known for Ethereum holders on Solana, so it's impossible without binding |
| Ethereum-side payout (XCORE as an ERC-20) | Possible via the chain-agnostic design (dest_chain='evm'), but the prior work puts XCORE on Solana |
14. Appendix: Assumptions Register & Glossary
14.1 Assumptions and flags
| ID | Assumption / flag | Action |
|---|---|---|
| A1 | Snapshot = 2026-10-01 00:00:00 UTC (1790812800), block rule first block with timestamp ≥ T, end-of-block balances |
Owner confirms. Freeze year, block number and hash in the manifest before launch. |
| A2 | XEN decimals = 18, standard Transfer events cover all balance changes | VERIFY against verified source + total supply check |
| A3 | XEN deployment block ≈ October 2022 (~15.7M) | VERIFY the exact creation block |
| A4 | XCORE payout on Solana (or X1) via SPL transfer; mint and decimals unknown here | VERIFY mint address, decimals, token program (Token vs Token-2022) |
| A5 | Destination wallets: Phantom and X1 wallet recommended | Per owner preference. Other Wallet-Standard wallets technically compatible but not featured. |
| A6 | Stack: Vercel (web + API) + Neon Postgres; worker isolated from the web runtime | Consistent with prior work; confirm |
| A7 | Claim page concept at wxnt-sale-sim.vercel.app/claim |
No changes made. Integration is a separate approved step. |
| A8 | All numeric examples in §3 are EXAMPLE ONLY | Owner decides formula, pool, caps |
| A9 | No sybil resistance; no KYC; no PII | Accept or add policy |
| A10 | LP pools, burn addresses and the token contract excluded by default; contract wallets → manual review; CEX → include-flagged | Owner decides D10–D12 |
| A11 | Rate limits, caps and alert thresholds are starting points | Tune with load tests |
14.2 Glossary
| Term | Meaning |
|---|---|
| Snapshot block (B) | Ethereum block whose end state defines eligibility |
| Manifest | Signed JSON pinning the block, parameters and artifact hashes |
| Allocation | XCORE base units assigned to an Ethereum address |
| Binding | Signed link from an eligible Ethereum address to a payout address |
payout_ref |
Deterministic 128-bit hex reference for a claim, embedded in an on-chain memo |
| SIWE | Sign-In with Ethereum, EIP-4361 |
| EIP-191 | personal_sign message prefixing standard |
| EIP-1271 | Contract signature validation standard |
| EIP-55 | Mixed-case checksum address encoding |
| PITR | Point-in-time recovery |
| WORM | Write-once-read-many storage |
End of document. Design documentation only: nothing was deployed, no live site was modified, no keys were created or used.