85 Commits

Author SHA1 Message Date
r4bbit
c528d85a2b feat(twap-oracle): implement PublishPrice with tick-to-price conversion and tail extrapolation
Add PublishPrice — a permissionless instruction that computes the TWAP over a
PriceObservations buffer, extrapolated to the current time, and writes it to the
consumer-facing OraclePriceAccount.

The stored body averages [t1, t2] (t1 = oldest valid entry, t2 = most recent),
needing no boundary search since each buffer is calibrated to one window_duration.
The final segment from t2 to `now` is extrapolated from the live tick in the
CurrentTickAccount (added as a fourth account), mirroring Uniswap's
OracleLibrary.consult. This keeps the published timestamp = now truthful: an
unchanged price yields a fresh stamp and the correct value, and a republish picks
up a since-reported move instead of freezing the pre-move average.

The live tick is only credited since it was written, so the tail is split at the
current tick's last_updated:

    boundary     = clamp(current_tick.last_updated, t2.ts, now)
    clamped_tick = last_recorded_tick + clamp(current_tick - last_recorded_tick, ±MAX_TICK_DELTA)
    cum_now      = t2.tick_cumulative
                 + last_recorded_tick * (boundary - t2.ts)   // before the live tick took effect
                 + clamped_tick       * (now - boundary)      // live tick, only since last_updated
    twap_tick    = (cum_now - t1.tick_cumulative) / (now - t1.ts)   // floor (div_euclid)

Splitting at last_updated stops a tick written moments before publish from being
smeared across a stale gap and inflating a supposedly fresh TWAP. The live-tick
segment is clamped against last_recorded_tick by MAX_TICK_DELTA — the same bound
RecordTick applies — capping how far a current-tick move can shift the result. A
zero-length tail (now == t2.ts) leaves the pure stored-window average.

If fewer than two observations exist the call is a silent no-op, leaving the price
account at timestamp = 0 (the uninitialized signal consumers reject). While young,
the TWAP covers the available span, which may be shorter than the window.

The TWAP tick is converted to a price ratio via the Uniswap v3 sqrtPriceX96
representation (pure integer, zkVM-safe), stored as a Q64.64 in
OraclePriceAccount.price — source-agnostic, no tick framing leaks into the standard.
Out-of-range ticks clamp; ratios above 2^64 saturate at u128::MAX. Adds
PRICE_FRACTIONAL_BITS = 64; removes the placeholder TWAP_PRICE_BIAS encoding.

Closes #117
2026-06-23 16:12:12 +02:00
r4bbit
03345db803 refactor(pda): use descriptive string seeds for PDA derivation
Replace the hardcoded numeric byte-stream seeds ([0; 32], [1; 32], ...)
used for domain separation in PDA derivation with descriptive byte-string
constants, mirroring the AMM config account's existing b"CONFIG" seed.

  amm:         [0; 32] -> b"LIQUIDITY_TOKEN"
               [1; 32] -> b"LP_LOCK_HOLDING"
  stablecoin:  [0; 32] -> b"POSITION"
               [1; 32] -> b"POSITION_VAULT"
  twap_oracle: [2; 32] -> b"PRICE_OBSERVATIONS"
               [3; 32] -> b"ORACLE_PRICE_ACCOUNT"
               [4; 32] -> b"CURRENT_TICK_ACCOUNT"

Since the seeds are now variable-length, each compute_*_pda_seed function
builds its hash input with a Vec and extend_from_slice instead of a
fixed-size buffer with offset writes.

Closes #146
2026-06-22 14:12:13 +02:00
r4bbit
0e2c5f9329 feat(amm): refresh TWAP current tick on every reserve-mutating instruction
Make swap_exact_input, swap_exact_output, add_liquidity, remove_liquidity,
and sync_reserves keep the pool's TWAP current tick in sync with its
reserves. Each now takes the current-tick and clock accounts, reads the
TWAP program ID from the config account, validates the clock account and
the current-tick PDA, and after computing the post-op pool chains an
UpdateCurrentTick to the oracle carrying the post-op spot price, with the
pool passed as the authorized price source via its pool PDA seed.

sync_reserves additionally now takes the config account so it can resolve
the TWAP program ID and gate on initialization, consistent with the other
instructions.

The invariant current_tick == tick(reserves) therefore holds after every
operation. Proportional add/remove preserve the price, so the tick is
unchanged for them, but the refresh still runs and lands on the correct
value.
2026-06-22 12:45:55 +02:00
r4bbit
b997ca678e feat(amm): bootstrap pool TWAP current-tick account at pool creation
Extend new_definition to also create the pool's TWAP current-tick account
via a chained CreateCurrentTickAccount, so a pool and its price feed are
born together. The opening tick is derived on-chain from the pool's own
reserves (reserve_b / reserve_a as Q64.64), not caller-supplied, so it
cannot be forged. The pool is passed in its post-claim state and authorized
as the price source via its pool PDA seed.

