Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0
Task Classification: Security defect remediation, cross-layer (audit-funded)
Affected Layers: contracts, backend, frontend (+ contracts)
Affected Paths: contracts/lending_pool/src/lib.rs, contracts/lending_pool/src/pool.rs, contracts/lending_pool/src/error.rs, contracts/lending_pool/src/test.rs, backend/src/services/poolQuoter.ts, backend/src/services/eventIndexer.ts, backend/src/routes/quotes.ts, backend/migrations/, frontend/src/hooks/useRedeemQuote.ts, frontend/src/components/RedeemForm.tsx, .github/workflows/ci.yml, docker-compose.yml
Severity: Critical (audit-funded tier)
Estimated Window: 72-96 hours
Technical Context & Monorepo Integration Failure
lending_pool mints and burns shares against a pooled asset. Current calc_shares_to_mint and calc_assets_to_return derive share price as total_assets / total_shares, where total_assets is read from token::Client::balance(pool_address) at call time, and neither deposit nor redeem accepts a client-supplied bound. Two properties follow: share price is a function of the live token balance, and a caller commits to whatever price exists when the invoking transaction executes. Both are exploitable within a single ledger because Soroban orders operations within a transaction set and a token transfer to the pool address mutates balance() without minting shares.
Concrete extraction walkthrough:
- Pool state:
total_shares = 0, contract token balance = 0.
- Attacker calls
deposit(attacker, 1) and receives 1 share (first-depositor path, shares = assets when supply is zero).
- Attacker transfers
1_000_000 of the asset directly to the pool address via the token contract. balance() is now 1_000_001; total_shares is still 1. Share price jumped to 1_000_001 assets per share with no supply change.
- Victim
deposit(victim, 1_000_000) computes shares = 1_000_000 * 1 / 1_000_001 = 0 after integer floor. The victim receives 0 shares while the balance rises to 2_000_001.
- Attacker
redeem(attacker, 1) computes assets = 1 * 2_000_001 / 1 = 2_000_001 and drains the victim's deposit plus the donation.
A contract-only bound (reject shares == 0) blocks step 4 but not the general case where the victim receives a nonzero but under-valued share count, and it cannot express the caller's price expectation. A backend-only quote is advisory and cannot bind on-chain settlement. A frontend-only guard is bypassed by any direct RPC caller. The invariant must be enforced in lending_pool, quoted by backend, and surfaced by frontend, because the trust boundary (the settling contract), the pricing oracle (indexed events plus simulation), and the user's tolerance input live in three separate layers. Drift between the price the backend quotes and the price the contract settles at is itself the attack surface, so the same accounting definition must be shared across all three.
Core Component Invariants & Code Paths
Smart Contract Infrastructure. Replace balance-derived pricing with internal accounting. Add DataKey::TotalManagedAssets (persistent) mutated only inside deposit, redeem, and accrual paths; never read token.balance for pricing. Introduce constants VIRTUAL_SHARES and VIRTUAL_ASSETS (decimals offset, 10^OFFSET with OFFSET = 3) applied in both directions:
calc_shares_to_mint(assets) returns assets * (total_shares + VIRTUAL_SHARES) / (total_managed_assets + VIRTUAL_ASSETS), rounded down.
calc_assets_to_return(shares) returns shares * (total_managed_assets + VIRTUAL_ASSETS) / (total_shares + VIRTUAL_SHARES), rounded down.
Extend signatures to deposit(env, from, assets, min_shares_out) and redeem(env, from, shares, min_assets_out); add read-only preview_deposit and preview_redeem returning the same math without state change. Add PoolError::MinSharesNotMet, PoolError::MinAssetsNotMet, PoolError::ZeroShares, PoolError::InsufficientLiquidity. Emit an accounting event on every mutation: env.events().publish((symbol_short!("price"),), (ledger_seq, total_managed_assets, total_shares)), plus existing deposit and redeem topics. Post-change invariants: pricing is independent of unsolicited transfers (donation resistance); mint rounds shares down and redeem rounds assets down, both in the pool's favor; a settlement failing its bound reverts with a typed error rather than executing at a shifted price.
Backend/API Layer. Add backend/src/routes/quotes.ts exposing GET /api/pools/:poolId/quote?action=redeem|deposit&amount=<i128>. backend/src/services/poolQuoter.ts reads pool storage via Soroban RPC getLedgerEntries, calls simulateTransaction against preview_redeem/preview_deposit, and returns { expected, min_bound, assets_per_share, ledger_seq, ttl_ms } where min_bound = expected * (10_000 - slippage_bps) / 10_000. Extend eventIndexer.ts (Soroban RPC getEvents) to ingest the price topic into a new table so quotes reconcile against the last settled price and reject on ledger skew. Post-change invariants: the quote's math is byte-for-byte the contract math (shared fixed-point rounding); a quote carries the ledger_seq it was computed at; the endpoint returns 409 when the indexed head has advanced past the simulated ledger.
Frontend Client. frontend/src/hooks/useRedeemQuote.ts (TanStack Query) fetches the quote and subscribes to the existing SSE stream to invalidate on new price events. frontend/src/components/RedeemForm.tsx computes min_assets_out from NEXT_PUBLIC_DEFAULT_SLIPPAGE_BPS, blocks submission when the live quote deviates beyond tolerance, and passes the bound into the contract invocation. New env vars: NEXT_PUBLIC_DEFAULT_SLIPPAGE_BPS, NEXT_PUBLIC_QUOTE_TTL_MS. Post-change invariants: no transaction is signed without a min_assets_out/min_shares_out derived from a quote younger than QUOTE_TTL_MS; an SSE price update stale-marks the form and forces re-quote.
Root Configuration & Orchestration. Add a cross-layer property-test target and an e2e container job to .github/workflows/ci.yml, invoked per layer (no root workspace exists). The e2e job uses docker-compose.yml to stand up postgres:16, the backend, and a local Soroban network, then runs the round-trip extraction scenario against real settlement. New migration file registered under backend/migrations and gated by the existing migration-check job. Post-change invariant: the extraction walkthrough above is a failing-then-passing regression fixture executed by root-initiated CI.
Value-conservation invariant (adversarial ordering). For any operation sequence within one ledger, let A = total_managed_assets, S = total_shares. Define P = (A + VIRTUAL_ASSETS) / (S + VIRTUAL_SHARES). The following must hold under any attacker-chosen ordering including interleaved direct token transfers:
- Donation independence: a raw token transfer to the pool leaves
A, S, and P unchanged.
- Round-trip non-profitability:
deposit(d) immediately followed by redeem(minted_shares) yields assets_out <= d.
- Monotonic price:
P is non-decreasing except through explicit accrual or loss-realization paths; no mint/burn pair lowers P.
- Bound safety: every settlement satisfies
assets_out >= min_assets_out and shares_out >= min_shares_out or reverts.
Verification & Acceptance Criteria
Suggested Execution Path
Phase 1 - Contract accounting, virtual offset, slippage params (24-32h). Deliverables: internal TotalManagedAssets accounting; VIRTUAL_SHARES/VIRTUAL_ASSETS in both calc paths; extended deposit/redeem signatures with bounds; preview_* functions; new PoolError variants; price event. Exit check: contract property tests and the donation/first-depositor cases pass under overflow-checks=true.
Phase 2 - Backend quote endpoint, indexer, migration (18-24h). Deliverables: poolQuoter.ts, quotes.ts route, eventIndexer.ts price ingestion, share-price migration. Exit check: quote math matches contract simulation byte-for-byte; migrate:up/migrate:down complete without error; typecheck, lint, test green.
Phase 3 - Frontend slippage guard (14-18h). Deliverables: useRedeemQuote hook, RedeemForm bound derivation, SSE-driven re-quote, new NEXT_PUBLIC_* env vars. Exit check: test:e2e proves no signature without a fresh bound.
Phase 4 - Root cross-layer invariant tests and CI (16-22h). Deliverables: e2e compose job, per-layer property-test wiring in ci.yml, extraction regression fixture, state-dump attachments. Exit check: root-initiated CI runs the extraction scenario red on the pre-fix commit and green on the branch.
Contributor coordination and questions: https://t.me/+DOylgFv1jyJlNzM0
Task Classification: Security defect remediation, cross-layer (audit-funded)
Affected Layers: contracts, backend, frontend (+ contracts)
Affected Paths:
contracts/lending_pool/src/lib.rs,contracts/lending_pool/src/pool.rs,contracts/lending_pool/src/error.rs,contracts/lending_pool/src/test.rs,backend/src/services/poolQuoter.ts,backend/src/services/eventIndexer.ts,backend/src/routes/quotes.ts,backend/migrations/,frontend/src/hooks/useRedeemQuote.ts,frontend/src/components/RedeemForm.tsx,.github/workflows/ci.yml,docker-compose.ymlSeverity: Critical (audit-funded tier)
Estimated Window: 72-96 hours
Technical Context & Monorepo Integration Failure
lending_poolmints and burns shares against a pooled asset. Currentcalc_shares_to_mintandcalc_assets_to_returnderive share price astotal_assets / total_shares, wheretotal_assetsis read fromtoken::Client::balance(pool_address)at call time, and neitherdepositnorredeemaccepts a client-supplied bound. Two properties follow: share price is a function of the live token balance, and a caller commits to whatever price exists when the invoking transaction executes. Both are exploitable within a single ledger because Soroban orders operations within a transaction set and a token transfer to the pool address mutatesbalance()without minting shares.Concrete extraction walkthrough:
total_shares = 0, contract token balance= 0.deposit(attacker, 1)and receives1share (first-depositor path,shares = assetswhen supply is zero).1_000_000of the asset directly to the pool address via the token contract.balance()is now1_000_001;total_sharesis still1. Share price jumped to1_000_001assets per share with no supply change.deposit(victim, 1_000_000)computesshares = 1_000_000 * 1 / 1_000_001 = 0after integer floor. The victim receives0shares while the balance rises to2_000_001.redeem(attacker, 1)computesassets = 1 * 2_000_001 / 1 = 2_000_001and drains the victim's deposit plus the donation.A contract-only bound (reject
shares == 0) blocks step 4 but not the general case where the victim receives a nonzero but under-valued share count, and it cannot express the caller's price expectation. A backend-only quote is advisory and cannot bind on-chain settlement. A frontend-only guard is bypassed by any direct RPC caller. The invariant must be enforced inlending_pool, quoted bybackend, and surfaced byfrontend, because the trust boundary (the settling contract), the pricing oracle (indexed events plus simulation), and the user's tolerance input live in three separate layers. Drift between the price the backend quotes and the price the contract settles at is itself the attack surface, so the same accounting definition must be shared across all three.Core Component Invariants & Code Paths
Smart Contract Infrastructure. Replace balance-derived pricing with internal accounting. Add
DataKey::TotalManagedAssets(persistent) mutated only insidedeposit,redeem, and accrual paths; never readtoken.balancefor pricing. Introduce constantsVIRTUAL_SHARESandVIRTUAL_ASSETS(decimals offset,10^OFFSETwithOFFSET = 3) applied in both directions:calc_shares_to_mint(assets)returnsassets * (total_shares + VIRTUAL_SHARES) / (total_managed_assets + VIRTUAL_ASSETS), rounded down.calc_assets_to_return(shares)returnsshares * (total_managed_assets + VIRTUAL_ASSETS) / (total_shares + VIRTUAL_SHARES), rounded down.Extend signatures to
deposit(env, from, assets, min_shares_out)andredeem(env, from, shares, min_assets_out); add read-onlypreview_depositandpreview_redeemreturning the same math without state change. AddPoolError::MinSharesNotMet,PoolError::MinAssetsNotMet,PoolError::ZeroShares,PoolError::InsufficientLiquidity. Emit an accounting event on every mutation:env.events().publish((symbol_short!("price"),), (ledger_seq, total_managed_assets, total_shares)), plus existingdepositandredeemtopics. Post-change invariants: pricing is independent of unsolicited transfers (donation resistance); mint rounds shares down and redeem rounds assets down, both in the pool's favor; a settlement failing its bound reverts with a typed error rather than executing at a shifted price.Backend/API Layer. Add
backend/src/routes/quotes.tsexposingGET /api/pools/:poolId/quote?action=redeem|deposit&amount=<i128>.backend/src/services/poolQuoter.tsreads pool storage via Soroban RPCgetLedgerEntries, callssimulateTransactionagainstpreview_redeem/preview_deposit, and returns{ expected, min_bound, assets_per_share, ledger_seq, ttl_ms }wheremin_bound = expected * (10_000 - slippage_bps) / 10_000. ExtendeventIndexer.ts(Soroban RPCgetEvents) to ingest thepricetopic into a new table so quotes reconcile against the last settled price and reject on ledger skew. Post-change invariants: the quote's math is byte-for-byte the contract math (shared fixed-point rounding); a quote carries theledger_seqit was computed at; the endpoint returns409when the indexed head has advanced past the simulated ledger.Frontend Client.
frontend/src/hooks/useRedeemQuote.ts(TanStack Query) fetches the quote and subscribes to the existing SSE stream to invalidate on newpriceevents.frontend/src/components/RedeemForm.tsxcomputesmin_assets_outfromNEXT_PUBLIC_DEFAULT_SLIPPAGE_BPS, blocks submission when the live quote deviates beyond tolerance, and passes the bound into the contract invocation. New env vars:NEXT_PUBLIC_DEFAULT_SLIPPAGE_BPS,NEXT_PUBLIC_QUOTE_TTL_MS. Post-change invariants: no transaction is signed without amin_assets_out/min_shares_outderived from a quote younger thanQUOTE_TTL_MS; an SSE price update stale-marks the form and forces re-quote.Root Configuration & Orchestration. Add a cross-layer property-test target and an e2e container job to
.github/workflows/ci.yml, invoked per layer (no root workspace exists). The e2e job usesdocker-compose.ymlto stand uppostgres:16, the backend, and a local Soroban network, then runs the round-trip extraction scenario against real settlement. New migration file registered underbackend/migrationsand gated by the existingmigration-checkjob. Post-change invariant: the extraction walkthrough above is a failing-then-passing regression fixture executed by root-initiated CI.Value-conservation invariant (adversarial ordering). For any operation sequence within one ledger, let
A=total_managed_assets,S=total_shares. DefineP = (A + VIRTUAL_ASSETS) / (S + VIRTUAL_SHARES). The following must hold under any attacker-chosen ordering including interleaved direct token transfers:A,S, andPunchanged.deposit(d)immediately followed byredeem(minted_shares)yieldsassets_out <= d.Pis non-decreasing except through explicit accrual or loss-realization paths; no mint/burn pair lowersP.assets_out >= min_assets_outandshares_out >= min_shares_outor reverts.Verification & Acceptance Criteria
cargo build --manifest-path contracts/Cargo.toml(overflow-checks on),npm --prefix backend run build,npm --prefix frontend run buildall pass.cargo testincontracts/lending_poolincludes: donation-independence case, first-depositor virtual-offset case, redeem/deposit bound rejection returningPoolError::MinAssetsNotMet/MinSharesNotMet, and a property test over randomized sequences asserting round-trip non-profitability.npm --prefix backend testcoverspoolQuotermath parity against contract simulation and the409ledger-skew path;npm --prefix backend run typecheckandlintpass.migrate:upthenmigrate:downcomplete without error under themigration-checkjob; no conflict with existing migration ordering.test:e2e(Playwright) asserts the form blocks submission on stale quote and injects a nonzeromin_assets_out.redeemreverts.total_managed_assets,total_shares,assets_per_share) for the extraction scenario; simulation-vs-settlement diff showing zero drift.Suggested Execution Path
Phase 1 - Contract accounting, virtual offset, slippage params (24-32h). Deliverables: internal
TotalManagedAssetsaccounting;VIRTUAL_SHARES/VIRTUAL_ASSETSin both calc paths; extendeddeposit/redeemsignatures with bounds;preview_*functions; newPoolErrorvariants;priceevent. Exit check: contract property tests and the donation/first-depositor cases pass underoverflow-checks=true.Phase 2 - Backend quote endpoint, indexer, migration (18-24h). Deliverables:
poolQuoter.ts,quotes.tsroute,eventIndexer.tspriceingestion, share-price migration. Exit check: quote math matches contract simulation byte-for-byte;migrate:up/migrate:downcomplete without error;typecheck,lint,testgreen.Phase 3 - Frontend slippage guard (14-18h). Deliverables:
useRedeemQuotehook,RedeemFormbound derivation, SSE-driven re-quote, newNEXT_PUBLIC_*env vars. Exit check:test:e2eproves no signature without a fresh bound.Phase 4 - Root cross-layer invariant tests and CI (16-22h). Deliverables: e2e compose job, per-layer property-test wiring in
ci.yml, extraction regression fixture, state-dump attachments. Exit check: root-initiated CI runs the extraction scenario red on the pre-fix commit and green on the branch.