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:Effect: Increments
| 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 = holder’s proportional share of total_funded: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
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]
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 |
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 |
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 |
Merkle Tree Construction (Off-chain)
The merkle tree is built off-chain by the publish authority server every 10 minutes:- Scan all token accounts for the OT mint (
getParsedProgramAccounts). This includes regular wallets AND on-chain PDAs that hold OT: RWT Vault PDA (holds OT as portfolio backing), DEX Pool PDAs (pool vaults hold OT with pool PDA as authority), and OtTreasury PDA (may hold OT from governance). - Filter: only holders with ≥ $100 total protocol holdings
- Calculate cumulative_amount per holder:
total_funded * holder_balance / total_eligible_supply - Non-eligible share → ARL Treasury wallet as leaf (protocol revenue — Areal Finance earns yield from holders below $100 threshold)
- Build tree: leaf =
sha256(address_bytes || cumulative_amount_le_bytes) - Publish root on-chain via
publish_root
The contract only verifies proofs — it does not scan holders or compute weights. All tree construction is off-chain.