Add spot_price_q64_64 to amm_core (not the oracle): the reserves -> price
mapping is the price source's concern; the oracle only converts price to a
tick.
2026-06-22 11:02:22 +02:00
r4bbit
4e4338945d feat(amm): create TWAP price observations on behalf of the pool
Add a `CreatePriceObservations` instruction that registers a TWAP
price-observations account for a pool over a time window, via a chained
call to the configured TWAP oracle program. The pool acts as the price
source: the AMM authorizes it with its pool PDA seed so the oracle ties
the feed to that pool.

The feed's initial tick is read from the pool's authoritative
`CurrentTickAccount` (validated against its pool-derived PDA) rather than
being supplied by the caller, so the feed cannot be seeded at a forged
price — mirroring what `RecordTick` does. The clock is verified to be the
canonical 1-block LEZ clock, and creation is rejected if the observations
account already exists.

To support the chained call, `AmmConfig` and the `Initialize` instruction
are extended with a `twap_oracle_program_id` that the instruction reads.
2026-06-22 09:47:45 +02:00
r4bbit
1d9e3dcb49 feat(amm): add admin authority and UpdateConfig instruction
Add an admin authority to the AMM config so configuration can be changed
after initialization. AmmConfig gains an `authority` field, set by
Initialize, and a new UpdateConfig instruction lets that admin change
config values.

UpdateConfig is access-controlled: the authority account must equal the
stored config.authority and be passed authorized (signed). Both fields are
optional — token_program_id updates the chained-call token program, and
new_authority transfers admin control to a different account. Without this
gate any caller could repoint the AMM at a malicious token program.
2026-06-19 16:09:51 +02:00
Andrea Franz
222c01e7d6 docs(stablecoin): remove minimum time between fee acrruals 2026-06-19 10:54:43 +02:00
Andrea Franz
5298c43dad docs(stablecoin): add refresh_globals and use milliseconds everywhere 2026-06-19 10:54:43 +02:00
Andrea Franz
b6c53e096d docs(stablecoin): add more examples and debt accrual terminology 2026-06-19 10:54:43 +02:00
Andrea Franz
78f61f43e2 docs(stablecoin): fix rate adjustment math 2026-06-19 10:54:43 +02:00
Andrea Franz
b3369d2a3d docs(stablecoin): improve after comments 2026-06-19 10:54:43 +02:00
Andrea Franz
63311157c8 docs(stablecoin): fix sections references 2026-06-19 10:54:43 +02:00
Andrea Franz
7c62668731 docs(stablecoin): add stablecoin design docs 2026-06-19 10:54:43 +02:00
r4bbit
3624ea1451 feat(amm): add Initialize instruction with config-gated chained calls
Introduce a singleton AMM configuration account, a PDA derived from the
constant "CONFIG" seed, created once via a new `Initialize` instruction.
The config stores the Token Program ID the AMM issues every chained call
to, replacing the previous behavior of trusting the program owner of a
caller-supplied holding.

The config account's existence is the Program's initialization gate: the
chained-call instructions (new_definition, add_liquidity, remove_liquidity,
swap_exact_input, swap_exact_output) now take the config as their first
account, validate it against `compute_config_pda(self_program_id)`, and
read the Token Program ID from it on demand — rejecting calls until the
Program is initialized. Vaults and user holdings are asserted to match the
configured Token Program. sync_reserves is left ungated, as it cannot act
on a pool that could not have existed before initialization.

- amm_core: AmmConfig type, compute_config_pda/_seed, Initialize variant
- amm: initialize.rs + config threading through chained-call instructions
- guest: initialize instruction; config + self_program_id on gated calls
- tests: config fixtures, init-gate unit tests, end-to-end Initialize VM test
2026-06-18 16:11:28 +02:00
r4bbit
e8fe634a2c feat(twap-oracle): implement RecordTick instruction
Add RecordTick — a permissionless instruction that reads the current tick
from a CurrentTickAccount and advances a PriceObservations ring buffer.

