Yield Distribution
✅ Ready to Dev
This contract specification has passed business logic audit, technical review, cross-program integration check (OT + RWT Engine), vesting math verification, and naming standardization. A developer can implement from this document.
Key Concepts
11 Instructions
Config, create/close distributor, convert, fund, publish root, claim, authority management
4 State Accounts
DistributionConfig, MerkleDistributor, Accumulator, ClaimStatus
4 PDA Seeds
dist_config, merkle_dist, accumulator, claim_status
How It Works
OT Revenue Arrives (monthly)
OT contract
distribute_revenue sends 70% USDC to the distributor’s Accumulator PDA (USDC ATA owned by the contract). No external wallets needed.Convert USDC → RWT + Fund (atomic)
Crank calls
convert_to_rwt — contract swaps USDC on DEX up to NAV price, mints remainder at NAV, deposits RWT into reward vault, and updates vesting state. All in one atomic instruction. Previously vested amount is locked, new RWT begins vesting from now.Publish Merkle Root (every 10 min)
Publish authority (server wallet) scans OT holders off-chain, builds merkle tree, publishes root on-chain via
publish_root. Holders with < $100 holdings excluded; their share → ARL Treasury (protocol revenue).Vesting Model
Perpetual distributor with incremental funding. Bothconvert_to_rwt and fund_distributor use identical vesting logic when adding RWT:
UX: The UI calculates
total_vested client-side every second to show a real-time growing balance. The actual on-chain transfer happens only when the holder clicks “Claim”. This is the standard DeFi pattern (Jito, Marinade, etc.).**Minimum 100 total protocol holdings across all OT + RWT positions are eligible for yield. Non-eligible holders’ share is allocated to ARL Treasury (= ARL OtTreasury PDA
["ot_treasury", arl_ot_mint]) as a leaf in the merkle tree. This is protocol revenue — Areal Finance earns the yield that small holders don’t qualify for. Claimed via OT claim_yd_for_treasury on the ARL OT instance.Three Roles
🏛️ Config Authority
Team Multisig (after bootstrap)
create_distributor— new OT projectclose_distributor— retire projectupdate_config— change fees, limitsupdate_publish_authority— change server walletpropose_authority_transfer
📡 Publish Authority
Server wallet (crank bot on VPS)
publish_root— every 10 minutes
🌐 Permissionless
Any wallet / Crank bot
convert_to_rwt— USDC→RWT conversionfund_distributor— deposit RWTclaim— holder claims yield
Instructions
Initialization
initialize_config
initialize_config
Create global distribution configuration. Called once per protocol deployment.Parameters:
Caller: Deployer (one-time)Accounts:
| Parameter | Type | Description |
|---|---|---|
protocol_fee_bps | u16 | Fee on deposits (default: 25 = 0.25%) |
min_distribution_amount | u64 | Min fund amount (default: $100 equivalent) |
areal_fee_destination | Pubkey | Where fees go — Areal Finance RWT ATA (static, immutable). Note: this is an RWT token account, not USDC — YD protocol fee is deducted in RWT after conversion. |
publish_authority | Pubkey | Server wallet for publish_root |
deployer(signer, mut) — pays for account creationconfig(init) — PDA seed:["dist_config"]system_program
DistributionConfigPDA — global singleton
authority = deployer(transferred to Team Multisig after bootstrap)publish_authority = publish_authority paramis_active = true
Distributor Lifecycle
create_distributor
create_distributor
Create a perpetual distributor for an OT project. One per OT mint, lives forever. Also creates the Accumulator PDA where USDC revenue arrives.
Caller: Authority (Team Multisig)Accounts:
| Parameter | Type | Description |
|---|---|---|
vesting_period_secs | i64 | Vesting period (default: 31,536,000 = 365 days) |
authority(signer) — must matchconfig.authorityconfig— validates authority, is_activeot_mint— the OT this distributor servesdistributor(init) — PDA seed:["merkle_dist", ot_mint]reward_vault(init) — RWT token account, authority = distributor PDAaccumulator(init) — PDA seed:["accumulator", ot_mint]accumulator_usdc_ata(init) — USDC ATA, authority = accumulator PDArwt_mint— RWT token mintusdc_mint— USDC mint (for accumulator ATA)token_program,system_program,associated_token_program
MerkleDistributorPDA — perpetual distributor state- Reward Vault — RWT ATA owned by distributor PDA
AccumulatorPDA — receives USDC from OT revenue- Accumulator USDC ATA — USDC holding account
total_funded = 0,total_claimed = 0locked_vested = 0,last_fund_ts = nowmerkle_root = [0; 32],epoch = 0is_active = true
vesting_period_secs > 0
close_distributor
close_distributor
Close a distributor permanently. Sweeps all unclaimed RWT to a destination.Caller: Authority (Team Multisig)Accounts:
authority(signer) — must matchconfig.authorityconfigdistributor(mut)reward_vault(mut) — RWT being sweptunclaimed_destination(mut) — receives remaining RWT (typically ARL Treasury ATA)token_program
distributor.is_active == true(cannot close twice)
distributor.is_active = false. Emits DistributorClosed.Funding
convert_to_rwt
convert_to_rwt
Convert accumulated USDC to RWT. Two-step: swap on DEX up to NAV price, mint remainder at NAV via RWT Engine. Accumulator PDA signs all transfers — no external keypairs needed.
Caller: Permissionless (crank)Accounts:
| Parameter | Type | Description |
|---|---|---|
max_swap_amount | u64 | Max USDC to swap on DEX (rest minted at NAV) |
min_rwt_out | u64 | Minimum total RWT to receive (slippage protection against sandwich attacks on DEX swap) |
crank(signer, mut)config— reads protocol_fee_bps, areal_fee_destinationdistributor(mut) — updates locked_vested, total_funded, last_fund_tsaccumulator— PDA, signs USDC transfers and RWT transfer via seedsfee_account(mut) — constraint:key == config.areal_fee_destination(receives protocol fee in RWT)accumulator_usdc_ata(mut) — USDC source, constraint:owner == accumulator.key()accumulator_rwt_ata(mut) — intermediate RWT ATA, constraint:owner == accumulator.key(),mint == rwt_mint(receives RWT from DEX swap/RWT Engine mint, then transfers to reward_vault). Created by crank if needed viainit_if_needed.reward_vault(mut) — final destination for RWT- DEX accounts:
pool_state,dex_config,vault_in,vault_out,dao_fee_account dex_program— constraint:key == DEX_PROGRAM_ID- RWT Engine CPI accounts (must match
mint_rwtinstruction layout exactly):rwt_vault(mut) — RWT Engine vault PDA["rwt_vault"], receives USDC depositrwt_mint(mut) — RWT token mint, authority = rwt_vault PDAcapital_acc(mut) — USDC ATA owned by rwt_vault (vault.capital_accumulator_ata)rwt_engine_fee_account(mut) —vault.areal_fee_destination(receives 0.5% mint fee)- Accumulator PDA signs as
user(depositor) in mint_rwt CPI
rwt_engine_program— constraint:key == RWT_ENGINE_PROGRAM_IDtoken_program
- Read accumulator USDC balance. If 0, return.
- Calculate DEX pool price vs RWT NAV
- If pool price < NAV: CPI swap
min(max_swap_amount, balance)USDC → RWT on DEX - If USDC remains: CPI to
rwt_engine::mint_rwtat NAV (accumulator PDA signs as user) - Fallback path: If no DEX pool exists or pool has zero liquidity: mint ALL at NAV via RWT Engine. In this case, entire USDC volume bypasses DEX — no LP fees generated, no swap volume. This is expected during bootstrap (before pools exist) but should not occur in normal operation.
- Calculate protocol fee on acquired RWT:
fee = rwt_acquired * protocol_fee_bps / 10,000 - Transfer
feeRWT → fee_account (accumulator PDA signs) - Transfer
rwt_acquired - fee→ reward_vault (accumulator PDA signs) - Lock vesting:
locked_vested += (total_funded - locked_vested) * min(elapsed, period) / period(cast to u128) total_funded += rwt_acquired - feelast_fund_ts = now- Validate:
rwt_acquired >= min_rwt_out(slippage check — reverts if sandwich attack caused unfavorable swap) - Emit
StreamConverted
fund_distributor call needed for converted funds.This instruction combines convert + fund into one atomic operation. The Accumulator PDA signs both the USDC swap/mint and the RWT transfer to reward vault. No intermediate state where RWT “floats” between accounts.
fund_distributor
fund_distributor
Deposit RWT into distributor’s reward vault. Locks previously vested amount and starts vesting new portion from now.
Caller: Permissionless — anyone can fundAccounts:
| Parameter | Type | Description |
|---|---|---|
amount | u64 | RWT amount to deposit |
depositor(signer)config— validates is_activedistributor(mut) — updates locked_vested, total_funded, last_fund_tsreward_vault(mut) — receives RWTdepositor_token(mut) — source RWT ATA, constraint:owner == depositorfee_account(mut) — constraint:key == config.areal_fee_destinationtoken_program
amount > 0amount >= config.min_distribution_amount(default $100 equivalent)distributor.is_active == true
- Calculate protocol fee:
fee = amount * protocol_fee_bps / 10,000 - Transfer fee → fee_account
- Transfer
amount - fee→ reward_vault - Lock vesting:
locked_vested += (total_funded - locked_vested) * min(elapsed, period) / period(cast to u128 before multiply) total_funded += amount - feelast_fund_ts = now- Emit
DistributorFunded
The locked_vested calculation preserves already-earned yield when new funds arrive. Without this, adding funds would dilute vesting progress of existing deposits.
Distribution
publish_root
publish_root
Publish a new merkle root representing current OT holder weights. Called every 10 minutes by the publish authority (server wallet).
Caller: Publish authority (server wallet)Accounts:Holders absent from
| Parameter | Type | Description |
|---|---|---|
merkle_root | [u8; 32] | Root hash of the merkle tree |
max_total_claim | u64 | Must equal current total_funded |
publish_authority(signer) — must matchconfig.publish_authorityconfig— validates publish_authority, is_activedistributor(mut) — updates merkle_root, epoch
max_total_claim > 0(prevents division by zero inclaim— cannot publish root for unfunded distributor)max_total_claim == distributor.total_fundedmax_total_claim >= distributor.total_claimed
sha256(claimant_pubkey_bytes || cumulative_amount_le_bytes)Where cumulative_amount is computed off-chain via per-deposit snapshot aggregation (see Merkle Tree Construction):snapshot_i contribute 0 for deposit_i. Invariant: Σ cumulative_amount[h] == total_funded.Effect: Increments epoch, stores new root. Emits RootPublished.Recommended frequency: Every 10 minutes (off-chain crank). Ensures holder balance changes reflected within 10 min. Must also be called immediately after each fund_distributor to make new funds claimable. Cost: ~$2.60/month per OT project.claim
claim
Claim vested RWT rewards. Can be called anytime, as often as desired. Vesting accrues every second.
Caller: Holder (or PDA via CPI — e.g., RWT Vault, ARL Treasury)Accounts:
| Parameter | Type | Description |
|---|---|---|
cumulative_amount | u64 | Holder’s cumulative share (from merkle leaf) |
proof | Vec<[u8; 32]> | Merkle proof path (max 20 nodes) |
claimant(signer) — holder wallet or PDApayer(signer, mut) — pays rent for ClaimStatus init on first claimdistributor— reads vesting state, merkle_rootclaim_status(init_if_needed) — PDA seed:["claim_status", distributor, claimant]reward_vault(mut) — source of RWTclaimant_token(mut) — holder’s RWT ATA, constraint:mint == reward_vault.minttoken_program,system_program
distributor.is_active == true(cannot claim from closed distributor)- Merkle proof:
verify(proof, root, sha256(claimant || cumulative_amount)) proof.len() <= MAX_PROOF_LEN(20)epoch > 0(root must be published)
- Verify merkle proof
- Initialize ClaimStatus if first claim (claimant, distributor, claimed_amount=0)
- Calculate total_vested (cast to u128 before multiply, same as fund logic):
- Cap at published root:
total_vested = min(total_vested, max_total_claim)(prevents over-claiming if fund happened after last publish_root) - Minimum vested:
total_vested = max(total_vested, min(MIN_VESTED_AMOUNT, max_total_claim)) - Personal share:
my_vested = (total_vested as u128) * cumulative_amount / max_total_claim claimable = my_vested - claim_status.claimed_amount- If claimable == 0, return
- Transfer
claimableRWT: reward_vault → claimant_token (distributor PDA signs) claim_status.claimed_amount += claimabledistributor.total_claimed += claimable- Emit
RewardsClaimed
Liquidity Routing
The 15% RWT liquidity slice produced byrwt_engine::claim_yield is staged in a contract-owned LiquidityHolding PDA before being atomically drained into the DEX LiquidityNexus token account. Yield Distribution exposes one new instruction for this flow.
| Instruction | Caller | Purpose |
|---|---|---|
withdraw_liquidity_holding | Authority (Team Multisig) | Atomic drain — moves RWT from LiquidityHolding RWT ATA into the Nexus RWT ATA, with a CPI to native_dex::nexus_record_deposit that updates the Nexus principal floor in the same instruction |
ownership_token::distribute_revenue— sends OT-revenue USDC into the YD per-OT Accumulatoryield_distribution::convert_to_rwt— converts Accumulator USDC into RWT, deposits RWT into the reward vault, updates vesting state (documented in the Funding section above)rwt_engine::claim_yield— RWT Engine claims its share via the standard YDclaimflow; the 15% liquidity slice lands in theLiquidityHoldingRWT ATA (see RWT Engine contract)ownership_token::claim_yd_for_treasury— OT Treasury PDA claims its share of any distributor (cross-project yield)
withdraw_liquidity_holding
withdraw_liquidity_holding
Atomic drain from
Caller: Authority (Team Multisig)Accounts:
LiquidityHolding RWT ATA into the DEX Nexus RWT ATA, with principal-floor update via CPI to native_dex::nexus_record_deposit. Authority-gated.| Parameter | Type | Description |
|---|---|---|
amount | u64 | RWT to drain (must be ≤ holding balance) |
authority(signer) — must matchconfig.authorityconfig— authority and pause gateliquidity_holding(mut) — incrementstotal_drainedliquidity_holding_rwt_ata(mut) — source ATA, owner =LiquidityHoldingPDAnexus_token_ata(mut) — destination ATA, owner =LiquidityNexusPDA on the DEX sideliquidity_nexus(mut) — DEX Nexus PDA, principal floor updated via CPIdex_program— DEX program account, validatedexecutable()token_programsystem_program
amount > 0amount ≤ liquidity_holding_rwt_ata.balancenexus_token_ata.mint == RWT_MINT(defence-in-depth)nexus_token_ata.owner == liquidity_nexusPDAdex_program.address() == DEX_PROGRAM_ID
- SPL Transfer
amountfromliquidity_holding_rwt_ata→nexus_token_ata(LiquidityHoldingPDA signs). - CPI to
native_dex::nexus_record_deposit(amount, token_kind=RWT)(LiquidityHoldingPDA signs). - Increment
liquidity_holding.total_drainedbyamount. - Emit
LiquidityHoldingWithdrawn { amount, total_drained, timestamp }.
Atomic guarantee. Either both legs succeed (RWT transfer AND principal-floor update) or both revert. There is no intermediate state in which the on-chain Nexus balance and
total_deposited_rwt disagree on this lane.Configuration & Authority
update_config
update_config
Update global distribution configuration.
Caller: Authority (Team Multisig)Accounts:
| Parameter | Type | Description |
|---|---|---|
protocol_fee_bps | u16 | New fee BPS |
min_distribution_amount | u64 | New minimum |
is_active | bool | Global active flag |
authority(signer) — must matchconfig.authorityconfig(mut)
ConfigUpdated.areal_fee_destination is immutable — set at init, cannot be changed. Only a program upgrade can modify it.update_publish_authority
update_publish_authority
propose_authority_transfer
propose_authority_transfer
accept_authority_transfer
accept_authority_transfer
State Accounts
DistributionConfig
Global singleton. One per protocol deployment.| Field | Type | Description |
|---|---|---|
authority | Pubkey | Config authority (Team Multisig after bootstrap) |
pending_authority | Option<Pubkey> | Pending authority transfer target |
publish_authority | Pubkey | Server wallet for publish_root (changeable by authority) |
protocol_fee_bps | u16 | Fee on fund_distributor (default: 25 = 0.25%) |
min_distribution_amount | u64 | Minimum fund amount |
areal_fee_destination | Pubkey | Areal Finance RWT ATA — receives protocol fee in RWT (static, immutable) |
is_active | bool | Global kill switch |
bump | u8 | PDA bump seed |
["dist_config"]
MerkleDistributor
One per OT project, perpetual (never recreated).| Field | Type | Description |
|---|---|---|
ot_mint | Pubkey | The OT this distributor serves |
reward_vault | Pubkey | RWT token account (authority = distributor PDA) |
accumulator | Pubkey | Accumulator PDA for this OT (USDC arrives here) |
merkle_root | [u8; 32] | Current merkle root of holder weights |
max_total_claim | u64 | Equal to total_funded (safety cap) |
total_claimed | u64 | Cumulative RWT claimed across all holders |
total_funded | u64 | Cumulative RWT deposited (grows with each fund) |
locked_vested | u64 | RWT guaranteed vested (locked at each fund) |
last_fund_ts | i64 | Timestamp of last fund_distributor call |
vesting_period_secs | i64 | Vesting duration (default: 365 days) |
epoch | u64 | publish_root counter |
is_active | bool | Active flag (false after close_distributor) |
bump | u8 | PDA bump seed |
["merkle_dist", ot_mint]
No
stream_id in seed — one distributor per OT mint. This is the key simplification vs multi-stream architecture.Accumulator
Per-OT USDC receiving account. Owned by the contract — no external keypairs. OTdistribute_revenue sends USDC here. convert_to_rwt spends from here.
| Field | Type | Description |
|---|---|---|
ot_mint | Pubkey | The OT this accumulator serves |
bump | u8 | PDA bump seed |
["accumulator", ot_mint]
Accumulator does not store USDC balance in state. Balance is read from the USDC ATA owned by this PDA. The PDA signs USDC transfers in
convert_to_rwt via seeds.ClaimStatus
Per (distributor, claimant) tracking. Created on first claim viainit_if_needed.
| Field | Type | Description |
|---|---|---|
claimant | Pubkey | Holder wallet or PDA |
distributor | Pubkey | Which distributor this tracks |
claimed_amount | u64 | Cumulative RWT claimed |
bump | u8 | PDA bump seed |
["claim_status", distributor, claimant]
LiquidityHolding
Singleton PDA that stages RWT received from the 15% liquidity slice ofrwt_engine::claim_yield before it is atomically drained into the DEX LiquidityNexus. The PDA owns a single RWT ATA (liquidity_holding_rwt_ata) which is the staging account; the drain ix transfers RWT to the Nexus and updates the Nexus principal floor via CPI in the same transaction.
| Field | Type | Description |
|---|---|---|
total_received | u64 | Lifetime RWT received from claim_yield (running counter) |
total_drained | u64 | Lifetime RWT drained into the Nexus |
bump | u8 | PDA bump seed |
["liq_holding"]
Owned ATA: the LiquidityHolding PDA owns a single RWT ATA (liquidity_holding_rwt_ata) which is the per-epoch staging account.
This account exists to make the RWT-into-Nexus path atomic with principal-floor accounting. Without it, RWT would have to first land in a regular wallet’s ATA, then a separate transaction would call DEX
nexus_deposit. With LiquidityHolding, the entire flow is a single instruction (withdraw_liquidity_holding) that bundles the SPL transfer with the principal-floor update via CPI.PDA Seeds
| Account | Seeds | Description |
|---|---|---|
| DistributionConfig | "dist_config" | Global singleton config |
| MerkleDistributor | "merkle_dist", ot_mint | Per-OT perpetual distributor |
| Accumulator | "accumulator", ot_mint | Per-OT USDC receiver (contract-owned) |
| ClaimStatus | "claim_status", distributor, claimant | Per-holder claim tracking |
| LiquidityHolding | "liq_holding" | Singleton — RWT staging for the Nexus drain |
Constants
| Constant | Value | Description |
|---|---|---|
BPS_DENOMINATOR | 10,000 | 100% in basis points |
DEFAULT_PROTOCOL_FEE_BPS | 25 | 0.25% fee on fund_distributor |
DEFAULT_MIN_DISTRIBUTION | 100,000,000 | $100 equivalent (6 decimals) |
DEFAULT_VESTING_PERIOD | 31,536,000 | 365 days in seconds |
MAX_PROOF_LEN | 20 | Max merkle proof depth (~1M holders) |
MIN_VESTED_AMOUNT | 1,000,000 | 1 RWT minimum vested (prevents stuck at zero) |
RWT_ENGINE_PROGRAM_ID | hardcoded | Validated in convert_to_rwt |
DEX_PROGRAM_ID | hardcoded | Validated in convert_to_rwt |
Events
| Event | Fields | When |
|---|---|---|
ConfigInitialized | authority, publish_authority, protocol_fee_bps, timestamp | Config created |
DistributorCreated | ot_mint, reward_vault, accumulator, vesting_period_secs, timestamp | Distributor created |
DistributorFunded | ot_mint, amount, protocol_fee, total_funded, locked_vested, timestamp | RWT deposited |
StreamConverted | ot_mint, usdc_swapped, rwt_minted, total_rwt, timestamp | USDC→RWT conversion |
RootPublished | ot_mint, epoch, merkle_root, max_total_claim, timestamp | New root published |
RewardsClaimed | claimant, ot_mint, amount, cumulative_claimed, timestamp | Holder claimed RWT |
ConfigUpdated | protocol_fee_bps, min_distribution_amount, is_active, timestamp | Config changed |
PublishAuthorityUpdated | old_publish_authority, new_publish_authority, timestamp | Publish wallet changed |
AuthorityTransferProposed | current_authority, pending_authority, timestamp | Transfer proposed |
AuthorityTransferAccepted | old_authority, new_authority, timestamp | Transfer accepted |
DistributorClosed | ot_mint, unclaimed_swept, timestamp | Distributor retired |
LiquidityHoldingWithdrawn | amount, total_drained, timestamp | RWT drained from LiquidityHolding into the DEX Nexus RWT ATA via withdraw_liquidity_holding |
Error Codes
| Error | Description |
|---|---|
Unauthorized | Signer is not the required authority |
SystemPaused | Global config is_active = false |
DistributorNotActive | Distributor is closed |
ZeroAmount | Amount must be > 0 |
BelowMinDistribution | Fund amount below min_distribution_amount |
RootNotPublished | Cannot claim before first publish_root (epoch = 0) |
InvalidProof | Merkle proof verification failed |
NothingToClaim | Claimable amount = 0 |
ProofTooLong | Proof exceeds MAX_PROOF_LEN (20) |
ExceedsMaxClaim | total_claimed would exceed max_total_claim |
SlippageExceeded | rwt_acquired < min_rwt_out (sandwich protection) |
MathOverflow | Arithmetic overflow |
InvalidVestingPeriod | vesting_period_secs must be > 0 |
SelfTransfer | Cannot transfer authority to yourself |
NoPendingAuthority | No pending transfer to accept |
InvalidPendingAuthority | Signer ≠ pending_authority |
Architecture & Integration Guide
Cross-Program Integration
← Ownership Token (receives 70% revenue as USDC)
← Ownership Token (receives 70% revenue as USDC)
- OT
distribute_revenuesends 70% USDC to Accumulator PDA’s USDC ATA - Accumulator PDA address is derived:
["accumulator", ot_mint] - OT destination config points to this ATA
- Flow: OT revenue → Accumulator USDC ATA →
convert_to_rwt(atomic: convert USDC→RWT + deposit to vault)
→ RWT Engine (convert USDC → RWT)
→ RWT Engine (convert USDC → RWT)
convert_to_rwtCPIs tonative_dex::swap(buy RWT below NAV)- If USDC remains: CPIs to
rwt_engine::mint_rwt(mint at NAV) - Accumulator PDA signs as user/depositor
- Uses raw
invoke_signedfor RWT Engine CPI (circular dependency workaround)
→ RWT Engine (vault claims yield)
→ RWT Engine (vault claims yield)
- RWT Vault is an OT holder → has a leaf in merkle tree
- RWT Engine
claim_yieldCPIs toyield_distribution::claim - Vault PDA signs as claimant
→ Ownership Token (ARL Treasury claims non-eligible share)
→ Ownership Token (ARL Treasury claims non-eligible share)
→ Team Multisig (config authority)
→ Team Multisig (config authority)
Authority Model
🔧 Program Upgrade
Team Multisig (Squads)Can deploy new contract versions.
🏛️ Config Authority
Team Multisig (after bootstrap)
create_distributor/close_distributorupdate_configupdate_publish_authoritypropose_authority_transfer
📡 Publish Authority
Server wallet (VPS crank bot)
publish_root(every 10 min)
Deployment Checklist
Prerequisites: RWT Engine and Native DEX must be deployed (needed for convert_to_rwt CPI).- Call
initialize_configwith areal_fee_destination, publish_authority (server wallet) - Create distributor for each OT project via
create_distributor(also creates Accumulator PDA) - Configure OT revenue destination to point to Accumulator USDC ATA
- Transfer config authority to Team Multisig via
propose_authority_transfer+accept_authority_transfer - Start crank bot — publish_root every 10 minutes, convert_to_rwt after each OT revenue distribution
Token Flow Summary
| From | To | Mechanism | Who triggers |
|---|---|---|---|
| OT Revenue (USDC) | Accumulator USDC ATA | OT distribute_revenue | Crank |
| Accumulator USDC | Reward vault RWT (via DEX + mint) | convert_to_rwt (atomic: convert + fund) | Crank |
| External RWT | Reward vault | fund_distributor (direct RWT deposit) | Anyone |
| Reward vault | Holder RWT ATA | claim (per-claimant vesting) | Holder |
| Reward vault | RWT Vault ATA | claim (via RWT Engine CPI) | Crank |
| Reward vault | ARL OtTreasury RWT ATA | claim (via OT claim_yd_for_treasury on ARL instance) | Crank |
| Reward vault | Destination (on close) | close_distributor | Authority |
| LiquidityHolding RWT ATA | Nexus RWT ATA | withdraw_liquidity_holding (atomic: SPL transfer + CPI floor update) | Authority |
Merkle Tree Construction (Off-chain)
The merkle tree is built off-chain by the publish authority server. The algorithm uses per-deposit snapshots to ensure fair yield distribution — each deposit is allocated only to holders who held OT at the time of that deposit. This prevents late buyers from capturing historical yield.Per-deposit snapshot algorithm
On each fund event (emitted byfund_distributor or convert_to_rwt):
- The bot listens for
DistributorFunded/StreamConvertedevents on-chain. - At the slot of the fund event, the bot snapshots all OT holder balances via
getProgramAccounts. This requires an archival RPC tier (e.g. Helius / Triton), since free-tier public RPC does not retain old program accounts. - The snapshot includes: regular wallets, RWT Vault PDA (holds OT as portfolio backing), DEX Pool PDAs (pool vaults hold OT with the pool PDA as authority), and OtTreasury PDA (may hold OT received from governance).
- Filter applied per snapshot: only holders with ≥ $100 total protocol holdings at that snapshot’s slot.
- The non-eligible share (< $100) for that snapshot is allocated to the ARL OtTreasury PDA leaf — protocol revenue.
- The snapshot is persisted:
{distributor, deposit_epoch, slot, balances: {holder → balance}, total_eligible}.
publish_root (every 10 minutes):
- For each holder that appeared in any snapshot, compute:
Holders absent from
snapshot_icontribute 0 fordeposit_i. - Verify the invariant
Σ cumulative_amount[h] == total_funded. Any integer-rounding remainder (bounded byN_deposits) is added to the ARL OtTreasury leaf. - Build the merkle tree: leaf =
sha256(address_bytes || cumulative_amount_le_bytes)with canonical ordering (lower hash first). - Publish the root on-chain via
publish_rootwithmax_total_claim = total_funded.
The on-chain invariants
max_total_claim == total_funded and total_claimed ≤ max_total_claim hold regardless of how cumulative_amount is computed off-chain — the contract verifies only the proof, not how the cumulative was derived. Switching algorithms (e.g. naive → per-deposit → TWAB) is an off-chain concern; no contract redeploy is needed.Publisher infrastructure
- Publisher key. Stored in an HSM or a managed KMS (AWS KMS / Google Cloud KMS / hardware wallet). The bot signs via the KMS API; the private key is never loaded onto disk or into process memory. On-disk keypair files are not acceptable for mainnet.
- Archival RPC. Required to snapshot balances at historical slots. Cost: ~$50 / month per OT project (Helius / Triton tier).
- Snapshot storage. ~40 MB per snapshot × 12 deposits / year ≈ 500 MB / year per distributor. SQLite / RocksDB / JSONL are all acceptable.
- Independent verifiers (strongly recommended). Two or more separate services — team-run and/or community-run — independently compute the root and post its hash to Discord, Telegram, or an on-chain log. A mismatch triggers an alert and rotation of
publish_authorityviaupdate_publish_authority.
Operational parameters
- Publish frequency: every 10 minutes. Mainnet TX cost: ~$2.60 / month per OT project.
- Compute: linear in holders × deposits. Approximately 1 second of CPU for 1M holders × 12 deposits on commodity hardware.
- Scale ceiling: linear; up to 100 deposits / year per distributor without algorithmic redesign.
The contract only verifies proofs — it does not scan holders or compute weights. All tree construction and snapshot management happen off-chain.
See also
- Liquidity Nexus — subsystem-level overview of the protocol-owned LP and its principal-lock invariant
- RWT Engine contract —
claim_yield70 / 15 / 15 split; the 15% liquidity slice lands in theLiquidityHoldingRWT ATA defined here