DWELL technical documentation
Status: design level, matches SPEC.md v1. Items marked [OPEN] are unresolved and tracked in ROADMAP.md.
1. Architecture
No custom on-chain program in v1. Components:
| Component | Technology | Role |
|---|---|---|
| Launch | Meteora Dynamic Bonding Curve SDK | Create token and curve with the shared fee config |
| Indexer | Node/TS service, Helius data | Read balances and swaps, write to Postgres (Supabase) |
| Scorer | Node/TS, deterministic | Compute Dwell Scores per epoch |
| Merkle publisher | Node/TS | Build tree, publish root and snapshot, fund distributor |
| Distributor | Open source Merkle distributor program already on mainnet | Hold funds, verify proofs, pay claims |
| Treasury | Squads vault, 2-of-3 | Claim fees, fund epochs |
| Web | Next.js | Home, launch, token, portfolio (claim), leaderboard, docs |
| Verifier CLI | Node/TS | Recompute an epoch from the public snapshot and compare to root |
2. Data flow
trade on curve/pool
-> fees accrue in DBC fee accounts
-> Squads vault claims creator share to creator, platform share to vault
-> Indexer ingests swaps and balance changes (Helius)
-> end of epoch (00:00 UTC): freeze snapshot
-> Scorer computes score per wallet
-> Publisher builds merkle tree, stores snapshot, root
-> Squads vault funds distributor with the Dwell Pool amount
-> Root set on distributor
-> Holders claim with proof from the web app
Each epoch is a closed unit: snapshot, scores, tree, root, funding. Nothing in a published epoch changes. Corrections are published as a new, explicitly linked restatement (see SECURITY.md).
3. Scoring math
Epoch length: 24 hours, from 00:00 UTC to 24:00 UTC. Each epoch is divided into 24 hourly slices h = 1..24.
Definitions for wallet w in epoch e:
B(w,h)= the minimum token balance the wallet held at any moment inside slice h. Using the minimum means a buy and sell inside one hour earns nothing.d(w)= tenure in days (a real number). Increases by 1 at each 00:00 UTC boundary while the wallet holds a balance. A wallet with zero balance has d = 0.- Sell of fraction f (tokens sold divided by balance before the sale):
d := d x (1 - f). Applied at the moment of the sale and carried into the following slices. - Buy: no change to d. [OPEN] see section 3.1.
M(d) = 1 + 2 x min(d, 14) / 14. M is 1.0 at d = 0 and 3.0 at d >= 14.
Score:
S(w,e) = ( sum over h=1..24 of B(w,h) x M(d(w,h)) ) / 24
where d(w,h) is the tenure at the start of slice h after any sells in earlier slices.
Total: T(e) = sum over scored wallets of S(w,e).
Payout in lamports: P(w,e) = floor( Pool(e) x S(w,e) / T(e) ), computed in integer arithmetic. Scores are held as integers scaled by 10^6 to avoid floating point. The remainder Pool(e) - sum P(w,e) goes to the reserve.
Worked check (balances constant, so S = B x M): pool 2,700,000,000 lamports, scores 3,000,000, 4,000,000, 5,000,000, 1,500,000, total 13,500,000. Each score point is worth 2,700,000,000 / 13,500,000 = 200 lamports. Payouts 600,000,000, 800,000,000, 1,000,000,000, 300,000,000, sum 2,700,000,000. No remainder.
Exclusions
Removed from scoring before T(e) is computed:
- Creator wallet.
- Program and pool accounts (bonding curve, AMM pool, vaults).
- Known exchange and burn addresses (maintained list, versioned and included in the snapshot).
- Any wallet that received tokens by transfer (non-swap) during the epoch. Transfers inherit no tenure.
The exclusion list version is part of the snapshot so the verifier uses the same list.
3.1 Open design question: buys
SPEC says buys do not reset tenure. Because d is per wallet, a wallet can hold a tiny amount for 14 days, then buy a large amount and receive M = 3.0 on all of it. Option for P1: on a buy of quantity q onto balance b, set d := d x b / (b + q) (token weighted blending). This preserves "buys do not reset" in spirit while closing the loophole. Needs founder decision.
4. Merkle format
Snapshot and tree are published per epoch. Proposed file layout:
epochs/<mint>/<epoch>/snapshot.json inputs and scores
epochs/<mint>/<epoch>/tree.json leaves and root
tree.json fields:
{
"version": 1,
"mint": "<base58>",
"epoch": 42,
"pool_lamports": "2700000000",
"root": "<hex 32 bytes>",
"claims": [
{ "index": 0, "claimant": "<base58>", "amount": "600000000", "proof": ["<hex>", "..."] }
]
}
Rules:
- Claims sorted by claimant public key bytes ascending; index assigned in that order.
- Amounts are decimal strings (u64).
- Pinned to
jup-ag/distributorcommit 304c28a4e15f4032a2cea88e1c54b839a8d7de4d (programDiS3nNjFVMieMgmiQFm6wgJL7nevk4NrhXKLbtEH1Z2R, same id on mainnet and devnet; the devnet binary matches this source). Leaf:sha256(0x00 || sha256(claimant_32 || amount_u64_le || locked_u64_le)). Parent:sha256(0x01 || min(l,r) || max(l,r)). Proof elements are siblings only, no direction bits. An unpaired node is hashed with itself (duplicated), so the rightmost leaf of an odd level carries its own hash as sibling. A single leaf has an empty proof and the root is the leaf hash. - Our canonical leaf order is claimant public key bytes ascending (the reference tool keeps input order and the root depends on order, so the verifier must use ours).
- Golden vectors from the reference Rust tool:
packages/merkle/test/fixtures/jup-3.json,jup-100.json. - The sum of claim amounts must equal
pool_lamportsminus the remainder recorded insnapshot.json.
snapshot.json includes: epoch bounds, slice balances per scored wallet, tenure inputs, exclusion list version, pool amount, remainder, code commit hash of the scorer.
5. Treasury operations
Squads vault, 2-of-3 signers. Routine operations per epoch:
- Claim creator and platform fee shares from DBC fee accounts on schedule.
- After scoring and tree build, a proposal is created to transfer
pool_lamportsinto the distributor and set the root. Two signers must approve. - Signers each run the verifier CLI before approving. A signer approves only if the CLI matches the proposed root.
- After 30 days, unclaimed amounts for an epoch are swept and added to the next epoch's pool for the same token.
- Reserve balance is tracked separately and spent only on failed claims and operations, with entries in an operations ledger.
Funding per epoch is per token. Fees from one token never pay holders of another.
Payout currency (confirmed on devnet, docs/devnet-proof/payout-e2e.json): one distributor per epoch with the wrapped SOL mint, version = epoch, max_total_claim = pool. Funding = system transfer of lamports into the distributor's wSOL vault ATA plus syncNative. A claim is one transaction: create the holder's wSOL ATA (idempotent), new_claim, closeAccount to the holder, which unwraps. Builders: packages/payout.
Costs the design must carry (devnet measured; the mainnet rent rate may differ, so re-measure): the holder pays a non-refundable ClaimStatus account (160 bytes, about 0.00146 SOL on devnet) plus the network fee on the first claim of each epoch, so a share below that is a net loss. The publisher pays the distributor account (448 bytes, about 0.0029 SOL devnet) and its vault ATA (about 0.0015 SOL devnet) per epoch; close_distributor is test-phase only, so this is a sunk cost per epoch. The distributor admin can change the clawback receiver, so the publisher hands the admin role to the Squads vault with set_admin in the same transaction that creates the distributor. The clawback start must be at least one day after the (unused) vesting end; the spec's 30 days is a parameter.
5.1 Dust policy and carry ledger (implemented, founder-changeable)
Why: a holder's first claim permanently costs the ClaimStatus rent plus the network fee (about 0.0015 SOL on devnet, about 0.0020 SOL at the classic mainnet rate), so a share below that is a net loss. Policy, a recommendation the founder can still change:
MIN_CLAIM_LAMPORTS= 10_000_000 (0.01 SOL), inpackages/scorer/src/dust.ts. Rent is then about 15% of the smallest possible first claim on devnet and about 20% at the classic mainnet rate. A higher floor lowers that share and lengthens the wait for small holders; a lower floor does the opposite.- Each epoch a wallet's due is its score allocation plus its live carry. If due >= the threshold, the wallet is in the tree with amount = due and its carry is cleared. If due < the threshold, nothing goes in the tree and the due becomes the wallet's carry (
{ amount, since }, keyed by wallet, one ledger per mint, so tokens never mix). CARRY_EXPIRY_SECONDS= 90 days. A carry whosesince(the epoch end it was first created) is 90 days or more before this epoch end expires and returns to the Dwell Pool: it is added to that same epoch's pool before allocation, so it is re-spent on holders, not kept.- Exact conservation, every epoch, in lamports:
poolLamports + carry in == paid + carry out + unallocated.unallocatedis non-zero only when nobody scored (the pool then stays untouched, as before). Allocation still uses largest remainder, so no lamport is created or lost. The property test runs 60 chains of 50 random epochs and asserts this identity, that every paid amount is at least the floor and every carry is below it. - The ledger is part of the snapshot:
inputs.prevCarry,inputs.minClaimLamports,inputs.carryExpirySeconds(optional, default 90 days), and outputscarryandreturnedLamports. The verifier CLI callsverifySnapshot, which recomputes all of it and rejects a changed carry, changed incoming carry, a lowered threshold, or payouts plus carry that do not equal pool plus incoming carry. - Legacy snapshots with no dust fields verify with the policy off (threshold 0). The epoch runner (
packages/epoch) now passes the default policy (DEFAULT_DUST_POLICY, i.e.MIN_CLAIM_LAMPORTS) on every snapshot it builds, chains the carry ledger from the previous saved epoch, and refuses to build a tree with a claim below the floor; the library itself still does not turn the policy on, only the runner does. Wiring that into the epoch runner is still to do. - Treasury: carried lamports are not in the distributor. The vault funds each distributor with the tree total only; the carry ledger total stays in the vault as a liability and is published with the snapshot.
assertNoUneconomicClaimsinpackages/payoutis the last check before building a distributor. - Trust note: the threshold is a published input, and a verifier checks consistency with it, not that it equals the announced policy. Signers must check the threshold against the documented value.
5.2 Epoch runner (packages/epoch) and mainnet scripts
pnpm epoch is a dry run by default: it reads the discovery state and read-only chain history, computes each token's next snapshot (dust policy, carry in and out, self-verified), and prints the distributor plan; --write saves docs/<net>-proof/epochs/<mint>/epoch-<n>.json (never overwrites a published epoch). It never signs or sends. The Dwell Pool per token is an explicit input (--pools); a token without one is skipped, never treated as zero. An event stamped exactly at the previous epoch end is not fetched again.
Money path per epoch, the one proven on devnet: (1) scripts/mainnet-publish-epoch.ts, signed by the payer and a throwaway base key, creates the wSOL distributor from the saved snapshot and passes its admin role to the Squads vault; (2) the same script writes the funding instructions (system transfer of exactly the tree total plus syncNative, checked by assertFundOnly, with a digest) for the vault members to propose and approve 2-of-3. Carried dust is not funded and stays in the vault as a liability. Every mainnet-capable script defaults to --simulate (RPC simulateTransaction, read-only) and needs --send plus the typed SOL cost; the launch route and scripts additionally need DWELL_NETWORK=mainnet and mainnet.config.json (see packages/launch/src/mainnet.ts and docs/MAINNET-RUNBOOK.md).
6. Public API (v1)
All responses are JSON, read only, no authentication, rate limited. No mock data: unknown values are null.
GET /api/v1/wallets/:wallet additionally returns (additive) carry, one row per token with a balance held back by the dust policy (mint, lamports, minClaimLamports, since, expiresAt, asOfEpoch), and carriedLamports on each position. The web portfolio shows it as pending, below the minimum claim.
GET /api/v1/tokens/:mint/dwell
{
"mint": "<base58>",
"epoch": 42,
"pool_sol_last_epoch": "2.7",
"scored_wallets": 4,
"total_score": "13500000",
"top_tenure_days": 14,
"next_epoch_at": "2026-10-14T00:00:00Z",
"root": "<hex>",
"updated_at": "2026-10-13T00:05:00Z"
}
(The values above are illustrative.)
Further endpoints (proposed):
GET /api/v1/tokens/:mint/epochs/:nreturns root, pool, snapshot URL.GET /api/v1/wallets/:address/claimsreturns claimable epochs and proofs.GET /api/v1/tokens/:mint/leaderboardreturns longest holders.
The API reports facts about past epochs and the current rules. It does not publish forecast payouts.
7. Testing and verification
- Unit tests for every scoring rule: sell reset, buy no reset, minimum balance within slice, transfer exclusion, creator exclusion, rounding.
- Property test: payouts plus remainder equal pool, always.
- Replay test: recompute historical devnet epochs from stored snapshots.
- Verifier CLI exit code nonzero on any mismatch.
8. Metrics, seasons, cards and the API (as built)
Packages: metrics (depth, distribution, Dwell Rate, sell simulator, seasons, badges), cards (Core Sample SVG and PNG), api (handler, file snapshot reader, Node server). Full request and response schemas are in docs/openapi.yaml, which supersedes the proposed shapes in section 6.
Dwell Depth (0 to 100, integer)
Computed over eligible holders at the end of the latest epoch (not excluded, no transfer in that epoch).
- 40 x min(median tenure / 14 days, 1)
- 25 x min(share of supply at max tenure / 50%, 1)
- 20 x (1 - top-10 share of supply)
- 15 x (1 - flip rate)
Flip rate is the fraction of eligible wallets that bought in the window and also sold or transferred out at least once. Depth is null when there are no holders. Tenure buckets are 0-1d, 1-3d, 3-7d, 7-14d and 14d+ (lower edge inclusive).
Dwell Rate
dwellRate is lamports of the latest epoch pool per 1000 tokens held per day, over eligible supply, scaled to 24 hours. It is the same unit for every token, which makes tokens comparable. It assumes 6 decimals (Meteora DBC default). The devnet proof epoch lasts 130 seconds, so its per-day figures are large; they are exact arithmetic on that snapshot and should not be read as a typical rate.
Sell simulator
Selling fraction f multiplies tenure by (1 - f) and scales balance by (1 - f). The 7 day pool-share loss is the difference between two day-by-day projections (with and without the sale), using the wallet's tenure at mid-day, everyone else's weighted holdings frozen at today's level, and the pool per day frozen at the latest epoch's rate. It is arithmetic on the current state, not a forecast. est* fields in the API follow the same rule.
Seasons and badges
- Seasons are 14 days, aligned to UTC midnight, and season 1 starts 2026-10-12T00:00:00Z.
- A dwell-day is time spent holding a non-zero position in one token while eligible; days sum across tokens and position size does not matter.
- Ranks by season dwell-days: Silt (above 0), Clay (7), Shale (28), Granite (84), Bedrock (196). Thresholds are tunable constants in
metrics/src/seasons.ts. - A badge is a record computed only from snapshots: season, wallet, rank, dwell seconds and the list of source epoch roots, hashed into a sha256 id.
transferableis always false andfinalis true only after the season ends. Nothing is minted.
Core Sample card
One layer per epoch the wallet held, oldest at the base (oxide), thickness proportional to time held, a tick where the wallet sold, plus tenure, multiplier, rank and balance. Colors come from brand/BRAND.md. Output is a pure function of the data (no clock, no randomness). PNG uses @resvg/resvg-js and system fonts.
API
Read only, GET, CORS open (access-control-allow-origin: *). Data is read from epoch snapshots through a one-method SnapshotStore; FileSnapshotStore reads snapshot*.json files and takes its network label from options or proof.json (it refuses to guess). A Postgres store can implement the same interface later. Missing data is a 404 with { "error": { "code", "message" } }. Every response has X-Dwell-Network, and object bodies carry network.
Run (repo root): pnpm --filter @dwell/api start, default http://127.0.0.1:8787, serving docs/devnet-proof as devnet. Environment: PORT, HOST, DWELL_SNAPSHOT_DIR.
9. Launch discovery and platform stats (as built)
Package discovery. Pools are found with getProgramAccounts on the DBC program, filtered by the VirtualPool discriminator at byte 0 and the config address at byte 72 (offsets checked against the pinned SDK IDL in test/layout.test.ts). The VirtualPool account survives migration, so graduated pools come out of the same query with is_migrated set (status graduated); the DAMM v2 pool is then derived from the migration fee configs and followed too. Volume is the SOL that changed hands including the fee, fees are trading + protocol + referral, both from the EvtSwap2 events in finalized transactions (the program also emits EvtSwap; it is used only when no EvtSwap2 is present, so nothing counts twice). For curve pools the sum is cross-checked against the pool account's own cumulative fee counters. createdAt is the block time of the pool's oldest successful transaction.
Scanning is incremental: the persisted state (FileStateStore) keeps each pool's newest processed signature, so a rerun fetches only newer transactions. A transaction that is listed but not yet available fails that pool's step without a partial write, and asOf only advances after a fully consistent refresh. RPC calls use the indexer's retry policy.
distributedPublished is the sum of claim amounts in rooted snapshots. distributedClaimed is the sum of total_amount_claimed on the epoch distributor accounts named in payouts.json and is null whenever a published epoch has no known distributor or an account is unreadable. API: GET /api/v1/tokens and GET /api/v1/stats (see docs/openapi.yaml), mounted through the handler's extraRoutes option; they answer 503 until discovery has produced a state file.