RFP ID
RFP-017 — Privacy-Preserving Token Vesting
Your Project Name
Moira — Privacy-Preserving Token Vesting for LEZ
From the Greek μοῖρα: an allotted portion or share, released in due measure.
Team or Organization Name
Equilibrium. We build and verify security-critical blockchain protocols, bridges, cryptographic systems, and node software, delivering complete modules with one organization accountable from design through handover.
Primary Contact
Olli Tiainen — olli@equilibrium.co
Team Members
The work will be led by Equilibrium engineers with production SVM/Solana and Rust experience, supported by our TypeScript, front-end, and ZK teams.
- Diogo Friggo, Senior Protocol Engineer. Status: lead; contractor. GitHub diogofriggo · Discord diogofriggo. Wrote Equilibrium's accepted RFP-020 (Kanon) proposal and is delivering it on the same LEZ / SPEL / RISC0 stack; Kanon and Moira run in sequence, not in parallel, so his work here begins on Kanon's completion. Also an EigenDA integration for Sovereign SDK (which supports RISC0 as a proving backend) and Solana P2P protocol testing across the Agave and Firedancer clients (public gossip spec: equilibriumco/solana-spec). Role: LEZ program / SPEL / RISC0 guest engineering.
- Jos Dehaes, Senior Engineer. Status: contractor. GitHub joske. Rust systems engineer, contributor to the Move Prover mutation-testing tooling (Aptos Foundation; see Relevant Experience) and Equilibrium's Move → PolkaVM compiler toolchain. Role:
vesting-core, state-machine testing, and the §4 invariants.
- Stoyan Kirov, Senior Engineer. Status: contractor. GitHub ctoyan. TypeScript, React Native, and wallet-SDK engineer who implemented Tether WDK support for Movement, including Mosaic swap and Echelon lending integrations. Role: TypeScript SDK and Basecamp mini-app lead.
These are Equilibrium staff assigned to the engagement for its duration, not contributors fitting it around other commitments. The Rust engineers above will own the IDL-based CLI.
Project Summary
Moira is the vesting component needed by the RFP-015/016 launchpads. It supports cliff+linear, fully linear, and milestone schedules, with cancellation, beneficiary transfers, idempotent milestone signaling, and atomic batch creation.
Claims can settle to a public account or, subject to the M0 runtime check in §5, directly to a beneficiary-selected private account. Schedules, beneficiaries, claim amounts, and claim times remain public: a private claim hides only the receiving account and its later activity, not the schedule or the beneficiary's association with it.
Moira uses LEZ private accounts directly, without a separate privacy network. Users still rely on LEZ, the Moira program and clients, and the configured admin. This deliberately narrow privacy boundary keeps the program composable with launchpads, treasuries, and grant programs.
Current dependencies
Testnet 0.2 went live on 30 June 2026, LEZ v0.2.4 followed on 7 August, and mainnet is targeted for early 2027 [3]. The Lambda Prizes are closed and the on-chain clock is live, but we read the runtime and canonical lez-programs sources rather than relying on prize status. LP-0013 delivers mint authority — "Token program improvements: mint authorities", as the RFP's own Resources section names it — which is not what a vesting escrow needs. The vault-debit primitive is not a token-program feature at all: it is the runtime's PDA authorization for chained calls, and canonical programs/amm already uses it. The one genuine gap is LP-0012: no event, log, or receipt mechanism exists in the runtime sources at all.
Every runtime claim here was read from LEZ v0.2.4 (commit 47eba25), the release we target, and cited to a path in that tree so a reviewer can check it. Where a later release changes one, the M0 spike that reproduces it is where it surfaces.
| Dependency |
Status |
Effect on Moira |
| LP-0013 — token authorities (the RFP's hard dependency) |
Closed; delivers a mint-authority model only |
Not what a vesting escrow needs, and not the blocker the RFP expected: escrow debit comes from runtime PDA authorization instead (see below) |
| LP-0015 — cross-program tail calls |
Closed and delivered |
Supports atomic debit → transfer → state-write claims |
| LP-0012 — structured events |
Closed; absent from runtime sources |
No event, log, or receipt mechanism exists under lee/. EventPage is development-only; public deployment requires the canonical mechanism or written Logos acceptance of an amended F6 (§11) |
| LP-0014 — ATAs and wallet tooling |
Available |
Supports public-account claims |
| Vault-debit authority |
Confirmed in runtime sources |
The caller presents the vault's PDA seed on a chained call and the runtime marks that account authorized for the callee; programs/amm swap debits its vault this way (lez/programs/amm/src/swap.rs). M0 reproduces it for Moira's escrow (§5) |
| On-chain clock |
Delivered |
All schedule types are buildable; §3 isolates the pending SPEL ClockContext API |
All three schedule types are buildable now. M0 reproduces the vault-authority path on the pinned release before end-to-end testnet claims; §5 gives the contingency if that release differs from the sources. Milestone vesting goes first because it is independent of custody integration. Delivery then continues through testnet 0.2, testnet 0.3, and mainnet as required.
Technical Approach
1. Stack and build model
Moira is written in Rust using SPEL (LEZ's Anchor-equivalent) and compiled to RISC0 guest binaries (riscv32im-risc0-zkvm-elf). Pure state transitions run in the guest; clients, IDL generation, and integration tests are host code. The repository follows the logos-co/scaffold and lgs toolchain and is tested against the LEZ runtime and the lez-payment-streams, lez-multisig, and SPEL reference programs. We target SPEL v0.6.0, which includes the breaking LEZ v0.2.0 migration and restores clean RISC-V guest builds [3]; M0 reproduces that build with Moira's dependency graph. All code is dual-licensed MIT + Apache-2.0.
vesting/
vesting-core/ # no_std: UnlockPolicy, unlock math, claim/cancel
# state transitions (machine-checked)
methods/guest/ # RISC0 guest binary
vesting-program/ # SPEL program: handlers, PDA derivation
vesting-idl/ # SPEL IDL + generated bindings
vesting-events/ # versioned event schema + Rust/TS decoders
vesting-indexer/ # event ingest + query by beneficiary / creator
vesting-sdk/ # TypeScript SDK: lifecycle + private-target checks
vesting-cli/ # Rust CLI generated from the IDL
vesting-app/ # Basecamp mini-app (Recipient + Creator views)
examples/ # runnable scripts against the local sequencer
2. State and account model
Each position is a PDA-addressed record keyed by position_id = (creator, position_index). Operations touch a fixed account set, so cost does not grow with the number of positions.
| Account |
Seeds |
Holds |
VestingPosition |
["position", creator, position_index] |
the two regions below |
Escrow |
["escrow", position_id] |
token-program account controlled by the vesting PDA |
EventPage |
["events", subject_id, page] |
versioned development-fallback event records, monotonic sequence numbers |
Settings |
["settings"] |
admin authority, fee switch state, fee recipient |
Settings is created once by an initialize instruction, before any schedule can be created, and rejects re-initialization. Who may call it first is the security question, and requiring the proposed admin to sign does not answer it — an attacker simply initializes themselves first. LEZ deployment carries only bytecode, with no deployer authority the runtime could authenticate, so we bind the admin to the program image: the initial admin and treasury are compile-time constants in the guest, and initialize ignores caller-supplied values. The pinned token program id (§5) is a constant on the same basis and deliberately not a Settings field, so no admin action can repoint custody. Since a ProgramId is the RISC0 image id of the bytecode (lee/state_machine/src/program/mod.rs), program identity commits to the admin, verifiable by anyone reproducing the build from the §11 bundle. Whoever wins the race to initialize therefore has no influence on the outcome; rotation afterwards runs through the §10 admin trait, and a negative test asserts a competing first caller can choose neither admin nor treasury.
Accounts are permanent, and we say so rather than promising otherwise. LEZ has no rent to reclaim — Account is { program_owner, balance, data, nonce } — the token instruction set has no close operation, and the execution rules forbid both changing an account's program_owner and returning an initialized account to the default owner (lee/state_machine/core/src/program/mod.rs). A fully claimed position and its empty escrow therefore remain in state. Nothing recoverable is stranded, so this costs storage rather than value. If Logos later adds a close primitive we will adopt it; any such design also needs a surplus policy, since an escrow anyone can dust would never satisfy an empty-balance close guard.
Each position separates terms fixed at creation from its lifecycle ledger. Handlers read terms through immutable accessors and update the ledger only via checked transitions.
pub struct VestingPosition {
layout: u8,
terms: PositionTerms,
ledger: Ledger,
}
pub struct PositionTerms {
token: AccountId,
creator: AccountId,
granted: u128,
unlock: UnlockPolicy,
reassignable: bool,
cancel_authority: Option<AccountId>,
milestone_signer: Option<AccountId>,
fee_bps: u16,
broker: Option<Broker>,
opened_at: u64,
}
pub struct Ledger {
beneficiary: AccountId,
withdrawn: u128,
cancellation: Cancellation,
time_high_water: u64,
event_sequence: u64,
}
pub enum Cancellation {
Allowed,
Waived,
Executed { at: u64, unlocked: u128, returned: u128 },
}
pub enum UnlockPolicy {
CliffThenLinear { begins: u64, cliff_at: u64, ends: u64 },
Continuous { begins: u64, ends: u64 },
Tranched { tranches: Vec<Tranche>, released: u64 },
}
pub struct Tranche { pub amount: u128 }
pub struct Broker { pub recipient: AccountId, pub fee_bps: u16 }
Cancellation permits only Allowed → Waived and Allowed → Executed. Cancellation snapshots total unlocked value before returning funds; storing both unlocked and returned lets §4 express conservation after cancellation using immutable values for time-based and milestone schedules alike. released is a 64-bit tranche bitmap, so MAX_TRANCHES ≤ 64; human-readable labels live in the creation event rather than state.
The current recipient is the ledger's beneficiary field, so a transfer rewrites one field and the position keeps its address; no second account is closed and reopened. We deliberately add no per-position marker account for beneficiary lookup: it would answer only queries that already supply position_id — which can read the ledger directly — while costing a third account per recipient in batch_create and reducing the maximum batch size.
Discovery needs a reader, and we ship one. Events are the discovery channel the RFP's LP-0012 rationale intends, but an event alone does not answer "which positions belong to me": the development EventPage is keyed by a subject_id the caller must already know, so it cannot enumerate unknown positions. vesting-indexer therefore ships as part of this work — a small service that ingests ScheduleCreated, BeneficiaryTransferred, Claimed, Canceled and TrancheReleased and exposes queries by beneficiary and by creator, exactly the two lists U2's Recipient and Creator views need. It reads canonical receipt events where they exist and EventPage PDAs in local development behind one interface, so the mini-app and SDK do not change when the canonical mechanism lands. It is a read-only projection holding no keys: losing it costs discovery convenience, never funds or claimability. We deliver the service and a runnable local deployment, not a hosted production service — operating one for a public network sits with Logos or an integrator and is outside this budget. It lands in M2, ahead of the M3 mini-app that consumes it.
How it bootstraps. Reading EventPage PDAs cannot be the discovery step, since their addresses derive from a subject_id the reader does not yet know. The indexer follows the chain, not the accounts: it walks blocks by id using the sequencer's get_latest_block_id and get_block_range — the cursor-and-poll shape lez/wallet/src/poller.rs already uses — and filters each block for Moira invocations. A creation transaction is where a previously unknown position_id first becomes visible; from there its EventPage addresses are derivable and payloads decodable. State is a persisted cursor, so restart resumes rather than rescans, and a backfill is the same walk from the deployment block recorded in the §11 bundle. Ingestion is idempotent per (subject_id, sequence), and monotonic sequence numbers let it detect a gap and re-fetch instead of serving incomplete history. When the canonical mechanism lands, only the payload-read step changes.
position_index allocation. The index is creator-scoped and client-supplied, so concurrent clients under one creator can pick the same value. Rather than a shared counter account, which would serialise every creation by that creator and burn a chained call, creation is allowed to collide and fail: the position PDA already exists, the runtime rejects the transaction, and no partial state is written. The index is an opaque u64, not a dense counter, so the SDK's default allocation is a random draw — collision probability is negligible and no coordination is needed. A creator wanting dense indices can opt into sequential allocation above the highest index the indexer has seen; that path can genuinely collide, so it retries with bounded exponential backoff and surfaces a typed error rather than looping. batch_create allocates its whole range in one draw under either scheme.
3. Time-source isolation
Cliff+linear and fully linear schedules read elapsed time from the delivered LEZ clock program. SPEL's ClockContext accessor is still an open PR (#227), so all reads pass through a small TimeSource trait:
trait TimeSource { fn now(&self) -> Result<u64, ClockUnavailable>; }
This keeps the unlock math deterministic in tests and limits future clock API changes to one implementation, leaving state, events, and the SDK untouched.
The clock is an input account, and the account is pinned. LEZ's clock keeps three accounts refreshed every 1, 10 and 50 blocks, so a timestamp is a declared input, not a call. A caller free to choose among them chooses the answer: a creator cancelling could present a stale account so unlocked_at(cancel_time) snapshots low and the beneficiary keeps less than they earned. Moira pins the every-block account and rejects any other id, as twap_oracle's publish_price does. The wrong-clock case is a negative test, not only a guard.
Timestamps are milliseconds — the sequencer's wall clock — so begins, cliff_at, ends and every stored at are milliseconds throughout, asserted in vesting-core rather than assumed, since a schedule wrong by 1000× still type-checks.
Canonical time is not monotonic, and pinning the account does not make it so. The clock program increments block_id with a checked add but writes the supplied timestamp without comparing it to the previous value (lez/programs/clock/src/main.rs), and the sequencer takes that value from chrono::Utc::now() (lez/sequencer/core/src/lib.rs), so a wall-clock correction can move canonical time backward. §4's monotonicity property is over unlocked_at's input; it says nothing about the timestamps a position actually observes. Untreated, a regression lets a cancellation snapshot land below a beneficiary's already-claimed total — permanently revoking earned value, since the snapshot is the claim ceiling — or makes cancellation fail outright when unlocked < withdrawn.
Moira therefore stores a per-position high-water timestamp and reads effective_now = max(clock_now, high_water), so a position's time never goes backward whatever the clock does, and clamps the cancellation snapshot to max(unlocked_at(effective_now), withdrawn) so it can never fall below value already withdrawn. Both are machine-checked in §4 and exercised by a regressing-clock test. time_high_water lives in the ledger, is set to the creation timestamp by create and batch_create, and is advanced to effective_now by every handler that reads the clock — claim, cancel, waive_cancellation; milestone signalling leaves it untouched. The cost is eight bytes per position and no extra account. The trade is deliberate: a forward jump is ratcheted in too, so a spurious future timestamp would advance a time-based schedule permanently. We prefer that, because the failure it prevents destroys earned value while an over-advance only releases the creator's own remaining allocation early. Whether canonical regression happens in practice is an M0 measurement; both guards hold either way.
Milestone schedules do not depend on elapsed time for unlock, but they are not clock-free: opened_at and the at in Cancellation::Executed are timestamps, so milestone create and cancel still carry the pinned clock account. Time-based schedules return ClockUnavailable if a runtime read fails. All three types share one layout and event schema, so delivering the milestone type first requires no later migration.
4. Claimable computation (machine-checked safety properties)
unlocked_at(now) -> u128 is deterministic, monotonic, and capped at granted. It uses integer-only math with 256-bit intermediates and floor rounding, and returns exactly granted at or after ends.
CliffThenLinear { begins, cliff_at, ends }: now < cliff_at → 0;
now ≥ ends → granted; else granted*(now-begins)/(ends-begins)
Continuous { begins, ends }: now ≤ begins → 0;
now ≥ ends → granted; else granted*(now-begins)/(ends-begins)
Tranched { tranches, released }: Σ tranche.amount where released[index]
The pure functions live in vesting-core (no_std) so CI can check these safety properties with Kani and randomized proptest coverage (≥10,000 generated cases):
- Monotonicity:
now1 ≤ now2 ⟹ unlocked_at(now1) ≤ unlocked_at(now2)
- Boundedness:
unlocked_at(now) ≤ granted
- Conservation: in the model,
withdrawn + escrow_balance == granted while active, and withdrawn + escrow_balance + returned == granted after cancellation, where returned is stored in Cancellation::Executed. On-chain donations are ignored by claimable accounting. The live lower bound is escrow_balance ≥ granted − withdrawn while active and escrow_balance ≥ cancellation.unlocked − withdrawn after; the M1 donation/inflation tests assert both forms.
- Time-regression safety: effective time is
max(clock_now, high_water) and never decreases, and the cancellation snapshot is max(unlocked_at(effective_now), withdrawn), so no clock regression can lower a claim ceiling below value already withdrawn (§3).
- Post-cancel preservation: cancellation stores that snapshot before returning funds. Vested but unclaimed value is then
unlocked − withdrawn, and every later claim uses the immutable snapshot. signal_milestone rejects an executed cancellation, so neither time nor a bitmap update can accrue value afterwards.
Before writing state, create and batch_create reject malformed inputs with typed errors:
granted > 0; zero-amount positions are rejected.
- CliffThenLinear:
begins < cliff_at < ends.
- Continuous:
begins < ends.
- Tranched:
1 ≤ tranche_count ≤ MAX_TRANCHES; every amount positive; Σ tranche.amount == granted exactly. The bound keeps creation within the runtime's transaction-size and execution limits. The released mask is a fixed 64-bit word (hence MAX_TRANCHES ≤ 64); bits at or above tranche_count must be zero at creation, and any signal index outside 0..tranche_count is rejected.
- Overflow:
granted is a u128 because the LEZ token program's amounts are u128 (Transfer { amount_to_transfer: u128 } and the u128 account balance in programs/token), not the u64 an SVM reader might expect, so no narrowing conversion sits on the transfer leg. A full-width granted does mean granted*(now-begins) can exceed u128, so the product is taken in a 256-bit widening intermediate before the divide; all balance arithmetic is checked and reverts rather than wrapping.
The same constraints appear in handler guards and proptest generators, so tests cover the inputs the program admits.
5. Token custody, claim atomicity, and the vault-authority contingency
The vesting program holds locked supply in a per-position token account controlled by the vesting PDA. The authority path is the runtime's own: the caller presents the escrow's PDA seed on the chained call, the runtime marks that account authorized for the callee, and the token program's transfer accepts it as the debit source. programs/amm debits its pool vaults this way today; M0 reproduces the pattern for Moira's escrow on the pinned runtime rather than discovering it. Claim settlement runs as one chained call:
claim(position_id, destination):
1. verify the beneficiary signature; read the pinned clock account; compute
effective_now = max(clock_now, ledger.time_high_water)
vested_total = cancellation.unlocked if cancellation is Executed
else unlocked_at(effective_now)
claim_delta = vested_total - withdrawn
2. chained token transfer escrow → destination
- authorized by the escrow PDA seed
- destination is either a public ATA or, for a private claim, a direct
private-account credit for the beneficiary
3. write the ledger region only (withdrawn += claim_delta;
time_high_water = effective_now); emit Claimed event
The token program is pinned, not inferred. The canonical LEZ programs derive the callee from a user-supplied account's program_owner — programs/amm does this in six places. We do not. Moira pins the token program id as a compile-time constant, bound to the program image like the initial admin (§2) so no admin action can rotate it, and rejects any escrow, source, destination, treasury or broker holding not owned by it, additionally requiring each to carry a TokenHolding for exactly terms.token. Inferring the callee would let a caller present accounts owned by a counterfeit token program that reports success without moving value, after which the ledger would advance and record a claim that never paid. Program-id substitution and token-definition mismatch are both negative tests.
Why a failed transfer cannot consume vested tokens. The RFP frames this as ordering — state written in a protected continuation after the transfer — which is the Solana CPI shape. LEZ does not need it: execution builds a state diff across the whole call tree and applies it only if every call succeeds, so a failure anywhere discards the diff whole. R1 holds by construction, and there is no partially applied claim to protect against. M0 publishes the failing-transaction evidence.
Negative tests cover a substituted token program id, a holding naming a different definition than terms.token, forged escrow debits, bad PDA seeds, partial-leg failure, replay, the wrong signer, two claims racing one position, cancellation after a partial claim, frozen time and milestone accrual after cancellation, beneficiary-transfer authorization and replay, and isolation between positions. Because a supplied clock account is caller-controlled, the suite also covers a substituted clock on claim and cancel — including the 10- and 50-block accounts, valid LEZ accounts and therefore the realistic attack — asserting rejection rather than a shifted unlocked_at (§3).
Private claims. The runtime's private-action encoding carries no account id at all (§7), and lez-payment-streams already runs privacy-preserving transactions across its instruction set in its own tests, so the shape is established. M0 confirms the part specific to us: that a private credit composes inside the same chained call as the escrow debit, so a claim stays one transaction. If it cannot, we will re-scope that path with Logos; a public-then-private hop would expose a temporary public account and would not meet the requirement.
Tooling lags the runtime here, which the spike accounts for. The runtime derives a private PDA from (program_id, seed, npk, vpk, identifier); SPEL's helper still passes a fixed identifier and no viewing key, with a TODO to that effect in its own source. The private path may therefore derive against the runtime API directly until that closes — a question of where the code sits, not whether the feature is reachable. We switch to the helper when it catches up, as §3 does for ClockContext.
Vault-authority contingency. The mechanism is confirmed in the sources for the release we target, so this is reproduction rather than discovery; the residual risk is that reading code is not running it. If the running release disagrees, milestone logic, cancellation, batch creation, and events can still be built against a mocked transfer-authority shim while we scope the smallest upstream change with Logos. The shim is development-only; M4 requires live custody.
Creation and funding transaction shape. M0 also tests an assumption separate from debit authority: whether a new escrow account can be initialized and funded in the same call chain, for one position and for an N-position batch. The token program's transfer already builds a recipient holding from a default account when the recipient does not exist, so we expect initialize-and-fund to work in one call, with this shape:
create or batch_create atomically writes every position, transfers its full granted amount into the escrow PDA, and activates it;
- when the fee switch is on, one further transfer moves the summed protocol fee to the treasury and one the summed broker fee to the batch's single broker — aggregated per batch, not per position (§10). At the launch rate of 0% no fee leg is emitted;
- if any leg fails, no position in the operation is created or active.
If the pinned release instead requires escrow PDAs as pre-state, a preparation transaction is added ahead of step 1. If neither shape works for arbitrary tokens, we will review a pre-funded creator vault with Logos before changing the account model; we will not silently weaken per-position isolation. Claims, cancellation, and milestone signals reject any position not fully funded. M0 publishes the tested shapes, runtime version, and pass/fail transactions; the M1/M2 negative suite covers missing escrow pre-state, underfunding, and a failed leg inside batch_create.
6. Prior art: why a purpose-built program instead of extending lez-payment-streams
logos-co/lez-payment-streams already implements the basic linear-streaming primitive, and we reuse its SPEL structure, PDA escrow, and token-transfer patterns where they fit. A separate program is still warranted because RFP-017 adds materially different state and account requirements:
- Cliff semantics and milestone tranches (discrete, authority-approved unlocks with an idempotent bitmap), which a continuous stream cannot express.
- Cancellation with vested-but-unclaimed preservation and a one-way
Allowed → Waived conversion.
- Beneficiary transferability, frozen at creation, with authorized in-place reassignment.
- Atomic batch creation of N schedules.
- The private claim path: settlement into a beneficiary private account, with SDK-side target checks and pre-claim disclosure.
Together these warrant a dedicated, machine-checked vesting state machine rather than a fork of the streaming contract.
7. Privacy architecture and its limits
Public surface: schedule terms (token, total, kind, dates, beneficiary, flags), allowing permissionless composition; every claim's (position_id, amount, timestamp), each publicly tied to the beneficiary address; cancellations; milestone signals; beneficiary transfers. Private surface on the private path: the receiving private account and any later token movement from it. No temporary public holding account is used.
This rests on a property of the runtime, not on our discretion. A privacy-preserving message splits into public actions, which carry an account_id and a plaintext post-state, and private actions, which carry only { nullifier, root, commitment, encrypted_post_state } (lee/state_machine/core/src/circuit_io.rs). A private action has no account id and no plaintext, so a claim settled into a private account cannot disclose the destination even in principle — there is no field for it. Private PDAs are additionally derived over the recipient's nullifier public key and viewing key, so the address is not reconstructible by an observer who knows the program and seed. Our F2 test asserts this end to end rather than trusting it.
Limitation. The beneficiary remains publicly linked to the schedule and to each claim; privacy covers only the destination and post-claim movement. Beneficiary anonymity would need commitment-based registration and LP-0003, and is outside this RFP. We state this boundary in the SDK documentation and the mandatory pre-claim disclosure.
Private-target validation (PrivateTargetRequired) is enforced in the SDK and wallet, not in the handler, which is what Privacy 3 asks for. The guest cannot do it: AccountWithMetadata gives a handler only { account, is_authorized, account_id }, and an account's public-or-private character is circuit metadata that never reaches the program. Claiming a handler-side check would be claiming a guard we cannot implement. If M0 finds a trustworthy visibility signal reaching the guest we will add the check there too. The handler does enforce the beneficiary signature (BeneficiarySignatureRequired); the gas check (ClaimGasTooLow) and pre-claim disclosure are client-side by nature. When nothing is claimable, the error includes the next unlock time.
How those errors reach the user is an M0 question. LEZ programs signal failure by panicking in the guest — sound and fail-closed, but not obviously a channel carrying a typed discriminant and a payload, and U7 asks for the next unlock time, a value rather than a reason. M0 establishes what a rejecting guest can return. Either way the SDK presents U7-grade errors, using runtime structure where it exists and otherwise reconstructing the reason from position state it can already read, so a failed claim never surfaces as a bare panic string. The README records which applies.
8. Competitive landscape
Established products (Sablier, Streamflow, Hedgey, Magna, Jupiter Lock) are transparent by design, and the market is consolidating: Coinbase/Liquifi, Anchorage/Hedgey, Payward/Magna, Sablier in maintenance mode [6], Zama/TokenOps [2]. A native, open LEZ primitive is not exposed to one vendor's product decisions. Two recent products show the alternatives: Umbra + Streamflow combines a transparent Solana vesting contract with recipient stealth addresses and Arcium's encrypted execution [1]; Zama + TokenOps provides FHE-based confidential distributions on Ethereum [2]. OpenZeppelin provides related ERC-7984 primitives on fhEVM [4], and Solana Token-2022 has confidential-transfer code, though Foundation guidance limits it to the TXTX/ZK-Edge cluster [7].
Moira's distinction is the smaller trust and integration footprint:
- Direct LEZ settlement: the program credits a protocol-level private account without a stealth-address layer, encrypted-execution network, or FHE coprocessor.
- No additional operator set: users rely on LEZ, Moira's program and clients, and the configured admin — not a separate privacy network.
- Native composability: one LEZ program with a published IDL that launchpads (RFP-015/016), treasuries and grant programs call permissionlessly.
- Machine-checked claimable math: the Kani + proptest invariant suite (§4), published as part of the deliverable.
FHE or MPC could also hide beneficiary identity and amounts, but that goes beyond RFP-017. For destination privacy, adding an external execution network to LEZ would expand the trust surface without improving the property in scope. §6 explains why we also rejected extending lez-payment-streams.
9. Cancellation, transferability, authorities, batch creation
-
Cancellation: cancelable by default; waive_cancellation permits only the checked Allowed → Waived transition. Cancellation is atomic: vested-but-unclaimed value stays available to the beneficiary and only unvested value returns to the creator. The handler snapshots max(unlocked_at(effective_now), withdrawn) into Cancellation::Executed before returning funds and advances time_high_water; that snapshot is the permanent claim ceiling, and the withdrawn floor means a clock regression can never set it below value the beneficiary already holds (§3). A cancel_authority such as a DAO multisig may be fixed at creation; otherwise the creator holds the right. Intentional deviation from F3: the RFP has the creator convert to non-cancelable; we require the holder of the cancellation right to waive it, so a creator who delegated cancellation to a DAO cannot unilaterally strip that protection. With no cancel_authority set, behaviour is exactly as F3 describes, so the two differ only under the soft-requirement delegation. We will revert to the literal reading if Logos prefers.
-
Transferability: reassignable is set at creation and frozen; transfer_beneficiary is rejected when false. The current beneficiary must sign the exact (position_id, current_beneficiary, new_beneficiary) transition, and the new beneficiary must be nonzero and distinct. On transfer the ledger's beneficiary field updates, every unclaimed right (vested and future) moves, and an event is emitted; the position keeps its address and no other account is touched. Replaying the old authorization fails because the ledger no longer names that signer.
-
Authorities: both delegated roles live in PositionTerms, so neither can be rotated after creation. The mini-app warns when a delegated role resolves to the creator's own account.
-
Milestone signaling: idempotent per index via the released mask; a repeat signal returns TrancheAlreadyReleased; no double-unlock by construction; if a milestone_signer is set, only it may signal, letting launchpads put tranche approval under the launchpad or a governance signer instead of the token creator. Once cancellation is Executed, every signal is rejected with ScheduleCanceled; the immutable snapshot, not the live bitmap, controls later claims.
-
Batch creation: batch_create funds and activates N schedules atomically and never leaves a partially created or underfunded batch. We measure the maximum batch across three configurations (4-year cliff+linear grant; 2-year linear advisor; 4-tranche milestone) and publish the establishing transaction with the README result.
What actually bounds N. The RFP expects transaction size to be the limit. It is not: the runtime caps chained calls per transaction as a single budget over the whole call tree, and funding each escrow is one call to the token program, so a batch is one entry call plus one transfer per recipient and N lands near ten long before size binds. We publish the exact figure for the pinned release at M0. The protocol fee is collected as one aggregated transfer per batch for the same reason (§10); a per-position fee leg would roughly halve N for no benefit.
Above that figure. F5 asks for creation "in a single operation" and separately for the maximum achievable "in one transaction". We meet both: batch_create is the single-transaction path with its measured ceiling documented, and for larger cohorts a staged batch_open → batch_add → batch_commit protocol holds the same atomicity across several transactions — positions stay inactive and unclaimable until commit, and an abandoned batch returns every staged token. We deliberately do not pool escrow into one vault: that would make N size-bound, but it turns §4's conservation invariant from a per-position property into a global one and admits one beneficiary claiming into another's funds. Whether the staged protocol is worth building is an M0 decision once the ceiling is known.
10. Fee model
Seven of the ten protocols in the RFP's ecosystem appendix charge no protocol fee [5], so Moira launches at 0%. The mechanism is defined from the start:
- Who pays / when: a governance-activatable fee switch, initially 0%, assessed once on the schedule creator at creation time (never on the beneficiary at claim), so vesting never reduces a recipient's earned tokens.
- Unit, rate, cap: the fee is
fee_bps basis points of granted, collected from the creator in the schedule's own token, on top of the locked supply (the escrow still holds the full granted for the beneficiary). Governance may set it only in the inclusive range 0–100 bps (0%–1%); the compile-time MAX_FEE_BPS is 100 and cannot be exceeded.
- No retroactivity:
fee_bps is recorded and charged at creation; later governance changes affect only new schedules.
- Routing: to a protocol-treasury account named in
Settings.
- Optional broker fee: an opt-in
broker_fee_bps in 0–100 bps, capped by MAX_BROKER_FEE_BPS = 100, set by the integrator at creation and paid to the broker_recipient it names; off by default, fixed at creation, charged to the creator like the protocol fee, never taken from the beneficiary. The combined charge is at most 200 bps (2%).
- Rounding: each position's fee is
floor(granted * fee_bps / 10_000), so a fee is never rounded up against the payer; the batch total is the sum of per-position floors, not a floor of the sum, so what each position records and what the batch transfers agree.
- One token and one broker per batch:
batch_create rejects a batch whose positions do not all share one token definition, or which names more than one broker_recipient. Aggregated fee transfers cannot span tokens and a distinct broker is a distinct leg, so without both rules the fee overhead — and with it the maximum batch size — is data-dependent. With them it is at most two legs. Neither rule costs anything real: creators split allocations by token anyway, and a batch is created by one integrator.
- Collection in a batch: the fee is assessed per position but moved once per batch, as a single transfer of the summed fee to the treasury; the broker fee aggregates the same way per
broker_recipient. Each position still records the fee_bps charged, so per-position accounting and no-retroactivity are unchanged. The reason is the chained-call budget, not tidiness (§9).
Governance activation. One admin-authority action changes the switch or rate, within MAX_FEE_BPS, and emits ConfigUpdated. RFP-001 is awarded but not yet in the canonical stack, so the check sits behind the same small admin trait used in our accepted RFP-020 proposal, replaceable when the upstream implementation lands. Settings holds the only mutable economic parameters; each position's terms remain fixed.
11. Interfaces, events, and performance
Every transition emits a structured event — ScheduleCreated, Claimed, Canceled, TrancheReleased, BeneficiaryTransferred, CancellationWaived — and admin changes emit ConfigUpdated. Emission goes through an EventSink trait with a stable, Borsh-encoded record:
{ schema_version, sequence, kind, subject_id, payload }
No receipt or log mechanism exists in the sources for the release we target, so M0 confirms that on the running sequencer rather than assuming it. Until one lands upstream, the sink may write to bounded, program-owned EventPage PDAs for local development only; these are not represented as F6 compliance and are never the sole event channel of a public deployment. Sequence numbers are monotonic per subject, letting test tooling detect missing records: the owning VestingPosition (or Settings for config events) stores the next sequence, and the page index derives from it and a fixed records-per-page bound. vesting-events provides Rust and TypeScript decoders and the schema specifies every payload. Claimed contains only (position_id, amount, timestamp) and never the destination.
For testnet and mainnet, EventSink must emit through the canonical receipt/log mechanism. If that is still unavailable, the project pauses before M4 unless Logos accepts a written amendment to F6 naming the replacement and its discovery semantics. The schema, sequence numbers, decoders, IDL-facing types, and consumer API remain stable across sinks. Tests cover one receipt event per handler, schema round-trips, monotonic sequencing, page rollover, gap detection, and destination omission. Admin boundaries follow the RFP-001 pattern (a transferable, renounceable admin PDA) through the §10 trait; Logos controls mainnet admin custody.
vesting-indexer ingests those events and answers "positions for this beneficiary" and "schedules for this creator" — the two queries §2 explains events alone cannot serve. The SDK and mini-app read discovery through it and settlement directly from chain state, so a stale or absent indexer degrades browsing, never claiming.
A TypeScript SDK covers the full lifecycle including public and private claims. An IDL-generated Rust CLI covers create, claim, cancel, signal a milestone, and query claimable. The Basecamp mini-app includes a Recipient view (position, total, claimable, next unlock, claim) and Creator view (create, list, cancel, signal milestone), plus the pre-claim confirmation summary, private-path gas check, and privacy disclosure, and ships with Figma designs, downloadable assets, and local build and Logos loading instructions. The pre-claim summary shows the claimable amount and an estimated fee from a dry run against the connected runtime, priced with the fee inputs that release exposes; where a release exposes none, it shows the last observed fee for the same operation, labelled as an observation rather than a quote.
A claim completes in one LEZ transaction. P2 asks for a compute-unit figure per operation, and the M0 cost spike establishes whether the pinned runtime exposes a CU meter. If it does, docs/execution-costs.md reports CU per operation exactly as P2 specifies. If not, we will not relabel another measurement as "CU": the report instead gives the metrics the runtime enforces or reproduces — RISC0 executor cycles, serialized transaction bytes, chained-call depth, accounts touched — and we ask Logos to accept those as the P2 substitute in writing, on the same basis as the F6 amendment. Every result names the runtime, SPEL and RISC0 versions, input fixture, command, and source commit; CI reruns a benchmark to detect drift. The report covers every operation and batch_create at its measured maximum.
Every testnet and mainnet deployment also publishes a reproducibility bundle: source freeze commit, dependency lockfile digest, guest ELF/ImageID, program address, deployment transaction and block height, network/runtime versions, and smoke-test transactions. The README includes the command to reproduce the build and records all values together, so an ImageID cannot be paired with a different source revision.
12. Requirement traceability
Because RFP-017 assigns no formal requirement IDs, we use F1–F6, U1–U7, R1–R4, P1–P2, S1–S6, Privacy 1–3, and Soft 1–2. CI runs from the first code milestone and is green on the default branch. The condensed matrix is below; the repository carries the full line-item mapping to named tests.
| Requirement (our label) |
Implemented in |
Verified by |
| F1 escrow + three schedule types |
§2, §4 |
vesting-core unit tests + Kani invariants |
| F2 claim to public or private; creator never learns the private destination |
§5, §7 |
E2E tests assert the destination is absent from the public transaction, receipt, account set, and event payload |
| F3 cancellation (default-on, one-way convert, unvested→creator); waiver held by the cancellation authority — deviation, §9 |
§9 |
cancel/convert state-machine tests + post-cancel-preservation invariant |
| F4 transferability frozen at creation |
§9 |
reject-when-frozen, current-signer, wrong-signer, and replay tests |
| F5 atomic batch creation (max N is chained-call-bound, not size-bound — §9) |
§5, §9 |
fully-funded atomicity, failed-leg rollback, measured max-N tests |
| F6 events on every transition, schema documented |
§11 |
canonical receipt event per handler, or replacement tests named in a Logos-approved amendment |
| U1 SDK · U3 CLI · U6 IDL |
§11 |
SDK/CLI command-suite tests vs the sequencer |
U2 Basecamp mini-app (both views, backed by vesting-indexer discovery) |
§11 |
mini-app load + flow tests; indexer query tests by beneficiary and by creator |
| U4 pre-claim confirmation · U5 privacy disclosure |
§7, §11 |
claimable/fee-summary, disclosure-shown, and gas-check tests |
| U7 actionable errors (incl. next-unlock time) |
§7 |
failed-claim error tests |
| R1–R2 atomic claim/cancel · R3 concurrency · R4 idempotent signaling |
§5, §9 |
atomicity, concurrency, double-signal, and post-cancel-signal tests |
| P1 single-tx claim · P2 per-op cost (CU where the runtime exposes one, else the §11 substitute) |
§5, §11 |
one-tx assertion; reproducible docs/execution-costs.md |
| Privacy 1–3 dual path / disclosure / private-target validation |
§7 |
private-target + disclosure tests |
| S2–S3 (sequencer E2E in CI green on the default branch, ≥1 test per requirement) |
M0 onward |
CI is a merge gate from M0 and stays green for every later milestone |
| S1, S4–S6 (testnet 0.2, README, testnet 0.3, mainnet) |
M4–M7 |
network regression suites and reproducible deployment bundles |
| Soft 1–2: separate cancellation authority; separate milestone authority |
§9 |
authority-set + non-self-approve tests |
Milestones and Timeline
The eight milestones are paid on completion of their done-gates, with no upfront payment, and cover testnet 0.2, testnet 0.3, an independent audit and remediation gate, and mainnet. Milestone vesting is built first because it does not depend on custody integration; the time-based types are already buildable and could be brought forward if needed.
M0 is also the entry gate for the rest of the project, and is itself payable when the documented spikes are complete with honest pass/fail evidence. M1 may begin only after direct private settlement, whole-transaction rollback, PDA-authorized vault debit, and a viable atomic single/batch funding shape have passed on the pinned runtime, and after F6 is resolved in writing. Three of the four are already evidenced in the sources, so that part of the gate is reproduction rather than an open question; direct private settlement is the one genuinely unproven. If an entry check fails, the project pauses; no later milestone begins or becomes payable until Logos approves a revised scope, budget, and schedule. A mocked transfer shim cannot satisfy this gate.
Why F6 sits at this gate and not at M4. We already know no canonical event mechanism exists, and events are a hard requirement. Leaving it to the M4 public-deployment gate would mean M0–M3 — the large majority of the budget, and every line of the implementation — could complete and be paid before anyone established that the result can satisfy F6 or deploy publicly. So M1 does not begin until Logos either accepts a written F6 amendment naming the replacement and its discovery semantics, or commits to an upstream delivery plan and date. M1–M3 then run on development event pages against whichever was agreed, and those pages are never represented as F6 compliance.
| # |
Milestone |
Key deliverables |
Done gate |
Duration |
| M0 |
Foundations & spikes |
Public repo and CI; clean SPEL v0.6.0 RISC-V build; runtime spikes on private settlement, whole-transaction rollback, execution-cost baseline including CU-meter availability, PDA derivation and escrow debit authority, clock account pinning, timestamp units and whether canonical time regresses in practice, guest error propagation, LP-0012 plus development-page init/write/rollover, and single/batch escrow initialize-and-fund shapes; event schema and deployment-evidence format; measured maximum batch size against the chained-call budget |
Build passes and CI is green on the default branch, which stays a merge gate for every later milestone; every spike publishes pass/fail results, transactions where applicable, exact source references, and the explicit M1 entry-gate decision above; the measured batch maximum is published with its establishing transaction and a stated recommendation for or against the staged protocol in §9 |
1.5 wks |
| M1 |
Vesting core + milestone handlers |
vesting-core with cliff+linear, continuous, and tranche unlock math; initialize for Settings; milestone create/batch/signal/cancel/waive handlers; development event pages and decoders; Kani and proptest invariants for all three policies; donation, underfunding, post-cancel-signal, failed-batch-leg tests |
Unlock-math proofs and M1 tests pass for all three policies; Settings initializes once and rejects re-initialization; event schema round-trips; signaling is idempotent and rejected after cancellation |
2.5 wks |
| M2 |
Claim mechanics, custody, privacy |
Direct public and private claims; live escrow and confirmed funding path; beneficiary transfer; vesting-indexer (event ingest, query by beneficiary and creator); atomicity and concurrency tests; SDK target checks; execution-cost and batch limits |
Both claim paths and fully funded batch creation pass E2E; forged-debit, replay, bad-PDA, substituted-clock, underfunding, and beneficiary-transfer wrong-signer/replay tests pass; the indexer serves both discovery queries; docs/execution-costs.md published |
3 wks |
| M3 |
Time-based types + apps |
Time-based schedule handlers; SDK; IDL-generated CLI; Basecamp mini-app and Figma |
Live-clock claims and SDK/CLI flows pass; mini-app loads on indexer-backed discovery; claimable/fee summary, private-path gas check, and disclosure tests pass |
2 wks |
| M4 |
Testnet 0.2 + docs |
Testnet 0.2 deployment, canonical receipt events, E2E CI, migration notes if needed, and the §11 reproducibility bundle; privacy, logos-docs, and launchpad-integration documentation |
Smoke test passes with live custody, no shim, and canonical receipt events for every transition (or a Logos-approved written F6 amendment); the full §11 bundle is published; documentation is delivered for review |
1.5 wks |
| M5 |
Testnet 0.3 |
Testnet 0.3 deployment, regression and privacy suites, refreshed execution metrics, reproducibility bundle |
E2E and regression suites pass; refreshed report and complete bundle published |
0.5 wk |
| M6 |
Independent audit & remediation |
Audit coordination and technical support; independent report covering unlock math, custody/atomicity, cancellation, milestone signaling, beneficiary transfer, fees, admin controls; remediation and re-review evidence |
Final report is public or shared with Logos; every critical/high finding is closed on the evidence test above, or deemed accepted after ten business days without dispute; medium findings are closed or explicitly accepted by Logos with rationale |
0.5 active wk (up to 2 wks if findings require) + external auditor calendar |
| M7 |
Mainnet |
Mainnet deployment; README and runbook; proofs, test matrix, traceability, execution benchmarks, admin handoff, audit-closure record, reproducibility bundle |
M6 accepted; deployment and smoke transactions verified; the complete bundle and mainnet handoff published |
0.5 wk |
Proposed schedule: 12 active weeks, at the top of the RFP's 10–12-week estimate. M0–M3 account for 9 weeks of product development; the remaining 3 cover testnet 0.2 and its documentation, testnet 0.3, audit support/remediation, and mainnet. The 12 weeks count M6 at its nominal 0.5 week; the ten-day remediation allowance depends on what the audit finds and does not change the amount requested. M1 fits in two and a half weeks because its scope is a pure library plus handlers against a state machine this proposal already specifies in full. M3 fits in two because the unlock math for all three types is machine-checked in M1 (§3: one shared layout, so time-based types add handlers rather than a second state machine), and because documentation sits in M4 alongside the deployment it describes. M0–M4 can begin against the live testnet 0.2. M5 and M6 dates depend on Logos releasing testnet 0.3 and scheduling the audit; M7 on the audit gate and mainnet release (targeted early 2027 [3]).
Timeline assumptions and risk. The vault-authority and initialize-and-fund paths are evidenced in the runtime sources, so the estimate assumes M0 reproduces them rather than discovering them; if the running release differs, the fallback preparation step and shim switchover absorb it. The two results the sources do not settle — direct private settlement and the measured batch ceiling — are both M0 outputs. The estimate further assumes each testnet line keeps a stable ABI and token-program image. Because the pinned token program id is a compile-time constant and a ProgramId is the image id of the bytecode (§2), any release changing the token program also changes Moira's image id, program address, and every derived PDA — it cannot be swapped under us silently, but neither can it be adopted without redeploying. The same mechanism gives each network a distinct program identity, since admin and treasury constants differ per network. A breaking release, or a token-program change mid-line, would require a separately estimated migration. Material platform changes will be re-planned with Logos.
External audit. M6 is a mandatory independent-audit and remediation gate before M7. Selecting, contracting, scheduling, and paying the auditor sit with Logos and are outside this request; our audit support and remediation engineering are included. M6 covers the claimable math, escrow and claim atomicity, cancellation and milestone signaling, beneficiary-transfer authorization, fee/admin controls, and the public/private claim boundary. Equilibrium remediates every critical and high finding in Moira's own code at no additional engineering charge, up to ten engineering days included in this budget. If closing them credibly needs more, we complete the work under a scope and fee re-estimated with Logos; the allowance bounds the budget, not the commitment.
What "closed" means. A finding is closed when the fix ships with evidence that it no longer reproduces — a test that previously triggered and now does not. Remediation covers the report's findings and any regression our own fixes introduce; genuinely new unrelated findings are separately scoped. Two re-review rounds are included. M6 is complete when no critical or high finding remains open on that basis, and if neither the auditor nor Logos disputes the evidence within ten business days it is treated as accepted, so neither side waits on the other — bounding the milestone by a deliverable we control rather than a third party's calendar. It does not relax the safety condition: M7 still requires that no critical or high finding is open. Findings rooted in the LEZ runtime, SPEL, or the token program rather than Moira are reported upstream with a reproduction and mitigated program-side where possible; the upstream fix is outside scope. Medium findings must be closed or explicitly accepted by Logos with written rationale; low/informational findings are triaged into the public tracker or accepted with rationale.
Total Requested Budget (USD)
to be agreed between the parties
Relevant Experience
Our experience spans SVM programs, token custody, DeFi security, wallet front ends, privacy engineering, and formal and property-based testing. Public work is linked; confidential engagements are described at the level we may disclose. We are also delivering Kanon, the accepted RFP-020 RedStone oracle adaptor, on the same LEZ / SPEL / RISC0 stack (proposal #117).
SVM / Solana-native program engineering
- Axelar <-> Solana GMP stack — Axelar (public). Built the Solana side of Axelar's cross-chain messaging: on-chain SVM programs, off-chain relayer, deployment tooling; externally audited. Rust + TypeScript. Axelar amplifier, Solana relayer.
- IBC on Solana — confidential client in the Cosmos / Interchain ecosystem. Solana IBC programs (extending IBC v2 / Eureka), an m-of-n attestation light client, the Interchain Fungible Token (IFT) mint-burn standard, and relayer infrastructure, with a working Solana → Cosmos → Solana roundtrip. Audited by Zenith Security, February 2026. Rust + Go; directly relevant to program-owned custody and atomic transfers.
- Sovereign SDK / RISC0 — public tech; client under NDA. Integrated EigenDA as a data-availability backend for the Sovereign SDK, a rollup framework supporting RISC0, the VM Moira targets.
Privacy-preserving protocol work
- ZAIR — Zcash ↔ Namada ZK shielded claim — Zcash Community Grants (public). A privacy-preserving cross-ecosystem claim ("shielded airdrop") protocol: per-note ZK proofs of ownership and unspentness without revealing the nullifier, across Zcash's Sapling (Groth16) and Orchard (Halo2) pools, with Namada consuming the proofs. Open source; not audited. equilibriumco/zair.
Formal and property-based verification
- Move Prover mutation-testing tooling — Aptos Foundation (public). Measures whether formal specifications and test suites actually hold, via mutation testing for the Move Prover. equilibriumco/move-mutation-tools.
Production wallet / DeFi front-end
- Multi-chain self-custodial wallet SDK — public tech: Tether WDK, Movement. React Native / TypeScript wallet-SDK engineering extending Tether's WDK to Movement, including DEX-aggregator and lending integrations; this is the team building the vesting SDK and mini-app.
- Zcash UniFFI bindings — Zcash Community Grants (public). Cross-language FFI bindings for Zcash's Rust libraries (Swift, Kotlin, Python, Ruby) via Mozilla's UniFFI, making shielded-pool primitives usable by wallet developers. equilibriumco/uniffi-zcash-lib.
Regulated token engineering
- EUROe — Membrane Finance (public; past engagement; incubated and spun out of Equilibrium Group). Supported core systems for EUROe, a Finnish-EMI-regulated euro stablecoin on Ethereum (Solidity) and Solana (Rust / SPL). EVM contracts audited by PeckShield (2022) and Runtime Verification (2022), the Concordium implementation by Sigma Prime (2023). Membrane Finance was acquired by Paxos in February 2025; EUROe has since moved to redemption-only mode.
Post-Delivery Plan
- Mainnet-readiness handoff. Audit-ready artifacts, verification results (Kani proofs and property-test corpus), execution benchmarks, reproducibility bundle, deployment runbook, and admin operations guide for Logos governance.
- Support window (3 months). Bug triage and dependency upkeep after M7: RFP-001 / SPEL version bumps, the
TimeSource switch to SPEL's ClockContext accessor when it merges, and integration support for the first launchpad (RFP-015/016) and treasury integrators.
- Audit follow-through. M6 includes remediation of every critical/high and agreed medium finding before mainnet, within the ten-day allowance above. During the support window we also answer auditor follow-ups and close any regression attributable to an in-scope remediation. Audit procurement and re-review fees remain outside the budget.
- Future work (out of scope). A later version could hide the beneficiary with commitment-based registration and ZK claim proofs: the creator stores
H(beneficiary_pk, salt) and the beneficiary proves knowledge of the preimage and entitlement in a RISC0 guest, revealing only (position_id, amount). This depends on the still-open LP-0003 and would need a separate RFP. A further version could hide claim amounts with a Pedersen-committed withdrawn total.
Sources
All links checked 2026-08-20/21.
Permissions and Consent
Program Requirements
RFP ID
RFP-017 — Privacy-Preserving Token Vesting
Your Project Name
Moira — Privacy-Preserving Token Vesting for LEZ
From the Greek μοῖρα: an allotted portion or share, released in due measure.
Team or Organization Name
Equilibrium. We build and verify security-critical blockchain protocols, bridges, cryptographic systems, and node software, delivering complete modules with one organization accountable from design through handover.
Primary Contact
Olli Tiainen — olli@equilibrium.co
Team Members
The work will be led by Equilibrium engineers with production SVM/Solana and Rust experience, supported by our TypeScript, front-end, and ZK teams.
vesting-core, state-machine testing, and the §4 invariants.These are Equilibrium staff assigned to the engagement for its duration, not contributors fitting it around other commitments. The Rust engineers above will own the IDL-based CLI.
Project Summary
Moira is the vesting component needed by the RFP-015/016 launchpads. It supports cliff+linear, fully linear, and milestone schedules, with cancellation, beneficiary transfers, idempotent milestone signaling, and atomic batch creation.
Claims can settle to a public account or, subject to the M0 runtime check in §5, directly to a beneficiary-selected private account. Schedules, beneficiaries, claim amounts, and claim times remain public: a private claim hides only the receiving account and its later activity, not the schedule or the beneficiary's association with it.
Moira uses LEZ private accounts directly, without a separate privacy network. Users still rely on LEZ, the Moira program and clients, and the configured admin. This deliberately narrow privacy boundary keeps the program composable with launchpads, treasuries, and grant programs.
Current dependencies
Testnet 0.2 went live on 30 June 2026, LEZ v0.2.4 followed on 7 August, and mainnet is targeted for early 2027 [3]. The Lambda Prizes are closed and the on-chain clock is live, but we read the runtime and canonical
lez-programssources rather than relying on prize status. LP-0013 delivers mint authority — "Token program improvements: mint authorities", as the RFP's own Resources section names it — which is not what a vesting escrow needs. The vault-debit primitive is not a token-program feature at all: it is the runtime's PDA authorization for chained calls, and canonicalprograms/ammalready uses it. The one genuine gap is LP-0012: no event, log, or receipt mechanism exists in the runtime sources at all.Every runtime claim here was read from LEZ v0.2.4 (commit
47eba25), the release we target, and cited to a path in that tree so a reviewer can check it. Where a later release changes one, the M0 spike that reproduces it is where it surfaces.lee/.EventPageis development-only; public deployment requires the canonical mechanism or written Logos acceptance of an amended F6 (§11)programs/ammswap debits its vault this way (lez/programs/amm/src/swap.rs). M0 reproduces it for Moira's escrow (§5)ClockContextAPIAll three schedule types are buildable now. M0 reproduces the vault-authority path on the pinned release before end-to-end testnet claims; §5 gives the contingency if that release differs from the sources. Milestone vesting goes first because it is independent of custody integration. Delivery then continues through testnet 0.2, testnet 0.3, and mainnet as required.
Technical Approach
1. Stack and build model
Moira is written in Rust using SPEL (LEZ's Anchor-equivalent) and compiled to RISC0 guest binaries (
riscv32im-risc0-zkvm-elf). Pure state transitions run in the guest; clients, IDL generation, and integration tests are host code. The repository follows thelogos-co/scaffoldandlgstoolchain and is tested against the LEZ runtime and thelez-payment-streams,lez-multisig, and SPEL reference programs. We target SPEL v0.6.0, which includes the breaking LEZ v0.2.0 migration and restores clean RISC-V guest builds [3]; M0 reproduces that build with Moira's dependency graph. All code is dual-licensed MIT + Apache-2.0.2. State and account model
Each position is a PDA-addressed record keyed by
position_id = (creator, position_index). Operations touch a fixed account set, so cost does not grow with the number of positions.VestingPosition["position", creator, position_index]Escrow["escrow", position_id]EventPage["events", subject_id, page]Settings["settings"]Settingsis created once by aninitializeinstruction, before any schedule can be created, and rejects re-initialization. Who may call it first is the security question, and requiring the proposed admin to sign does not answer it — an attacker simply initializes themselves first. LEZ deployment carries only bytecode, with no deployer authority the runtime could authenticate, so we bind the admin to the program image: the initial admin and treasury are compile-time constants in the guest, andinitializeignores caller-supplied values. The pinned token program id (§5) is a constant on the same basis and deliberately not aSettingsfield, so no admin action can repoint custody. Since aProgramIdis the RISC0 image id of the bytecode (lee/state_machine/src/program/mod.rs), program identity commits to the admin, verifiable by anyone reproducing the build from the §11 bundle. Whoever wins the race toinitializetherefore has no influence on the outcome; rotation afterwards runs through the §10 admin trait, and a negative test asserts a competing first caller can choose neither admin nor treasury.Accounts are permanent, and we say so rather than promising otherwise. LEZ has no rent to reclaim —
Accountis{ program_owner, balance, data, nonce }— the token instruction set has no close operation, and the execution rules forbid both changing an account'sprogram_ownerand returning an initialized account to the default owner (lee/state_machine/core/src/program/mod.rs). A fully claimed position and its empty escrow therefore remain in state. Nothing recoverable is stranded, so this costs storage rather than value. If Logos later adds a close primitive we will adopt it; any such design also needs a surplus policy, since an escrow anyone can dust would never satisfy an empty-balance close guard.Each position separates terms fixed at creation from its lifecycle ledger. Handlers read terms through immutable accessors and update the ledger only via checked transitions.
Cancellationpermits onlyAllowed → WaivedandAllowed → Executed. Cancellation snapshots total unlocked value before returning funds; storing bothunlockedandreturnedlets §4 express conservation after cancellation using immutable values for time-based and milestone schedules alike.releasedis a 64-bit tranche bitmap, soMAX_TRANCHES ≤ 64; human-readable labels live in the creation event rather than state.The current recipient is the ledger's
beneficiaryfield, so a transfer rewrites one field and the position keeps its address; no second account is closed and reopened. We deliberately add no per-position marker account for beneficiary lookup: it would answer only queries that already supplyposition_id— which can read the ledger directly — while costing a third account per recipient inbatch_createand reducing the maximum batch size.Discovery needs a reader, and we ship one. Events are the discovery channel the RFP's LP-0012 rationale intends, but an event alone does not answer "which positions belong to me": the development
EventPageis keyed by asubject_idthe caller must already know, so it cannot enumerate unknown positions.vesting-indexertherefore ships as part of this work — a small service that ingestsScheduleCreated,BeneficiaryTransferred,Claimed,CanceledandTrancheReleasedand exposes queries by beneficiary and by creator, exactly the two lists U2's Recipient and Creator views need. It reads canonical receipt events where they exist andEventPagePDAs in local development behind one interface, so the mini-app and SDK do not change when the canonical mechanism lands. It is a read-only projection holding no keys: losing it costs discovery convenience, never funds or claimability. We deliver the service and a runnable local deployment, not a hosted production service — operating one for a public network sits with Logos or an integrator and is outside this budget. It lands in M2, ahead of the M3 mini-app that consumes it.How it bootstraps. Reading
EventPagePDAs cannot be the discovery step, since their addresses derive from asubject_idthe reader does not yet know. The indexer follows the chain, not the accounts: it walks blocks by id using the sequencer'sget_latest_block_idandget_block_range— the cursor-and-poll shapelez/wallet/src/poller.rsalready uses — and filters each block for Moira invocations. A creation transaction is where a previously unknownposition_idfirst becomes visible; from there itsEventPageaddresses are derivable and payloads decodable. State is a persisted cursor, so restart resumes rather than rescans, and a backfill is the same walk from the deployment block recorded in the §11 bundle. Ingestion is idempotent per(subject_id, sequence), and monotonic sequence numbers let it detect a gap and re-fetch instead of serving incomplete history. When the canonical mechanism lands, only the payload-read step changes.position_indexallocation. The index is creator-scoped and client-supplied, so concurrent clients under one creator can pick the same value. Rather than a shared counter account, which would serialise every creation by that creator and burn a chained call, creation is allowed to collide and fail: the position PDA already exists, the runtime rejects the transaction, and no partial state is written. The index is an opaqueu64, not a dense counter, so the SDK's default allocation is a random draw — collision probability is negligible and no coordination is needed. A creator wanting dense indices can opt into sequential allocation above the highest index the indexer has seen; that path can genuinely collide, so it retries with bounded exponential backoff and surfaces a typed error rather than looping.batch_createallocates its whole range in one draw under either scheme.3. Time-source isolation
Cliff+linear and fully linear schedules read elapsed time from the delivered LEZ clock program. SPEL's
ClockContextaccessor is still an open PR (#227), so all reads pass through a smallTimeSourcetrait:This keeps the unlock math deterministic in tests and limits future clock API changes to one implementation, leaving state, events, and the SDK untouched.
The clock is an input account, and the account is pinned. LEZ's clock keeps three accounts refreshed every 1, 10 and 50 blocks, so a timestamp is a declared input, not a call. A caller free to choose among them chooses the answer: a creator cancelling could present a stale account so
unlocked_at(cancel_time)snapshots low and the beneficiary keeps less than they earned. Moira pins the every-block account and rejects any other id, astwap_oracle'spublish_pricedoes. The wrong-clock case is a negative test, not only a guard.Timestamps are milliseconds — the sequencer's wall clock — so
begins,cliff_at,endsand every storedatare milliseconds throughout, asserted investing-corerather than assumed, since a schedule wrong by 1000× still type-checks.Canonical time is not monotonic, and pinning the account does not make it so. The clock program increments
block_idwith a checked add but writes the suppliedtimestampwithout comparing it to the previous value (lez/programs/clock/src/main.rs), and the sequencer takes that value fromchrono::Utc::now()(lez/sequencer/core/src/lib.rs), so a wall-clock correction can move canonical time backward. §4's monotonicity property is overunlocked_at's input; it says nothing about the timestamps a position actually observes. Untreated, a regression lets a cancellation snapshot land below a beneficiary's already-claimed total — permanently revoking earned value, since the snapshot is the claim ceiling — or makes cancellation fail outright whenunlocked < withdrawn.Moira therefore stores a per-position high-water timestamp and reads
effective_now = max(clock_now, high_water), so a position's time never goes backward whatever the clock does, and clamps the cancellation snapshot tomax(unlocked_at(effective_now), withdrawn)so it can never fall below value already withdrawn. Both are machine-checked in §4 and exercised by a regressing-clock test.time_high_waterlives in the ledger, is set to the creation timestamp bycreateandbatch_create, and is advanced toeffective_nowby every handler that reads the clock —claim,cancel,waive_cancellation; milestone signalling leaves it untouched. The cost is eight bytes per position and no extra account. The trade is deliberate: a forward jump is ratcheted in too, so a spurious future timestamp would advance a time-based schedule permanently. We prefer that, because the failure it prevents destroys earned value while an over-advance only releases the creator's own remaining allocation early. Whether canonical regression happens in practice is an M0 measurement; both guards hold either way.Milestone schedules do not depend on elapsed time for unlock, but they are not clock-free:
opened_atand theatinCancellation::Executedare timestamps, so milestone create and cancel still carry the pinned clock account. Time-based schedules returnClockUnavailableif a runtime read fails. All three types share one layout and event schema, so delivering the milestone type first requires no later migration.4. Claimable computation (machine-checked safety properties)
unlocked_at(now) -> u128is deterministic, monotonic, and capped atgranted. It uses integer-only math with 256-bit intermediates and floor rounding, and returns exactlygrantedat or afterends.The pure functions live in
vesting-core(no_std) so CI can check these safety properties with Kani and randomizedproptestcoverage (≥10,000 generated cases):now1 ≤ now2 ⟹ unlocked_at(now1) ≤ unlocked_at(now2)unlocked_at(now) ≤ grantedwithdrawn + escrow_balance == grantedwhile active, andwithdrawn + escrow_balance + returned == grantedafter cancellation, wherereturnedis stored inCancellation::Executed. On-chain donations are ignored by claimable accounting. The live lower bound isescrow_balance ≥ granted − withdrawnwhile active andescrow_balance ≥ cancellation.unlocked − withdrawnafter; the M1 donation/inflation tests assert both forms.max(clock_now, high_water)and never decreases, and the cancellation snapshot ismax(unlocked_at(effective_now), withdrawn), so no clock regression can lower a claim ceiling below value already withdrawn (§3).unlocked − withdrawn, and every later claim uses the immutable snapshot.signal_milestonerejects an executed cancellation, so neither time nor a bitmap update can accrue value afterwards.Before writing state,
createandbatch_createreject malformed inputs with typed errors:granted > 0; zero-amount positions are rejected.begins < cliff_at < ends.begins < ends.1 ≤ tranche_count ≤ MAX_TRANCHES; every amount positive;Σ tranche.amount == grantedexactly. The bound keeps creation within the runtime's transaction-size and execution limits. Thereleasedmask is a fixed 64-bit word (henceMAX_TRANCHES ≤ 64); bits at or abovetranche_countmust be zero at creation, and any signal index outside0..tranche_countis rejected.grantedis au128because the LEZ token program's amounts areu128(Transfer { amount_to_transfer: u128 }and theu128accountbalanceinprograms/token), not theu64an SVM reader might expect, so no narrowing conversion sits on the transfer leg. A full-widthgranteddoes meangranted*(now-begins)can exceedu128, so the product is taken in a 256-bit widening intermediate before the divide; all balance arithmetic is checked and reverts rather than wrapping.The same constraints appear in handler guards and proptest generators, so tests cover the inputs the program admits.
5. Token custody, claim atomicity, and the vault-authority contingency
The vesting program holds locked supply in a per-position token account controlled by the vesting PDA. The authority path is the runtime's own: the caller presents the escrow's PDA seed on the chained call, the runtime marks that account authorized for the callee, and the token program's transfer accepts it as the debit source.
programs/ammdebits its pool vaults this way today; M0 reproduces the pattern for Moira's escrow on the pinned runtime rather than discovering it. Claim settlement runs as one chained call:The token program is pinned, not inferred. The canonical LEZ programs derive the callee from a user-supplied account's
program_owner—programs/ammdoes this in six places. We do not. Moira pins the token program id as a compile-time constant, bound to the program image like the initial admin (§2) so no admin action can rotate it, and rejects any escrow, source, destination, treasury or broker holding not owned by it, additionally requiring each to carry aTokenHoldingfor exactlyterms.token. Inferring the callee would let a caller present accounts owned by a counterfeit token program that reports success without moving value, after which the ledger would advance and record a claim that never paid. Program-id substitution and token-definition mismatch are both negative tests.Why a failed transfer cannot consume vested tokens. The RFP frames this as ordering — state written in a protected continuation after the transfer — which is the Solana CPI shape. LEZ does not need it: execution builds a state diff across the whole call tree and applies it only if every call succeeds, so a failure anywhere discards the diff whole. R1 holds by construction, and there is no partially applied claim to protect against. M0 publishes the failing-transaction evidence.
Negative tests cover a substituted token program id, a holding naming a different definition than
terms.token, forged escrow debits, bad PDA seeds, partial-leg failure, replay, the wrong signer, two claims racing one position, cancellation after a partial claim, frozen time and milestone accrual after cancellation, beneficiary-transfer authorization and replay, and isolation between positions. Because a supplied clock account is caller-controlled, the suite also covers a substituted clock onclaimandcancel— including the 10- and 50-block accounts, valid LEZ accounts and therefore the realistic attack — asserting rejection rather than a shiftedunlocked_at(§3).Private claims. The runtime's private-action encoding carries no account id at all (§7), and
lez-payment-streamsalready runs privacy-preserving transactions across its instruction set in its own tests, so the shape is established. M0 confirms the part specific to us: that a private credit composes inside the same chained call as the escrow debit, so a claim stays one transaction. If it cannot, we will re-scope that path with Logos; a public-then-private hop would expose a temporary public account and would not meet the requirement.Tooling lags the runtime here, which the spike accounts for. The runtime derives a private PDA from
(program_id, seed, npk, vpk, identifier); SPEL's helper still passes a fixed identifier and no viewing key, with a TODO to that effect in its own source. The private path may therefore derive against the runtime API directly until that closes — a question of where the code sits, not whether the feature is reachable. We switch to the helper when it catches up, as §3 does forClockContext.Vault-authority contingency. The mechanism is confirmed in the sources for the release we target, so this is reproduction rather than discovery; the residual risk is that reading code is not running it. If the running release disagrees, milestone logic, cancellation, batch creation, and events can still be built against a mocked transfer-authority shim while we scope the smallest upstream change with Logos. The shim is development-only; M4 requires live custody.
Creation and funding transaction shape. M0 also tests an assumption separate from debit authority: whether a new escrow account can be initialized and funded in the same call chain, for one position and for an N-position batch. The token program's transfer already builds a recipient holding from a default account when the recipient does not exist, so we expect initialize-and-fund to work in one call, with this shape:
createorbatch_createatomically writes every position, transfers its fullgrantedamount into the escrow PDA, and activates it;If the pinned release instead requires escrow PDAs as pre-state, a preparation transaction is added ahead of step 1. If neither shape works for arbitrary tokens, we will review a pre-funded creator vault with Logos before changing the account model; we will not silently weaken per-position isolation. Claims, cancellation, and milestone signals reject any position not fully funded. M0 publishes the tested shapes, runtime version, and pass/fail transactions; the M1/M2 negative suite covers missing escrow pre-state, underfunding, and a failed leg inside
batch_create.6. Prior art: why a purpose-built program instead of extending
lez-payment-streamslogos-co/lez-payment-streamsalready implements the basic linear-streaming primitive, and we reuse its SPEL structure, PDA escrow, and token-transfer patterns where they fit. A separate program is still warranted because RFP-017 adds materially different state and account requirements:Allowed → Waivedconversion.Together these warrant a dedicated, machine-checked vesting state machine rather than a fork of the streaming contract.
7. Privacy architecture and its limits
Public surface: schedule terms (token, total, kind, dates, beneficiary, flags), allowing permissionless composition; every claim's
(position_id, amount, timestamp), each publicly tied to the beneficiary address; cancellations; milestone signals; beneficiary transfers. Private surface on the private path: the receiving private account and any later token movement from it. No temporary public holding account is used.This rests on a property of the runtime, not on our discretion. A privacy-preserving message splits into public actions, which carry an
account_idand a plaintext post-state, and private actions, which carry only{ nullifier, root, commitment, encrypted_post_state }(lee/state_machine/core/src/circuit_io.rs). A private action has no account id and no plaintext, so a claim settled into a private account cannot disclose the destination even in principle — there is no field for it. Private PDAs are additionally derived over the recipient's nullifier public key and viewing key, so the address is not reconstructible by an observer who knows the program and seed. Our F2 test asserts this end to end rather than trusting it.Limitation. The beneficiary remains publicly linked to the schedule and to each claim; privacy covers only the destination and post-claim movement. Beneficiary anonymity would need commitment-based registration and LP-0003, and is outside this RFP. We state this boundary in the SDK documentation and the mandatory pre-claim disclosure.
Private-target validation (
PrivateTargetRequired) is enforced in the SDK and wallet, not in the handler, which is what Privacy 3 asks for. The guest cannot do it:AccountWithMetadatagives a handler only{ account, is_authorized, account_id }, and an account's public-or-private character is circuit metadata that never reaches the program. Claiming a handler-side check would be claiming a guard we cannot implement. If M0 finds a trustworthy visibility signal reaching the guest we will add the check there too. The handler does enforce the beneficiary signature (BeneficiarySignatureRequired); the gas check (ClaimGasTooLow) and pre-claim disclosure are client-side by nature. When nothing is claimable, the error includes the next unlock time.How those errors reach the user is an M0 question. LEZ programs signal failure by panicking in the guest — sound and fail-closed, but not obviously a channel carrying a typed discriminant and a payload, and U7 asks for the next unlock time, a value rather than a reason. M0 establishes what a rejecting guest can return. Either way the SDK presents U7-grade errors, using runtime structure where it exists and otherwise reconstructing the reason from position state it can already read, so a failed claim never surfaces as a bare panic string. The README records which applies.
8. Competitive landscape
Established products (Sablier, Streamflow, Hedgey, Magna, Jupiter Lock) are transparent by design, and the market is consolidating: Coinbase/Liquifi, Anchorage/Hedgey, Payward/Magna, Sablier in maintenance mode [6], Zama/TokenOps [2]. A native, open LEZ primitive is not exposed to one vendor's product decisions. Two recent products show the alternatives: Umbra + Streamflow combines a transparent Solana vesting contract with recipient stealth addresses and Arcium's encrypted execution [1]; Zama + TokenOps provides FHE-based confidential distributions on Ethereum [2]. OpenZeppelin provides related ERC-7984 primitives on fhEVM [4], and Solana Token-2022 has confidential-transfer code, though Foundation guidance limits it to the TXTX/ZK-Edge cluster [7].
Moira's distinction is the smaller trust and integration footprint:
FHE or MPC could also hide beneficiary identity and amounts, but that goes beyond RFP-017. For destination privacy, adding an external execution network to LEZ would expand the trust surface without improving the property in scope. §6 explains why we also rejected extending
lez-payment-streams.9. Cancellation, transferability, authorities, batch creation
Cancellation: cancelable by default;
waive_cancellationpermits only the checkedAllowed → Waivedtransition. Cancellation is atomic: vested-but-unclaimed value stays available to the beneficiary and only unvested value returns to the creator. The handler snapshotsmax(unlocked_at(effective_now), withdrawn)intoCancellation::Executedbefore returning funds and advancestime_high_water; that snapshot is the permanent claim ceiling, and thewithdrawnfloor means a clock regression can never set it below value the beneficiary already holds (§3). Acancel_authoritysuch as a DAO multisig may be fixed at creation; otherwise the creator holds the right. Intentional deviation from F3: the RFP has the creator convert to non-cancelable; we require the holder of the cancellation right to waive it, so a creator who delegated cancellation to a DAO cannot unilaterally strip that protection. With nocancel_authorityset, behaviour is exactly as F3 describes, so the two differ only under the soft-requirement delegation. We will revert to the literal reading if Logos prefers.Transferability:
reassignableis set at creation and frozen;transfer_beneficiaryis rejected when false. The current beneficiary must sign the exact(position_id, current_beneficiary, new_beneficiary)transition, and the new beneficiary must be nonzero and distinct. On transfer the ledger's beneficiary field updates, every unclaimed right (vested and future) moves, and an event is emitted; the position keeps its address and no other account is touched. Replaying the old authorization fails because the ledger no longer names that signer.Authorities: both delegated roles live in
PositionTerms, so neither can be rotated after creation. The mini-app warns when a delegated role resolves to the creator's own account.Milestone signaling: idempotent per index via the
releasedmask; a repeat signal returnsTrancheAlreadyReleased; no double-unlock by construction; if amilestone_signeris set, only it may signal, letting launchpads put tranche approval under the launchpad or a governance signer instead of the token creator. Once cancellation isExecuted, every signal is rejected withScheduleCanceled; the immutable snapshot, not the live bitmap, controls later claims.Batch creation:
batch_createfunds and activates N schedules atomically and never leaves a partially created or underfunded batch. We measure the maximum batch across three configurations (4-year cliff+linear grant; 2-year linear advisor; 4-tranche milestone) and publish the establishing transaction with the README result.What actually bounds N. The RFP expects transaction size to be the limit. It is not: the runtime caps chained calls per transaction as a single budget over the whole call tree, and funding each escrow is one call to the token program, so a batch is one entry call plus one transfer per recipient and N lands near ten long before size binds. We publish the exact figure for the pinned release at M0. The protocol fee is collected as one aggregated transfer per batch for the same reason (§10); a per-position fee leg would roughly halve N for no benefit.
Above that figure. F5 asks for creation "in a single operation" and separately for the maximum achievable "in one transaction". We meet both:
batch_createis the single-transaction path with its measured ceiling documented, and for larger cohorts a stagedbatch_open→batch_add→batch_commitprotocol holds the same atomicity across several transactions — positions stay inactive and unclaimable until commit, and an abandoned batch returns every staged token. We deliberately do not pool escrow into one vault: that would make N size-bound, but it turns §4's conservation invariant from a per-position property into a global one and admits one beneficiary claiming into another's funds. Whether the staged protocol is worth building is an M0 decision once the ceiling is known.10. Fee model
Seven of the ten protocols in the RFP's ecosystem appendix charge no protocol fee [5], so Moira launches at 0%. The mechanism is defined from the start:
fee_bpsbasis points ofgranted, collected from the creator in the schedule's own token, on top of the locked supply (the escrow still holds the fullgrantedfor the beneficiary). Governance may set it only in the inclusive range 0–100 bps (0%–1%); the compile-timeMAX_FEE_BPSis 100 and cannot be exceeded.fee_bpsis recorded and charged at creation; later governance changes affect only new schedules.Settings.broker_fee_bpsin 0–100 bps, capped byMAX_BROKER_FEE_BPS = 100, set by the integrator at creation and paid to thebroker_recipientit names; off by default, fixed at creation, charged to the creator like the protocol fee, never taken from the beneficiary. The combined charge is at most 200 bps (2%).floor(granted * fee_bps / 10_000), so a fee is never rounded up against the payer; the batch total is the sum of per-position floors, not a floor of the sum, so what each position records and what the batch transfers agree.batch_createrejects a batch whose positions do not all share one token definition, or which names more than onebroker_recipient. Aggregated fee transfers cannot span tokens and a distinct broker is a distinct leg, so without both rules the fee overhead — and with it the maximum batch size — is data-dependent. With them it is at most two legs. Neither rule costs anything real: creators split allocations by token anyway, and a batch is created by one integrator.broker_recipient. Each position still records thefee_bpscharged, so per-position accounting and no-retroactivity are unchanged. The reason is the chained-call budget, not tidiness (§9).Governance activation. One admin-authority action changes the switch or rate, within
MAX_FEE_BPS, and emitsConfigUpdated. RFP-001 is awarded but not yet in the canonical stack, so the check sits behind the same small admin trait used in our accepted RFP-020 proposal, replaceable when the upstream implementation lands.Settingsholds the only mutable economic parameters; each position's terms remain fixed.11. Interfaces, events, and performance
Every transition emits a structured event —
ScheduleCreated,Claimed,Canceled,TrancheReleased,BeneficiaryTransferred,CancellationWaived— and admin changes emitConfigUpdated. Emission goes through anEventSinktrait with a stable, Borsh-encoded record:No receipt or log mechanism exists in the sources for the release we target, so M0 confirms that on the running sequencer rather than assuming it. Until one lands upstream, the sink may write to bounded, program-owned
EventPagePDAs for local development only; these are not represented as F6 compliance and are never the sole event channel of a public deployment. Sequence numbers are monotonic per subject, letting test tooling detect missing records: the owningVestingPosition(orSettingsfor config events) stores the next sequence, and the page index derives from it and a fixed records-per-page bound.vesting-eventsprovides Rust and TypeScript decoders and the schema specifies every payload.Claimedcontains only(position_id, amount, timestamp)and never the destination.For testnet and mainnet,
EventSinkmust emit through the canonical receipt/log mechanism. If that is still unavailable, the project pauses before M4 unless Logos accepts a written amendment to F6 naming the replacement and its discovery semantics. The schema, sequence numbers, decoders, IDL-facing types, and consumer API remain stable across sinks. Tests cover one receipt event per handler, schema round-trips, monotonic sequencing, page rollover, gap detection, and destination omission. Admin boundaries follow the RFP-001 pattern (a transferable, renounceable admin PDA) through the §10 trait; Logos controls mainnet admin custody.vesting-indexeringests those events and answers "positions for this beneficiary" and "schedules for this creator" — the two queries §2 explains events alone cannot serve. The SDK and mini-app read discovery through it and settlement directly from chain state, so a stale or absent indexer degrades browsing, never claiming.A TypeScript SDK covers the full lifecycle including public and private claims. An IDL-generated Rust CLI covers create, claim, cancel, signal a milestone, and query claimable. The Basecamp mini-app includes a Recipient view (position, total, claimable, next unlock, claim) and Creator view (create, list, cancel, signal milestone), plus the pre-claim confirmation summary, private-path gas check, and privacy disclosure, and ships with Figma designs, downloadable assets, and local build and Logos loading instructions. The pre-claim summary shows the claimable amount and an estimated fee from a dry run against the connected runtime, priced with the fee inputs that release exposes; where a release exposes none, it shows the last observed fee for the same operation, labelled as an observation rather than a quote.
A claim completes in one LEZ transaction. P2 asks for a compute-unit figure per operation, and the M0 cost spike establishes whether the pinned runtime exposes a CU meter. If it does,
docs/execution-costs.mdreports CU per operation exactly as P2 specifies. If not, we will not relabel another measurement as "CU": the report instead gives the metrics the runtime enforces or reproduces — RISC0 executor cycles, serialized transaction bytes, chained-call depth, accounts touched — and we ask Logos to accept those as the P2 substitute in writing, on the same basis as the F6 amendment. Every result names the runtime, SPEL and RISC0 versions, input fixture, command, and source commit; CI reruns a benchmark to detect drift. The report covers every operation andbatch_createat its measured maximum.Every testnet and mainnet deployment also publishes a reproducibility bundle: source freeze commit, dependency lockfile digest, guest ELF/ImageID, program address, deployment transaction and block height, network/runtime versions, and smoke-test transactions. The README includes the command to reproduce the build and records all values together, so an ImageID cannot be paired with a different source revision.
12. Requirement traceability
Because RFP-017 assigns no formal requirement IDs, we use F1–F6, U1–U7, R1–R4, P1–P2, S1–S6, Privacy 1–3, and Soft 1–2. CI runs from the first code milestone and is green on the default branch. The condensed matrix is below; the repository carries the full line-item mapping to named tests.
vesting-coreunit tests + Kani invariantsvesting-indexerdiscovery)docs/execution-costs.mdMilestones and Timeline
The eight milestones are paid on completion of their done-gates, with no upfront payment, and cover testnet 0.2, testnet 0.3, an independent audit and remediation gate, and mainnet. Milestone vesting is built first because it does not depend on custody integration; the time-based types are already buildable and could be brought forward if needed.
M0 is also the entry gate for the rest of the project, and is itself payable when the documented spikes are complete with honest pass/fail evidence. M1 may begin only after direct private settlement, whole-transaction rollback, PDA-authorized vault debit, and a viable atomic single/batch funding shape have passed on the pinned runtime, and after F6 is resolved in writing. Three of the four are already evidenced in the sources, so that part of the gate is reproduction rather than an open question; direct private settlement is the one genuinely unproven. If an entry check fails, the project pauses; no later milestone begins or becomes payable until Logos approves a revised scope, budget, and schedule. A mocked transfer shim cannot satisfy this gate.
Why F6 sits at this gate and not at M4. We already know no canonical event mechanism exists, and events are a hard requirement. Leaving it to the M4 public-deployment gate would mean M0–M3 — the large majority of the budget, and every line of the implementation — could complete and be paid before anyone established that the result can satisfy F6 or deploy publicly. So M1 does not begin until Logos either accepts a written F6 amendment naming the replacement and its discovery semantics, or commits to an upstream delivery plan and date. M1–M3 then run on development event pages against whichever was agreed, and those pages are never represented as F6 compliance.
vesting-corewith cliff+linear, continuous, and tranche unlock math;initializeforSettings; milestone create/batch/signal/cancel/waive handlers; development event pages and decoders; Kani and proptest invariants for all three policies; donation, underfunding, post-cancel-signal, failed-batch-leg testsSettingsinitializes once and rejects re-initialization; event schema round-trips; signaling is idempotent and rejected after cancellationvesting-indexer(event ingest, query by beneficiary and creator); atomicity and concurrency tests; SDK target checks; execution-cost and batch limitsdocs/execution-costs.mdpublishedlogos-docs, and launchpad-integration documentationProposed schedule: 12 active weeks, at the top of the RFP's 10–12-week estimate. M0–M3 account for 9 weeks of product development; the remaining 3 cover testnet 0.2 and its documentation, testnet 0.3, audit support/remediation, and mainnet. The 12 weeks count M6 at its nominal 0.5 week; the ten-day remediation allowance depends on what the audit finds and does not change the amount requested. M1 fits in two and a half weeks because its scope is a pure library plus handlers against a state machine this proposal already specifies in full. M3 fits in two because the unlock math for all three types is machine-checked in M1 (§3: one shared layout, so time-based types add handlers rather than a second state machine), and because documentation sits in M4 alongside the deployment it describes. M0–M4 can begin against the live testnet 0.2. M5 and M6 dates depend on Logos releasing testnet 0.3 and scheduling the audit; M7 on the audit gate and mainnet release (targeted early 2027 [3]).
Timeline assumptions and risk. The vault-authority and initialize-and-fund paths are evidenced in the runtime sources, so the estimate assumes M0 reproduces them rather than discovering them; if the running release differs, the fallback preparation step and shim switchover absorb it. The two results the sources do not settle — direct private settlement and the measured batch ceiling — are both M0 outputs. The estimate further assumes each testnet line keeps a stable ABI and token-program image. Because the pinned token program id is a compile-time constant and a
ProgramIdis the image id of the bytecode (§2), any release changing the token program also changes Moira's image id, program address, and every derived PDA — it cannot be swapped under us silently, but neither can it be adopted without redeploying. The same mechanism gives each network a distinct program identity, since admin and treasury constants differ per network. A breaking release, or a token-program change mid-line, would require a separately estimated migration. Material platform changes will be re-planned with Logos.External audit. M6 is a mandatory independent-audit and remediation gate before M7. Selecting, contracting, scheduling, and paying the auditor sit with Logos and are outside this request; our audit support and remediation engineering are included. M6 covers the claimable math, escrow and claim atomicity, cancellation and milestone signaling, beneficiary-transfer authorization, fee/admin controls, and the public/private claim boundary. Equilibrium remediates every critical and high finding in Moira's own code at no additional engineering charge, up to ten engineering days included in this budget. If closing them credibly needs more, we complete the work under a scope and fee re-estimated with Logos; the allowance bounds the budget, not the commitment.
What "closed" means. A finding is closed when the fix ships with evidence that it no longer reproduces — a test that previously triggered and now does not. Remediation covers the report's findings and any regression our own fixes introduce; genuinely new unrelated findings are separately scoped. Two re-review rounds are included. M6 is complete when no critical or high finding remains open on that basis, and if neither the auditor nor Logos disputes the evidence within ten business days it is treated as accepted, so neither side waits on the other — bounding the milestone by a deliverable we control rather than a third party's calendar. It does not relax the safety condition: M7 still requires that no critical or high finding is open. Findings rooted in the LEZ runtime, SPEL, or the token program rather than Moira are reported upstream with a reproduction and mitigated program-side where possible; the upstream fix is outside scope. Medium findings must be closed or explicitly accepted by Logos with written rationale; low/informational findings are triaged into the public tracker or accepted with rationale.
Total Requested Budget (USD)
to be agreed between the parties
Relevant Experience
Our experience spans SVM programs, token custody, DeFi security, wallet front ends, privacy engineering, and formal and property-based testing. Public work is linked; confidential engagements are described at the level we may disclose. We are also delivering Kanon, the accepted RFP-020 RedStone oracle adaptor, on the same LEZ / SPEL / RISC0 stack (proposal #117).
SVM / Solana-native program engineering
Privacy-preserving protocol work
Formal and property-based verification
Production wallet / DeFi front-end
Regulated token engineering
Post-Delivery Plan
TimeSourceswitch to SPEL'sClockContextaccessor when it merges, and integration support for the first launchpad (RFP-015/016) and treasury integrators.H(beneficiary_pk, salt)and the beneficiary proves knowledge of the preimage and entitlement in a RISC0 guest, revealing only(position_id, amount). This depends on the still-open LP-0003 and would need a separate RFP. A further version could hide claim amounts with a Pedersen-committedwithdrawntotal.Sources
All links checked 2026-08-20/21.
Permissions and Consent
Program Requirements