Technical Documentation

XCORE Claims via an Off-Chain Database Layer

Technical design & implementation guide: XEN (Ethereum) snapshot → authenticated off-chain claim → XCORE payout

Technical Documentation  ·  October 16, 2026  ·  Draft — design only, nothing here is deployed

Source token: XEN Crypto (Ethereum)  ·  Claimed token: XCORE  ·  Snapshot assumption: October 1, 2026, 00:00 UTC

Abstract

This document describes a system that records every Ethereum address that held a non-zero XEN balance at one fixed snapshot block, turns each balance into an XCORE allocation using a versioned formula, and loads the fixed, hashed result into an off-chain Postgres database. A holder connects their Ethereum wallet, proves ownership with a SIWE / EIP-4361 signed message, binds a destination address on the payout chain, and submits a claim; a separate fulfillment worker then pays XCORE at most once per claim. The design is off-chain and trust-based: transparency comes from publishing the snapshot artifacts, their SHA-256 manifest and the allocation formula, so anyone can recompute them.

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

  1. Overview & Architecture
  2. Snapshot Production
  3. Eligibility & Allocation Rules
  4. Off-Chain Database Design
  5. Wallet Authentication
  6. Claim API
  7. Payout Fulfillment Options
  8. Webpage UX Flow
  9. Security Threat Model & Controls
  10. Operations
  11. Testing & Acceptance Criteria
  12. Build Sequence & Open Decisions
  13. Alternatives Considered
  14. Appendix: Assumptions Register & Glossary

1. Overview & Architecture

1.1 What this system does

  1. 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).
  2. Allocation. Turns each balance into an XCORE allocation using a pluggable, versioned formula. The result is a fixed, hashed allocation table.
  3. Database. Loads the table into an off-chain Postgres database, which is the system of record for who can claim and how much.
  4. 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.
  5. 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.
  6. 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

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 --> AUD
Figure — diagram 1. Rendered from Mermaid source in the Markdown document.
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 --> AUD

1.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


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_manifest before 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 field block_rule. This document uses first_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:

  1. 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 B and the same block_hash.
  2. Wait for finality: only take the snapshot once B is 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.
  3. Record block_number, block_hash, block_timestamp, parent_hash and block_rule in the manifest.
  4. Anyone can verify with a public explorer or any archive RPC: block B has the recorded hash, ts(B) ≥ T, and ts(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 Transfer event (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

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 to remainder_raw and 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:


4. Off-Chain Database Design

4.1 Platform and roles

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

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 --> [*]
Figure — diagram 2. Rendered from Mermaid source in the Markdown document.
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

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

  1. Prove that the requester controls the private key of the eligible Ethereum address (personal_sign, EIP-191, carrying an EIP-4361 SIWE message).
  2. 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.
  3. Make every signature single-use, time-boxed and bound to this domain.

5.2 SIWE challenge flow

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 end
Figure — diagram 3. Rendered from Mermaid source in the Markdown document.
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
  end

5.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

5.6 Smart-contract wallets (Safe, etc.)

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:

  1. Ethereum signature (EIP-191 personal_sign) by the eligible address. It proves the eligible holder chose this destination, and is required.
  2. Destination-chain signature by the Solana/X1 key (signMessage in 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:

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

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:

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

  1. Admin (MFA) runs export_batch(max_rows, max_total_raw). It selects queued jobs, assigns a batch_id, and writes batch_<id>.csv (claim_id,dest,amount_raw,payout_ref) plus its SHA-256 into admin_actions.
  2. A second admin independently re-derives the batch from the DB read replica and compares hashes.
  3. Multisig signers execute transfers with memos (xcore:1:<payout_ref>), in chunks that fit the tx size limit.
  4. The worker ingests the results by scanning the multisig vault's transactions for memos, then moves the claims to paid/confirmed.
  5. Rows not found on-chain after the batch window → back to queued (proven not paid) and into the next batch.

7.5 Pause switch


8. Webpage UX Flow

8.1 Flow

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]
Figure — diagram 4. Rendered from Mermaid source in the Markdown document.
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


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

  1. Signed snapshot manifest published publicly. ETL refuses to load unless the artifact SHA-256 matches the manifest and the manifest signature verifies.
  2. Immutable tables (triggers + grants). Migrations that touch allocations need a new snapshot_id.
  3. 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.
  4. Append-only audit with hash chain + hourly external anchor.
  5. Read-replica separation: dashboards, recon and support tooling use the replica / xc_readonly. Only the API and worker hold write roles.
  6. 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

9.9 Backup and restore

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

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
Figure — diagram 5. Rendered from Mermaid source in the Markdown document.
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

10.3 Migrations

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

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

11.2 Integration tests (Neon branch / local Postgres + local chain)

11.3 End-to-end (Playwright with test wallets)

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)


12. Build Sequence & Open Decisions

12.1 Phased checklist

Phase 0: Decisions & freeze (owner)

Phase 1: Snapshot (1–2 weeks)

Phase 2: MVP backend + page (2–3 weeks)

Phase 3: Hardening (1–2 weeks)

Phase 4: Launch

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.