Authorization is implicit: both PDAs are verified against price_source_id,
so the tick can only have been written by whoever controls that price source.
A sampling guard silently no-ops if less than `window_duration /
OBSERVATIONS_CAPACITY`` ms have elapsed, allowing keepers to call blindly on
every block. Tick-delta truncation clamps the per-observation delta to
`MAX_TICK_DELTA (9 116)` before advancing tick_cumulative, with
last_recorded_tick tracking the untruncated position for the next delta.

Also switches ObservationEntry.tick_cumulative to use elapsed milliseconds
rather than seconds.

Closes #116
2026-06-17 14:46:30 +02:00
r4bbit
3285d5787e feat(twap-oracle): implement CreateCurrentTickAccount and UpdateCurrentTick
Add CurrentTickAccount — an oracle-owned PDA (one per price source) that holds
the latest raw tick written by the price source and a timestamp. The price source
calls UpdateCurrentTick after each price-changing operation; anyone can then call
RecordTick (upcoming) to advance the PriceObservations accumulator without
requiring the price source to be present. PDA is derived from price_source_id
only (no window) since a single current tick serves all time windows.

Add price_to_tick(price: u128) -> i32 to twap_oracle_core: isqrt(price << 128)
-> sqrtPriceX96 -> get_tick_at_sqrt_ratio. The sqrtPriceX96 is clamped to
>= MIN_SQRT_RATIO so a zero/dust price maps to MIN_TICK rather than erroring.

Add a pure-integer integer_sqrt(U256) (bit-by-bit, no floating point): ruint's
root is gated behind its std feature and seeds with f64, neither available in
the guest. Uses wrapping_shr for the digit loop (checked_shr rejects the
intended lossy shifts).

Pull in uniswap_v3_math (for get_tick_at_sqrt_ratio) and alloy-primitives
(U256), with ruint pinned to =1.17.0 — 1.18 raised its MSRV to rustc 1.90,
above the risc0 guest toolchain's 1.88.
2026-06-16 16:51:26 +02:00
r4bbit
b0ac30039b fix(idl-gen): sort types array for deterministic output
The IDL `types` array was emitted in HashMap iteration order by
spel-framework-core, which is non-deterministic across processes. Two
independent regenerations of the same source could therefore disagree on
type ordering, producing different bytes.

This is what makes the check-idl CI job flaky: a PR's committed IDL is
generated locally with one ordering, but CI regenerates with a different
ordering and the diff fails — including PRs that were green when posted
then breaking main after merge.

Sort the top-level `types` array by name before serializing so output is
byte-stable regardless of where idl-gen runs. Enable serde_json's
`preserve_order` feature so the Value round-trip preserves struct-field
key order (otherwise all keys would alphabetize and churn every artifact).

Only top-level `types` was unstable; variants and fields already follow
source order. Committed artifacts are unchanged — they happened to already
be in sorted order.
2026-06-16 16:21:07 +02:00
r4bbit
7461c9552b feat(twap-oracle): implement CreateOraclePriceAccount instruction
Adds the CreateOraclePriceAccount instruction to the TWAP oracle program.
The instruction initialises a canonical OraclePriceAccount PDA for a given
price source and time window, seeding it with a non-zero initial price and
the current block timestamp so the account is immediately valid to consumers.

- PDA mirrors PriceObservations: derived from (oracle_program_id,
  price_source_id, window_duration) with a distinct seed constant, so each
  (source, window) pair maps to a distinct oracle price account that cannot
  collide with its corresponding observations account.

- source_id is not a parameter: it is always set to price_source.account_id.
  Accepting it as a free parameter would allow callers to register a price
  account that claims to represent a source it does not control. Deriving it
  from the authorized price source account closes that vector entirely.

- Authorization follows the same model as CreatePriceObservations:
  is_authorized = true on the price source proves the caller controls it; the
  PDA check ensures the supplied oracle price account address is the one
  derived from that specific source and window.

- The initial timestamp is read from the canonical 1-block LEZ clock
  (CLOCK_01_PROGRAM_ACCOUNT_ID), never from a caller-supplied value. The clock
  account_id is asserted, so a caller cannot substitute an account they
  control to forge the seeding timestamp.

- A zero price or zero timestamp is rejected at creation. Both are the
  "no valid price" sentinel consumers treat as unset, so an account must never
  be created in that state; the instruction asserts a non-zero initial_price
  and a non-zero clock timestamp.

- initial_price is a Q64.64 fixed-point value (real price = initial_price /
  2^64), matching the oracle price representation. The non-zero check rejects
  the sentinel but cannot validate scale — supplying a correctly-scaled value
  is the caller's responsibility.

Closes #129
2026-06-16 09:52:46 +02:00
r4bbit
3ce998c37c fix(twap_oracle): validate clock account
Ensures user controlled clock account is validated against constraints.
2026-06-09 14:41:03 +02:00
r4bbit
fe9d919299 feat(twap-oracle): implement CreatePriceObservations instruction
Adds the CreatePriceObservations instruction to the TWAP oracle program.
The instruction initialises a PriceObservations PDA for a given price
source account and time window, writing the initial tick and timestamp
as the first entry.

Key design decisions:

- Per-window accounts: each (price_source, window_duration) pair maps to
  a distinct PriceObservations PDA. The window duration is baked into the
  PDA seed so a single price source can support multiple TWAP windows
  (24h, 7d, 30d) at independent sampling rates without sharing a buffer.

- window_duration not stored on struct: it is implicit in the PDA address.
  Any reader that located the account already knows the window duration
  used to derive it. Storing it would be redundant.

- Authorization is implicit: the PriceObservations PDA is derived from
  the price source account ID, so is_authorized = true on the price source
  proves the caller controls it without a redundant authority field.

- Impersonation is prevented by the PDA check: passing a controlled price
  source with a victim's observations account ID fails immediately because
  the computed PDA (from the attacker's source) does not match.

Closes #126
2026-06-09 13:23:46 +02:00
r4bbit
7b1696f98e docs: update README and CLAUDE.md to reflect current state
- Rename amm-ui to amm in the Apps table
- Add "Running Apps" section with Nix flake setup and amm example
- Remove hardcoded Rust toolchain version; point to rust-toolchain.toml
- Replace raw cargo/clippy invocations with make targets (clippy, test, fmt, idl)
- Expand program listings in overview and workspace structure to cover
  all five programs: token, amm, ata, stablecoin, twap_oracle
- Add integration_tests and apps/amm to the workspace structure diagram
2026-05-26 16:42:18 +02:00
r4bbit
3622016e6c refactor: move programs into programs and UIs into apps
This refactors the repository structure as it has grown over time.
2026-05-26 14:05:52 +02:00
Andrea Franz
cdb53a4d0c feat(stablecoin): implement repay_debt (#93) 2026-05-26 10:31:52 +02:00
Andrea Franz
1ae2b325ff test(stablecoin): move chained-transfer coverage to integration tests 2026-05-26 10:31:52 +02:00
Andrea Franz
7da110a616 fix(stablecoin): leave position_post.program_owner default so the runtime sets it through Claim::Pda 2026-05-26 10:31:52 +02:00
Ricardo Guilherme Schmidt
a0a1e08dfb test(stablecoin): cover invalid withdraw transfer pre-states 2026-05-26 10:31:52 +02:00
Andrea Franz
eb7f44a98a feat(stablecoin): implement withdraw_collateral
closes #92
2026-05-26 10:31:52 +02:00
r4bbit
f4f61be322 chore(Makefile): add idl command to Makefile 2026-05-25 20:57:59 +02:00
Ricardo Guilherme Schmidt
291149b114 chore(twap_oracle): scaffold twap_oracle program
Closes #114
2026-05-25 20:04:33 +02:00
copilot-swe-agent[bot]
9efef64b54 Fix CI regex to use PCRE2 mode for non-greedy quantifier support
Agent-Logs-Url: https://github.com/logos-blockchain/lez-programs/sessions/65c1e83e-b757-457f-bd78-2073df6b2c1a

Co-authored-by: 0x-r4bbit <445106+0x-r4bbit@users.noreply.github.com>
2026-05-22 09:46:53 +02:00
r4bbit
cfa4bb1e36 chore: include stablecoin program in clippy task 2026-05-22 09:46:53 +02:00
r4bbit
20a947137c refactor: rename rust-toolchain file
Fixes #13
2026-05-22 09:46:53 +02:00
r4bbit
6fd87766c2 chore: adjust linting rules and introduce Makefile
This relaxes some of the rules introduced in the previous commit.
To simplify testing, building and formatting locally and on CI, we also
introduce a dedicated makefile
2026-05-22 09:46:53 +02:00
Ricardo Guilherme Schmidt
49d7f91ee5 chore(lint): add staged lint baseline 2026-05-22 09:46:53 +02:00
r4bbit
035f593f5e chore: update spel to v0.3.0 2026-05-19 08:40:35 +02:00
Ricardo Guilherme Schmidt
5229855d57 fix(ata)!: namespace accounts by token program
ATA accounts are now namespaced by token program, so callers must
explicitly pass the token_program_id when invoking ATA::Transfer.

BREAKING CHANGE: `Instruction::Transfer`, `Instruction::Burn`, `Instruction::Create` now requires a
`token_program_id` field. Any existing call site that omits it will
fail to compile.

Closes #83
2026-05-18 12:53:07 +02:00
r4bbit
29b4c01739 fix(integration_tests) remove no longer needed program ID 2026-05-18 12:22:06 +02:00
Ricardo Guilherme Schmidt
f8cbcc6956 fix(ata): lock down ATA::Transfer recipient contract
Enforce at the ATA layer that the recipient token holding is already
initialized, owned by the same token program as the sender ATA, decodes
to a valid `TokenHolding`, and points at the same token definition as
the sender. Align the core instruction doc and guest wrapper doc with
that contract, and cover the boundary with unit tests (default,
foreign-owned, malformed, mismatched-definition recipients, plus the
missing-owner-auth and happy paths) and end-to-end integration tests
(default and mismatched-definition recipients).

Without this, the downstream `token::Transfer` default-recipient
`Claim::Authorized` path was reachable through ATA, so integrators had
to reverse-engineer recipient semantics from token/runtime internals.
2026-05-13 13:32:09 +02:00
Ricardo Guilherme Schmidt
0b078b2dde fix(stablecoin): address open position review feedback 2026-05-13 12:23:20 +02:00
Ricardo Guilherme Schmidt
d6082d0c81 docs(stablecoin): improve documentation for OpenPosition instruction 2026-05-13 12:23:20 +02:00
Ricardo Guilherme Schmidt
e63d09f793 docs(stablecoin): list all five OpenPosition accounts and qualify size_of_val
The `Instruction::OpenPosition` doc claimed four required accounts but the
handler and IDL take five — the collateral token definition was missing.
Update the list to match the actual contract.

Also fully qualify `std::mem::size_of_val` in `From<&Position> for Data`
so the call no longer relies on Rust 1.80+ prelude additions for the
2021 edition.
2026-05-13 12:23:20 +02:00
Ricardo Guilherme Schmidt
f4f7b45bd4 feat(stablecoin): implement open_position
Adds the `open_position` instruction to the Stablecoin Program. The instruction
claims a per-owner `Position` PDA, initializes a collateral vault token holding
via a chained `Token::InitializeAccount` under the vault's PDA authority, and
moves `collateral_amount` from the user's holding into the vault with a chained
`Token::Transfer`. `Position` is persisted with `collateral_amount` and
`debt_amount = 0`; the debt path is deferred to `generate_debt`.

- Add `Position` struct, `OpenPosition` instruction variant, and
  `compute_position_pda{,_seed}` / `compute_position_vault_pda{,_seed}` helpers
  in `stablecoin_core` with domain-separated PDA seeds.
- Implement `open_position::open_position` mirroring the ATA `create` and AMM
  `new_definition` patterns: authorization and uninitialized-state asserts, PDA
  verification, and same-transaction chained `InitializeAccount` + `Transfer`.
- Wire the new instruction through the SPEL guest and regenerate the stablecoin
  IDL artifact.
- Cover the happy path, all assertion paths, and PDA determinism /
  non-collision in 11 new unit tests.
2026-05-13 12:23:20 +02:00
Andrea Franz
22b41bdb3d chore(amm-ui): reorganize liquidity page components 2026-05-12 16:11:24 +02:00
Andrea Franz
9375129c9e chore(amm-ui): add layout and reorganize swap UI files 2026-05-12 16:11:24 +02:00
Ricardo Guilherme Schmidt
8005c74e26 feat(token): verify definition ownership via self_program_id in initialize and mint
Pass `ctx.self_program_id` from `ProgramContext` into `initialize_account`
and `mint`, which now assert that the token definition account is owned by
the token program. This prevents callers from supplying a foreign-owned
account as the definition.

See https://github.com/logos-co/spel/issues/172
2026-05-12 16:10:40 +02:00
r4bbit
ceb8a4b597 chore: update spel
This updates the spel dependency, which introduces a breaking change.
To make reviewing changes easier from other changes, this update comes
in a separate commit.
2026-05-12 11:47:35 +02:00
r4bbit
f4a0aaf8d0 feat: make use of spel's [#account_type] directive
This annotates custom account data as [#account_type], to allow for deserializing abritrary LEZ account data.

Closes #49
2026-05-12 10:04:22 +02:00
r4bbit
e7a69f619f chore: update LEZ to v0.2.0-rc3 2026-05-12 08:46:36 +02:00
Andrea Franz
4178406fda feat(stablecoin): initial stablecoin scaffold
closes #86
2026-05-11 16:53:04 +02:00
Andrea Franz
6eed55d7e4 chore(amm-ui): add SlippageToleranceControl 2026-05-11 10:15:52 +02:00