API

The complete public interface of the five Soroban contracts that make up Agama on Stellar, taken from the deployed source rather than written alongside it. For the reasoning behind these entry points, which guard exists because of which failure, read Soroban Contracts. For the live addresses, read Deployments. The source is at github.com/agamafinance/agama-soroban.

How to read this page

Signatures are given as they appear in the contract, minus the leading e: Env that every Soroban function takes. The Stellar CLI takes each parameter as a named flag, so deposit(from: Address, amount: i128) is invoked as --from G... --amount 10000000.

Amounts are i128 at 7 decimals throughout, matching USDC on Stellar: 10000000 is one unit. Basis points are u32, where 10000 is 100%.

Caller is the address that must authorize the invocation, which is not always an address the function takes as a parameter. Where it says stored admin or stored Engine, the contract reads the address out of its own state and requires that one, so there is no argument to substitute. Where it says anyone, the function requires no authorization at all, and in each of those cases the caller chooses nothing: the arguments cannot redirect funds or select a beneficiary.

Errors are contract errors, returned rather than trapped, and each contract owns a numbered range. A call that fails returns Error(Contract, #NNN). The SEP-41 token functions are the exception: they trap with codes 1 to 4 from the shared token module rather than returning a typed error.

__constructor runs inside the transaction that deploys the contract and cannot be called again. It replaced a separate initialize entry point, which left a window between the deploy and the wiring in which anyone could send the same call naming themselves admin.

Vault

Custody, issuance and the withdrawal queue. It is the only contract that holds USDC. Errors are in the 300 range.

Capital in and out

FunctionWhat it doesCallerErrors
deposit(from: Address, amount: i128) -> i128Takes amount USDC and mints the same amount of agUSD to from. Returns what was minted. The USDC lands before the mint, so a transfer that fails takes the whole call with it.fromPaused 303, InvalidAmount 304, NotInitialized 301
request_withdrawal(from: Address, amount: i128) -> u64Burns amount agUSD immediately and joins the FIFO queue. Returns the claim id. The burn is at request time, not at claim time, which is what stops a queued position being sold or staked while it waits.fromPaused 303, InvalidAmount 304, BelowMinWithdrawal 305
claim_withdrawal(from: Address, claim_id: u64)Pays a claim to its owner. The claim must be at the head of the queue, or deferred, and must not already be paid.from, and it must be the recorded ownerClaimNotFound 306, NotClaimOwner 307, AlreadyClaimed 308, NotAtQueueHead 309, InsufficientLiquidity 310, PaymentRejected 322
settle_withdrawal() -> u64Pays whichever claim is at the head, to the owner recorded on it, and advances the queue. Returns the claim id settled. If the token refuses delivery the claim is marked deferred, left unpaid and stepped over.anyone. It takes no claim id and no recipient, so it can only do what the owner's own claim_withdrawal would have doneQueueEmpty 315, ClaimNotFound 306, AlreadyClaimed 308, InsufficientLiquidity 310
bump_claim(claim_id: u64)Postpones the archival of a claim record by extending its TTL. Claims are bumped only when they are written, and a claim waiting behind a stalled queue is a claim nothing writes to.anyone. It cannot shorten a TTL or alter a claim, and the caller pays the rentClaimNotFound 306

The anti-dust minimum on request_withdrawal is 1 agUSD, or 10000000.

Allocation and the book

FunctionWhat it doesCallerErrors
settle_allocation(pool: Address, amount: i128)Releases idle USDC to a pool adapter and raises the Vault's own deployed book. Refuses any release that would take free reserves below the queued claims or below the reserve floor, whoever is asking.stored EngineNotInitialized 301, Paused 303, InvalidAmount 304, InsufficientLiquidity 310, ReserveFloorBreached 316
record_repayment(amount: i128)Books capital returning from a pool. The Vault compares its real balance against the balance it can account for and refuses any repayment it cannot see.stored EngineNotInitialized 301, InvalidAmount 304, DeployedUnderflow 318, RepaymentNotReceived 317
record_writedown(admin: Address, amount: i128)Recognises that deployed capital is not coming back. Lowers the deployed book with no cash arriving, and adds the same amount to recognised_losses, which stays in the reserve floor's denominator.stored Engine and the stored admin, bothNotInitialized 301, NotAdmin 302, InvalidAmount 304, DeployedUnderflow 318
record_recovery(admin: Address, amount: i128)Books capital that had been written off and has come back, releasing the recognised loss against it. Refuses any amount the Vault cannot see in its own balance.stored Engine and the stored admin, bothNotInitialized 301, NotAdmin 302, InvalidAmount 304, RecoveryNotReceived 323

record_writedown and record_recovery are reached through the Allocation Engine's write_down and recover, which write the adapter's book and the Engine's in the same transaction.

Configuration

FunctionWhat it doesCallerErrors
__constructor(admin: Address, usdc_token: Address)Deploy-time wiring. Takes USDC only, and checks it answers the token interface. agUSD and the Engine arrive later, through setters that interrogate them, because each is built against this Vault's address and cannot exist before it does.adminInvalidUsdc 325
set_agusd(admin: Address, agusd_token: Address)Points the Vault at the token it mints. The token must name this Vault as its minter, and the pointer closes at the first deposit.stored admin It must also count stroops the way this Vault's USDC does. deposit mints one stroop of agUSD for one stroop of USDC, so a token with different decimals leaves the internal arithmetic self consistent, the round trip exact, and everything outside wrong: an AMM pool or a lending market valuing a unit at a dollar would be out by a factor of ten with nothing on-chain contradicting it.NotInitialized 301, NotAdmin 302, DepositsExist 312, AgUsdMismatch 324
set_engine(admin: Address, allocation_engine: Address)Points the Vault at the Engine allowed to call settle_allocation. The Engine must answer that it governs this Vault and arrive with an empty book, and the pointer will not move while this Vault has capital out at a pool.stored adminNotInitialized 301, NotAdmin 302, CapitalDeployed 313, EngineMismatch 314
set_oracle(admin: Address, oracle: Address, feed_id: Symbol)Points the Vault at an Oracle Adapter and the feed get_nav reads. Interrogates the pair before accepting it, which it did not used to: the address has to answer get_feed for this exact feed, so it has to be a contract, an oracle, and one that knows the feed. It deliberately asks nothing about whether that feed has reported yet or whether its last value is fresh, because pointing at a newly deployed oracle, or at one whose reporter is down, is an ordinary operation and often the reason for repointing. What no check reaches is a real feed that is the wrong one for this Vault's book, which is why the pair is readable.stored adminNotAdmin 302, OracleMismatch 326
set_reserve_floor(admin: Address, floor_bps: u32)Sets the share of the floor's base the Vault will not release. Ships closed at 10000 until this is called.stored adminNotInitialized 301, NotAdmin 302, InvalidFloor 319
set_paused(admin: Address, paused: bool)Circuit breaker over deposit, request_withdrawal and settle_allocation. It does not touch the payout paths, because the agUSD behind a queued claim is already burned.stored adminNotInitialized 301, NotAdmin 302
propose_admin(admin: Address, new_admin: Address)First half of a two step handover. The role does not move.stored adminNotInitialized 301, NotAdmin 302
accept_admin(new_admin: Address)Second half. Only the proposed address can call it, and its signature is the proof the key is reachable.new_adminNoPendingAdmin 320, NotPendingAdmin 321

Views

oracle() -> Result<Address, VaultError> and oracle_feed() -> Result<Symbol, VaultError> read back the pair set_oracle writes. Every other counterparty this Vault points at could be read back and this one could not, so the only way to learn where it pointed was to call get_nav() and infer it from the number, and a wrong pointer produces a perfectly plausible number. The missing getter is what let the missing check go unnoticed.

FunctionWhat it returns
idle_reserves() -> i128USDC held by the Vault, gross. Counts money already owed to the queue, so no deployment limit is measured against it.
outstanding_liabilities() -> i128USDC owed to claims that have burned their agUSD and not been paid.
free_reserves() -> i128idle_reserves less outstanding_liabilities, never negative. The cash the protocol may actually deploy, and what every limit reads.
deployed_capital() -> i128Capital released to pools and not seen back, from the Vault's own records rather than the Engine's.
booked_reserves() -> i128The idle balance the Vault can account for from its own flows. Anything above it arrived unannounced, which is what a repayment looks like from inside.
recognised_losses() -> i128Written off and not recovered. Falls only through record_recovery, which requires the cash.
floor_base() -> Result<i128, VaultError>The denominator the reserve floor is a share of: booked_reserves + deployed_capital + recognised_losses - outstanding_liabilities, summed unclamped and clamped once at zero. It is measured on the cash this Vault can account for from its own flows rather than on its raw balance, because cash arriving without the books being told would otherwise raise the base on arrival and be spent again when something booked it. An invariant fuzzer found that in four operations.
get_total_assets() -> Result<i128, VaultError>Gross assets: idle reserves plus deployed capital.
get_net_assets() -> Result<i128, VaultError>Free reserves plus deployed capital. The honest measure of what the Vault is worth, and for that reason not the floor's base.
reserve_floor_bps() -> u32The Vault's own copy of the floor.
get_nav() -> Result<i128, VaultError>NAV from the Oracle Adapter for the configured feed. A stale feed surfaces as OracleStale 511 rather than an old number.
get_claim(claim_id: u64) -> Result<Claim, VaultError>The claim record: owner, amount, requested_at, claimed.
claim_status(claim_id: u64) -> Result<ClaimStatus, VaultError>Pending, Ready or Claimed. Ready is derived, because both conditions for it change without anyone touching the claim.
is_deferred(claim_id: u64) -> boolWhether settle_withdrawal could not deliver this claim and stepped over it. It is unpaid, still owed, and collectable out of head order.
queue_head() -> u64The next claim id that may be paid.
queue_tail() -> u64The next claim id to be handed out.
queue_length() -> u64Claims the queue has not reached. Deferred claims are not counted, because they are out of the way.
deposits() -> u64Deposits taken since deployment. This is what set_agusd keys off.
paused() -> bool
admin() -> Result<Address, VaultError>, usdc(), agusd(), allocation_engine()The stored pointers.
pending_admin() -> Option<Address>The proposed and not yet accepted admin, None if no handover is in flight.

Error codes

300 AlreadyInitialized (retired)301 NotInitialized302 NotAdmin303 Paused
304 InvalidAmount305 BelowMinWithdrawal306 ClaimNotFound307 NotClaimOwner
308 AlreadyClaimed309 NotAtQueueHead310 InsufficientLiquidity311 OracleNotConfigured
312 DepositsExist313 CapitalDeployed314 EngineMismatch315 QueueEmpty
316 ReserveFloorBreached317 RepaymentNotReceived318 DeployedUnderflow319 InvalidFloor
320 NoPendingAdmin321 NotPendingAdmin322 PaymentRejected323 RecoveryNotReceived
324 AgUsdMismatch325 InvalidUsdc326 OracleMismatch327 DecimalMismatch

agUSD

The synthetic dollar the Vault mints, one for one against a deposit. A SEP-41 token that does nothing except keep balances and let exactly one address create them. Errors are in the 200 range.

FunctionWhat it doesCallerErrors
__constructor(admin: Address, minter: Address, decimal: u32, name: String, symbol: String)Deploy-time. Records the admin and the minting authority and writes the SEP-41 metadata.admin
mint(to: Address, amount: i128)Creates supply. The authorization is the stored minter's, not an address the caller passes, so there is no argument to talk your way through.stored minter, which is the VaultNotInitialized 201, InvalidAmount 203
set_minter(admin: Address, minter: Address)Corrects the minting authority, and only while the token has never minted. After the first mint it is fixed for the life of the contract.stored adminNotInitialized 201, NotAdmin 204, MinterFrozen 205
propose_admin(admin: Address, new_admin: Address)First half of a two step handover.stored adminNotInitialized 201, NotAdmin 204
accept_admin(new_admin: Address)Second half.new_adminNoPendingAdmin 206, NotPendingAdmin 207
minter() -> Result<Address, AgUsdCoreError>The only address that can create supply.anyoneNotInitialized 201
mints() -> u64Mints since deployment. Zero means the minter can still be corrected, which is the one number an auditor needs to check that the promise holds.anyone
admin() -> Result<Address, AgUsdCoreError>, pending_admin() -> Option<Address>anyoneNotInitialized 201

SEP-41: balance(id), transfer(from, to, amount), transfer_from(spender, from, to, amount), approve(from, spender, amount, expiration_ledger), allowance(from, spender), burn(from, amount), burn_from(spender, from, amount), decimals(), name(), symbol(), total_supply().

burn and burn_from are the standard holder-authorized ones, because that is what Vault::request_withdrawal relies on. Supply therefore goes up only through the Vault and down only through the holder.

Error codes: 200 AlreadyInitialized (retired), 201 NotInitialized, 202 NotMinter (declared, never returned: a non-minter is stopped by the authorization check), 203 InvalidAmount, 204 NotAdmin, 205 MinterFrozen, 206 NoPendingAdmin, 207 NotPendingAdmin.

sagUSD

The staking contract. Yield reaches holders through share price appreciation rather than a moving redemption rate, which is what keeps agUSD usable as a unit of account. Errors are in the 800 range.

FunctionWhat it doesCallerErrors
__constructor(admin: Address, agusd: Address, cooldown_seconds: u64, decimal: u32, name: String, symbol: String)Deploy-time. Records the admin, the agUSD accepted, the cooldown and the sagUSD metadata.admin
stake(from: Address, amount: i128) -> i128Takes agUSD and mints sagUSD shares at the current rate. Returns the shares minted. First staker gets 1:1.fromInvalidAmount 806, ZeroShares 807
request_unstake(from: Address, shares: i128) -> i128Burns the shares immediately and records the agUSD owed, claimable after the cooldown. Returns the assets owed. A second request restarts the cooldown on the whole pending balance.fromInvalidAmount 806, NoSupply 808
claim(from: Address) -> i128Pays out matured pending agUSD.fromNothingPending 809, StillInCooldown 810
bump_pending(addr: Address)Postpones the archival of a pending unstake by extending its TTL. The record is written once, at the request, and nothing writes to it again until it is claimed, so nothing extends it; a cooldown is by construction a period the staker has been told to go away for.anyone. It cannot shorten a TTL, cannot alter what is owed or to whom, and the caller pays the rentNothingPending 809
distribute_yield(amount: i128)Moves amount agUSD from the admin into the contract and raises NAV by exactly that. Every share appreciates; there is nothing to claim and no rebase. It replaced a bare NAV setter, which was a setter on the denominator of the contract's own share price.stored admintraps: amount must be positive
set_agusd(admin: Address, agusd: Address)Repoints the accepted token, and only while the contract has never taken custody: no stakes, no NAV, no balance.stored adminNotInitialized 801, NotAdmin 802, CustodyTaken 803
set_allocations(allocations: Vec<Allocation>)Records the off-chain strategy breakdown shown in the interface. Display metadata; nothing on-chain reads it.stored admin
propose_admin(admin: Address, new_admin: Address) / accept_admin(new_admin: Address)Two step handover.stored admin, then new_adminNotInitialized 801, NotAdmin 802, NoPendingAdmin 804, NotPendingAdmin 805

Views: nav() -> i128, total_shares() -> i128, exchange_rate() -> i128 (agUSD per share at 7 decimals), share_price() -> i128 (an alias kept because the first generation agUSD calls it), pending(addr) -> Pending with fields assets and claimable_at, cooldown() -> u64, allocations() -> Vec<Allocation>, stakes() -> u64, agusd() -> Address, admin() -> Address, pending_admin() -> Option<Address>.

SEP-41: balance, transfer, transfer_from, approve, allowance, decimals, name, symbol, total_supply. There is deliberately no burn: shares leave supply only through request_unstake.

Error codes: 800 AlreadyInitialized (retired), 801 NotInitialized, 802 NotAdmin, 803 CustodyTaken, 804 NoPendingAdmin, 805 NotPendingAdmin, 806 InvalidAmount, 807 ZeroShares, 808 NoSupply, 809 NothingPending, 810 StillInCooldown, 811 DecimalMismatch.

ZeroShares is worth naming separately from InvalidAmount: it is a stake large enough to be a positive number of assets and small enough to round to no shares at all, and taking it would be taking a deposit and giving nothing back for it.

Events: staked(staker, assets, shares, nav, supply) · unstake_requested(staker, shares, assets, claimable_at, nav, supply) · unstake_claimed(staker, assets) · yield_distributed(amount, nav, supply) · agusd_repointed(agusd) · admin_proposed(new_admin) · admin_changed(admin). The first four carry nav and supply as they stand after the call, because the share price is nav / supply and an indexer replaying the stream has no way back to a past pair: carrying both makes every price in a history a fact from the ledger rather than a sample somebody happened to take. unstake_claimed carries neither, on purpose, because the shares were burned at request time and nothing about the price moves at a payout.

Allocation Engine

The constraint layer. It decides nothing about where capital goes, which in V1 is an admin decision; what it does is refuse. Errors are in the 400 range.

FunctionWhat it doesCallerErrors
allocate(admin: Address, pool_id: Address, amount: i128)Routes Vault capital into a registered pool, against four limits checked in the same transaction as the release: the per-pool cap, the per-originator cap, the per-jurisdiction cap and the reserve floor. Any one failing reverts the whole call, so a refused allocation moves nothing and books nothing.stored adminNotInitialized 401, NotAdmin 402, InvalidAmount 406, PoolNotRegistered 404, InsufficientReserves 411, PoolCapExceeded 407, OriginatorCapExceeded 408, JurisdictionCapExceeded 409, ReserveFloorBreached 410, AdapterMismatch 414
deallocate(pool_id: Address, amount: i128)Capital coming back. The adapter moves the USDC to the Vault in the same call that reduces the exposure, and the Vault checks the money arrived before it believes it. No cap is checked, because deallocating can never breach one. The adapter still has to name this Engine and this Engine's Vault, re-read here rather than taken from registration.stored adminNotInitialized 401, InvalidAmount 406, PoolNotRegistered 404, ExposureUnderflow 412, AdapterMismatch 414
write_down(admin: Address, pool_id: Address, amount: i128, reason: Symbol)Recognises a loss across all three books at once: the adapter's, the Engine's and the Vault's. It buys the caller nothing: recognised losses stay in the reserve floor's denominator, and the amount is charged against the pool's concentration cap until the cash comes back.stored admin, whose signature must also satisfy the Vault's adminNotInitialized 401, NotAdmin 402, InvalidAmount 406, PoolNotRegistered 404, WriteDownExceedsExposure 415, AdminMismatch 418
recover(admin: Address, pool_id: Address) -> i128Brings home whatever an adapter holds above its booked exposure, which is what a written-off position that recovers looks like, and what interest above principal looks like. Returns the amount. The destination is the adapter's stored Vault and the amount is not a parameter, so there is nothing here for a caller to aim, which holds only while that Vault is still the one this Engine governs, so the pairing is re-checked here too.stored admin, whose signature must also satisfy the Vault's adminNotInitialized 401, NotAdmin 402, PoolNotRegistered 404, AdapterMismatch 414, AdminMismatch 418, and the adapter's NothingToRecover
book_recovery(admin: Address, pool_id: Address, amount: i128)recover with the adapter leg removed, for a recovery whose cash is already in the Vault because the adapter's own admin swept it. Bounded by the Vault, which subtracts the balance it can account for from the balance it holds and refuses anything larger, so the recovery stays evidenced rather than asserted. The pool is an admin assertion here, because cash already in the Vault is unattributed by construction: the global loss book moves by the cash that arrived whatever pool is named, so solvency does not rest on it, and the per-pool cap does.
register_pool(admin: Address, pool_id: Address, originator: Symbol, jurisdiction: Symbol, cap_bps: u32)Whitelists an adapter with the metadata the caps aggregate over. The adapter must name this Engine and this Engine's Vault back. This is the first time that check runs rather than the only time: allocate, deallocate and recover re-run it, because set_vault can move the Engine's end of the pairing after a pool is registered.stored adminNotInitialized 401, NotAdmin 402, InvalidCap 405, PoolAlreadyRegistered 403, AdapterMismatch 414
set_pool_cap(admin: Address, pool_id: Address, cap_bps: u32)Moves a registered pool's own cap. A pool's effective limit is the tighter of this and the global pool cap, and until this existed only the second could move, so wherever a pool's own figure was binding it was binding for the life of the Engine. The useful direction is down: a cap of zero stops new capital reaching a pool without touching what it holds or releasing what it has been charged, which is the delisting that works on a pool in default.stored adminNotAdmin 402, PoolNotRegistered 404, InvalidCap 405
unregister_pool(admin: Address, pool_id: Address)Removes an entry. Refused while this Engine or the adapter still books capital in it, and refused while a write-off is still charged against it: the aggregate caps are built by walking this registry, so an entry leaving takes its charge out of them and a defaulted pool could otherwise be delisted and replaced under the same originator with its whole limit back. It runs no counterparty check, deliberately, because the entry most worth removing is the one whose adapter no longer names this Engine's Vault.stored adminNotAdmin 402, PoolNotRegistered 404, PoolHasExposure 420, PoolHasWrittenOffCharge 421
set_caps(admin: Address, pool_cap_bps: u32, originator_cap_bps: u32, jurisdiction_cap_bps: u32)Sets the global concentration limits. A pool's effective limit is the tighter of its own and the global one.stored adminNotInitialized 401, NotAdmin 402, InvalidCap 405
set_reserve_floor(admin: Address, floor_bps: u32)Sets the minimum share of the floor's base that stays as idle USDC in the Vault. Ships closed at 10000.stored adminNotInitialized 401, NotAdmin 402, InvalidCap 405
__constructor(admin: Address, vault: Address)Deploy-time. The Vault must answer admin(), and must answer with this Engine's admin, because the calls that recognise and reverse a loss need one signature that satisfies both contracts.adminVaultMismatch 419
set_vault(admin: Address, vault: Address)Repoints the Engine, while its exposure book is empty, and runs the same check the constructor runs. An empty book is not an empty registry, and this call cannot repair the second: every pool already registered goes on naming the outgoing Vault, and each has to be brought across with set_counterparties before anything can be allocated to it again.stored adminNotInitialized 401, NotAdmin 402, CapitalDeployed 413, VaultMismatch 419
propose_admin(admin: Address, new_admin: Address) / accept_admin(new_admin: Address)Two step handover.stored admin, then new_adminNotInitialized 401, NotAdmin 402, NoPendingAdmin 416, NotPendingAdmin 417

Views

FunctionWhat it returns
get_reserve_ratio() -> Result<u32, EngineError>Vault::accounted_free_reserves() over Vault::floor_base(), in bps. This is the number set_reserve_floor bounds, measured against the same base the floor is checked against, so it does not jump upwards when a loss is recognised and it does not report liquidity the Vault would refuse to release.
floor_base() -> Result<i128, EngineError>Vault::floor_base(), read from the Vault rather than rebuilt here, so the limit the Engine enforces and the limit the Vault enforces are one number. Rebuilding it from the Engine's own books reproduces it only while no term is clamped, and the Vault clamps the sum once at the end.
written_off() -> i128Written off across all pools and not recovered.
written_off_pool(pool_id: Address) -> i128Written off against one pool and not recovered. This is what a write-down costs that pool's cap.
charged_exposure(pool_id: Address) -> i128What the concentration caps are measured on: deployed plus written off. It differs from get_exposure only after a write-down, and that difference is the reason a write-down cannot reopen a cap.
get_exposure(pool_id: Address) -> i128Capital currently deployed into a pool.
get_exposures() -> Map<Address, i128>The whole book, keyed by pool. Registered pools with no exposure appear as zero, so it doubles as the whitelist.
total_allocated() -> i128Booked as deployed across every pool.
admin_aligned() -> boolWhether this Engine's admin and the Vault's are the same address. It is false exactly when write_down and recover will refuse, which makes a half finished rotation visible before an incident puts a number on it.
vault_admin() -> Result<Address, EngineError>The Vault's admin, as the Vault reports it.
caps() -> Capspool_bps, originator_bps, jurisdiction_bps.
get_pool(pool_id: Address) -> Result<Pool, EngineError>originator, jurisdiction, cap_bps.
pools() -> Vec<Address>Registered adapters.
reserve_floor_bps() -> u32, vault() -> Result<Address, EngineError>, admin() -> Result<Address, EngineError>, pending_admin() -> Option<Address>

Error codes

400 AlreadyInitialized (retired)401 NotInitialized402 NotAdmin403 PoolAlreadyRegistered
420 PoolHasExposure421 PoolHasWrittenOffCharge
404 PoolNotRegistered405 InvalidCap406 InvalidAmount407 PoolCapExceeded
408 OriginatorCapExceeded409 JurisdictionCapExceeded410 ReserveFloorBreached411 InsufficientReserves
412 ExposureUnderflow413 CapitalDeployed414 AdapterMismatch415 WriteDownExceedsExposure
416 NoPendingAdmin417 NotPendingAdmin418 AdminMismatch419 VaultMismatch

Pool adapters

Every adapter implements the same interface, which is what lets the Engine route to an off-chain credit facility and to a tokenized bond through identical code. allocate, deallocate and write_down are callable only by the stored Engine; recover_surplus by the Engine or the adapter's admin, and it sends to the adapter's stored Vault rather than anywhere the caller names.

allocate(amount: i128), deallocate(amount: i128), write_down(amount: i128), recover_surplus(caller: Address) -> i128, get_exposure() -> i128, engine() -> Address, vault() -> Address, admin() -> Address, pool_kind() -> Symbol, oracle_feed() -> Symbol, set_counterparties(admin, engine, vault), propose_admin, accept_admin, pending_admin.

The settlement figure is the one place the two adapters differ, and they differ because the instruments do. Private credit publishes settlement_window() -> (u32, u32), a range of 15 to 90 days; Etherfuse publishes settlement_days() -> u32, which is zero, because a Stablebond redemption is on-chain and returns in the same call. Nothing on-chain enforces either. They are published so that the Engine's operators and the withdrawal queue can be sized against the real cash conversion time of the book.

Error ranges: 600 for the private credit adapter, 700 for Etherfuse, with matching variants. NotEmpty 605 and 705 is the refusal to repoint an adapter that is still holding something; NothingToRecover 611 and 711 is recover_surplus finding no surplus.

Oracle Adapter

NAV reporting, with the guards on the way in rather than on the way out. Errors are in the 500 range.

FunctionWhat it doesCallerErrors
push_nav(reporter: Address, feed_id: Symbol, nav: i128, timestamp: u64)Strict report. Validates and stores, or fails the transaction. A deviation breach fails here rather than emitting a rejection, because a failing invocation's events are rolled back anyway.reporter, and it must be in the reporter setUnauthorizedReporter 503, FeedNotRegistered 504, InvalidNav 507, NavOutOfBand 513, TimestampInFuture 509, NonMonotonicTimestamp 508, TooSoon 514, DeviationOutOfBounds 510
submit_nav(reporter: Address, feed_id: Symbol, nav: i128, timestamp: u64) -> PushOutcomeThe same validation, but a deviation breach returns RejectedDeviation and publishes a rejection event instead of failing. Returns Accepted otherwise.reporter, in the reporter setas push_nav, without 510
register_feed(admin, feed_id: Symbol, staleness_secs: u64, deviation_bps: u32, min_nav: i128, max_nav: i128, min_interval_secs: u64)Write-once registration of a feed's guards. The band bounds every report including the first, which a deviation bound cannot reach, because a bound on a move needs something to move from.stored adminNotInitialized 501, NotAdmin 502, InvalidFeedConfig 506, FeedAlreadyRegistered 505
set_quorum_threshold(admin, feed_id: Symbol, threshold: u32)How many distinct authorized reporters have to submit the same value for the same round before it commits. Defaults to 1, which is V1 exactly: the first vote is quorum and it lands. Above 1 a reporter gets one vote per round whatever it votes for, and partial agreement moves nothing. Reaching quorum changes how many reporters must agree before the band, staleness, deviation and interval guards run, never whether they run.stored adminNotAdmin 502, FeedNotRegistered 504, InvalidQuorumThreshold 517
quorum_threshold(feed_id: Symbol) -> u32 / quorum_votes(feed_id: Symbol, timestamp: u64, nav: i128) -> u32The threshold in force, and how many votes a particular value has in a particular round.anyone
add_reporter(admin, reporter: Address) / remove_reporter(admin, reporter: Address)Manages the set of addresses allowed to report.stored adminNotInitialized 501, NotAdmin 502
get_nav(feed_id: Symbol) -> Result<i128, OracleError>The latest NAV, refusing to return one older than the feed's staleness window. It fails rather than handing back a stale number, and the Vault lets that failure propagate.anyoneFeedNotRegistered 504, NoNavReported 512, OracleStale 511
last_update(feed_id: Symbol) -> Result<NavPoint, OracleError>The raw stored point, including its age, for monitoring: nav, timestamp, recorded_at.anyoneNoNavReported 512
get_feed(feed_id: Symbol) -> Result<Feed, OracleError>The registered guards for a feed.anyoneFeedNotRegistered 504
is_reporter(addr: Address) -> bool, reporters() -> Vec<Address>, admin(), pending_admin()anyone
__constructor(admin: Address)Deploy-time. Empty reporter set, no feeds registered.admin
propose_admin(admin, new_admin) / accept_admin(new_admin)Two step handover.stored admin, then new_adminNotInitialized 501, NotAdmin 502, NoPendingAdmin 515, NotPendingAdmin 516

The minimum interval is measured on recorded_at, which is ledger time, and never on the timestamp the reporter supplies, because the reporter chooses that one.

Error codes

500 AlreadyInitialized (retired)501 NotInitialized502 NotAdmin503 UnauthorizedReporter
504 FeedNotRegistered505 FeedAlreadyRegistered506 InvalidFeedConfig507 InvalidNav
517 InvalidQuorumThreshold518 AlreadyVoted
508 NonMonotonicTimestamp509 TimestampInFuture510 DeviationOutOfBounds511 OracleStale
512 NoNavReported513 NavOutOfBand514 TooSoon515 NoPendingAdmin
516 NotPendingAdmin

Calling a contract

Read-only, simulated and never submitted:

stellar contract invoke --id CONTRACT --source KEY --network testnet --send=no \
  -- free_reserves

State changing, submitted, returning a transaction hash:

stellar contract invoke --id CONTRACT --source KEY --network testnet \
  -- deposit --from GADDRESS --amount 10000000

The full interface of any deployed contract, including types, is on the ledger:

stellar contract info interface --id CONTRACT --network testnet

A refusal is contract logic and shows up identically in simulation, so simulating is enough to confirm an entry point rejects what it should. Authorization is not: simulation records the authorizations a call would need rather than enforcing them, so anything that turns on who signed has to be checked with a submitted transaction signed by the key under test.