Summary
100% of all REPORTED Findings have been addressed
- 24Acknowledged
- 4Risk Accepted
- 10Solved
- 38All Findings
- Critical0
- High0
- Medium0
- Low5
- 4Risk A.
- 1Solved
- Informational33
- 24Ack.
- 9Solved
INTRODUCTION#
LI.FI engaged Halborn to conduct a security assessment of the LI.FI Stellar execution router, a Soroban smart contract that routes a user's swap and bridge activity through an operator-governed registry of external adapters. The assessment covered the authority and governance model, the adapter registry and dispatch path, the fee and slippage arithmetic, the trade execution flows, storage durability and TTL handling, event emission, error hygiene, and the build and toolchain configuration.
The review covered a single Cargo workspace member built against soroban-sdk 27.0.1:
contracts/lifi(cratelifiversion 1.0.0): the entire on-chain surface, comprisinglib.rs,core_admin.rs,config.rs,lifi.rs,execution.rs,types.rs,events.rs,errors.rs, and the shipped tests undersrc/tests.The supporting artifacts that shape a deployment: the
packages/lifiTypeScript bindings, theexamplesintegration scripts, thepayloads/testnetproposal and route templates, anddocs.
The assessed revision is commit f8d20509c3de21d5e8125c8107124ae7be73b966. The engagement produced 38 findings against the contract, all scored with BVSS 2.0: 0 critical, 0 high, 0 medium, 5 low, and 33 informational. The severity shown for each finding is the band that the published formula returns for that finding's vector rather than a separate judgement about it.
A second review pass re-examined the same revision against an independent automated analysis of 36 candidate issues, treating each as a hypothesis to verify in the source rather than as a result to accept. Candidates that restated a finding already in this report were merged, candidates that did not survive verification were discarded with a recorded reason, and confirmed candidates were added to the report, several of them backed by newly executed proofs of concept. That pass is described under the dynamic testing section.
How severity was scored#
Severity in this report is derived, not assigned. Each finding carries a BVSS 2.0 vector and its severity is the band the published formula returns for that vector. Two properties of the formula shape the distribution and are worth stating plainly, because the result is a report with no critical, high, or medium findings and thirty-three informational ones out of thirty-eight.
First, exploitability is a product, so one privileged precondition dominates the score. A defect reachable only by an operator or by the admin carries AO:S, which multiplies exploitability by 0.2 and caps the finding at 2.5 however severe its consequences are. A defect that additionally requires a governance misconfiguration carries AX:H and is capped at 3.3. Most governance defects in this report are therefore informational by construction, including several whose consequences are permanent.
Second, the impact metrics measure consequences to a deployed system, so an assurance gap scores zero. The mutation result and the error-hygiene findings describe what the shipped test suite fails to check rather than a value, integrity or availability consequence, so every impact metric is N and the score is 0.0.
The practical consequence is that a BVSS band answers how much risk a finding carries to a correctly operated deployment, not how much attention it deserves. Several informational findings here are load-bearing for that reason: the segregated admin authority leaves a paused protocol with no operator recovery path if the admin key is lost, the snapshot quorum makes the incident-response step in operations.md ineffective, and an approval banked by an operator who has since been removed still counts toward quorum. They score low because they need operator keys and a mistake, not because the outcome is mild. The recommendations below are ordered by consequence rather than by score.
Scope note: the swap and bridge adapters that the router dispatches to are external contracts, including Soroswap and Axelar ITS as named production targets. They were not in scope, so their behavior is treated as untrusted throughout this assessment, which is the same posture the router itself should adopt.
Key themes observed across findings#
Registry curation is the only real control over an arbitrary cross-contract call. The router holds no swap or bridge logic. It invokes
interface.contract.addresswithinterface.execute_fnand forwards the caller's arguments verbatim apart from one patched amount slot.execute_fnis a free-formSymbol,wasm_hashis an optional field that disables validation entirely when null, and nothing constrains what kind of contract a target may be. Registering an interface is therefore a security decision, not a configuration change.The router's one substantive check on an adapter is measured on values the caller chooses. The balance-delta spend cap in
internal_swapcompares deltas of the caller-declaredtoken_inandtoken_out. When the declared tokens are not the tokens the adapter actually moves, both deltas read zero and the cap passes trivially, so it is vacuous in exactly the case where it would matter.Governance decides on stale state. A proposal snapshots the operator configuration at creation, and the quorum check at execution reads that snapshot while operator authentication reads the live set. Approvals banked by removed operators stay load-bearing, a raised threshold does not bind proposals that already exist, and there is no expiry ceiling, so an approved proposal remains executable indefinitely.
Fee and value handling trusts the route author. There is no fee ceiling below total confiscation, the one-unit minimum fee lets a split fee list charge many times the declared rate on small amounts, and no fee is required at all. The router accepting itself as a fee destination has since been closed off by the client, who added a check that rejects that destination outright.
Main recommendations#
Constrain what the registry can point at. Make
wasm_hashmandatory so thatvalidate_contractalways runs, verify that the target is a deployed contract, and restrictexecute_fnto an expected adapter entrypoint so that a plain token cannot be registered as an adapter. This single change removes the highest-impact path in the report.Make the spend cap bind the tokens the adapter actually moves rather than the tokens the caller declares, and validate that a caller-supplied token address is a contract of the expected shape before invoking it or trusting it as a measurement oracle. The client has since closed the zero-output gap directly, by requiring
min_amount_outto be strictly positive so a swap that delivers nothing can no longer be reported as a success.Evaluate governance against live state. Read the quorum from the current
OperatorConfig, revalidate that approvers are still operators, reject operator sets that contain duplicate addresses, bound the timelock, and add an expiry ceiling so that an approved proposal cannot be executed indefinitely.Extend event emission to
upgradenow that proposal creation, approval and execution are observable, and subjectupgradeto the same quorum and timelock as any lesser configuration change, so that the most powerful operation is not also the least observable.Bound the fee surface. Cap total fees well below the input, rework the one-unit floor so that a split list cannot exceed the declared rate, and give the standalone
swapandbridgeentrypoints the deadline thatswap_bridgealready enforces.Extend the TTL of an entry when it is written, not only when it is read. Proposal and interface entries are created at the network minimum of roughly six hours against the contract's own one-day target, so a prudent timelock outlives the proposal it governs and an adapter registered during the documented paused deployment window can archive before the first trade reaches it.
Test methodology and approach#
Halborn combined an exhaustive manual read of the contract with an executable proof-of-concept campaign and a mutation run that measured what the client's own tests actually cover.
Static review track: every module of the contract was read line by line (
lib.rs,core_admin.rs,config.rs,lifi.rs,execution.rs,types.rs,events.rs,errors.rs) together with the shipped tests, the workspace and toolchain configuration, and the operational payloads and documentation that define how the contract is deployed and governed.Dynamic track: 132 executable Rust proofs of concept in a scratch project that builds against the audited crate and never modifies it, all green. The suite is organized in phased modules covering privilege and governance, dispatch and self-authorization, the deputy problem, a lattice sweep of configuration states, and error hygiene.
Authorization realism: results were established under real authorization trees rather than a global authorization mock, so each finding reflects what a signer would actually have to sign. This is what separates an attack that needs no signature at all from one that needs the victim to sign a transfer they were shown.
Paired controls: every attack ships with a control probe, so that a positive result is attributed to the mechanism claimed rather than to a permissive fixture. The anonymous drain, for example, ships with a control that aims the identical attack at an ordinary user and is correctly rejected.
Mutation testing: each of the 21 guards in the contract was disabled one at a time and only the 20 shipped tests were run against the mutated source. A guard whose removal leaves the suite green is a guard that no shipped test covers. Eleven survived.
Skeptical reassessment: every hypothesis was re-tested with a deliberately adversarial lens to find its honest limit. Boundary probes established where each ceiling sits and which check enforces it, and several severities are bounded as a result rather than reported at face value.
Independent-analysis reconciliation: a second pass re-examined the revision against candidate issues produced by an automated analysis, treating each as a hypothesis to verify in the source rather than as a result to accept. Confirmed candidates were added to the report, several with new executed proofs of concept, duplicates of existing findings were merged, and unsupported candidates were discarded with a recorded reason.
Remediation Review#
Following delivery, the client submitted a series of code changes addressing the findings in this report. Halborn verified each claimed fix against the updated revision rather than accepting it on assertion, re-running the relevant proofs of concept and reading the changed code directly. Every one of the 38 findings now carries a final disposition: 10 are Solved, meaning the verified code change removes the defect, 24 are Acknowledged, meaning the client accepts the finding as an intentional design or operational decision and no further code change is expected, and 4 are Risk Accepted, meaning the client accepts the residual exposure with only a partial mitigation in place. No finding remains outstanding.
As part of this review, every BVSS 2.0 vector in the report was re-derived from the actual precondition each finding requires, rather than from a uniform convention applied across the report. The current severity distribution, no critical, high, or medium findings, 5 low and 33 informational, reflects that correction together with the fixes verified above. No finding was withdrawn and none was reclassified as a false positive in this process.
Technical Profile#
Document Purpose#
This section describes what the system is, how it is structured, and which architectural characteristics matter most to the audit. It is not a findings report. It is the implementation profile that supports the attack surface and threat model sections.
System Summary#
The LI.FI Stellar router is a single Soroban contract, LiFi, that implements no swap or bridge logic of its own. It is a thin and nominally non-custodial dispatcher: a caller submits a typed route, the router collects fees, patches one amount argument, and invokes an external adapter that governance has registered under a short name. Value moves between the caller and the adapter, and the router measures the outcome by reading token balances before and after the call rather than by trusting what the adapter returns.
Components#
CoreTraitincore_admin.rs: the constructor,set_admin,pause,upgrade, andrequire_not_paused.ConfigTraitinconfig.rs: the proposal lifecycle (create_proposal,approve_proposal,execute_proposal), the mutatorsadjust_operator_configandadjust_interface, and the read pathsget_operator_config,get_proposal, andget_interface.LiFiTraitinlifi.rs: the three trade entrypointsswap_bridge,swap, andbridge, withinternal_swapandinternal_bridgeperforming dispatch and measurement.execution.rsholdsvalidate_route_planandcollect_fees, whiletypes.rs,events.rs, anderrors.rshold the storage keys, the payload structs, the event schemas, and the eighteen declared error codes.
Authority model (the key characteristic)#
Three principal classes exist and they are not interchangeable. The admin is a single stored Address that alone can call set_admin, pause, and upgrade. The operators are a Vec<Address> inside OperatorConfig, governed by a threshold and a timelock, and they alone drive the proposal flow that changes configuration. A sender is authenticated per call and authorizes only its own trade.
The asymmetries between them are what matter. upgrade replaces the entire contract on one admin signature, outside the quorum and timelock that govern every lesser change. Governance is not gated on the pause state even though trading is. Operators have no path to lift a pause or to upgrade, so the admin key is the sole escape from a halted protocol, while admin key compromise is equivalent to full control.
Data model#
Instance storage holds DataKey::Admin, DataKey::Paused, DataKey::OperatorConfig, and DataKey::ProposalCounter. Persistent storage holds ContractKey::Proposal(u32) and ContractKey::Interface(Symbol). No temporary storage is used anywhere in the contract.
A registry entry is an Interface: a Contract carrying an address and an optional wasm_hash, an execute_fn: Symbol, an amount_arg_index: u32, an optional amount_field for a nested struct argument, and an enabled flag. A route is a RoutePlan carrying a SwapStep and a BridgeStep, each holding the adapter name, an opaque args: Vec<Val>, token addresses, a min_amount_out, and an optional fee list. Fees are FeeCollection entries of a fee_bps and a fee_destination against a denominator of 10000. All amounts are i128 and the release profile sets overflow-checks = true.
Deployment posture#
The constructor deliberately leaves the contract paused, so a fresh deployment is inert until the admin unpauses it. docs/operations.md asks for a Stellar account-level multisig on the admin, independent operator keys with two or more approvers, and WASM hash pinning on production interfaces. None of those three is enforced on-chain, so the security of a deployment rests on operational discipline that the contract itself does not check.
Execution Flows#
Document Purpose#
This section describes the step-by-step execution of each major operation, so that the audit team and the client share one vocabulary for how data moves through the implementation. For each flow it notes the inputs, the internal steps in order, the authorization required, and the points at which the contract validates or rejects.
swap_bridge (the chained route)#
The caller submits a RoutePlan. The router authenticates route_plan.sender, refuses to run while paused, and extends the instance TTL. validate_route_plan then requires a positive input amount, non-negative minimum outputs, a deadline strictly in the future, token continuity (swap.token_in equal to input_token, and bridge.token equal to swap.token_out), and that neither step carries its own fee list. Fees are collected from the sender on the input token, the net amount is patched into the swap arguments, and the swap adapter is dispatched. The swap output is then required to meet bridge.min_amount_out, is patched into the bridge arguments, and the bridge adapter is dispatched.
What the flow does not validate matters as much as what it does. Nothing checks the addresses embedded in the adapter arguments against the authenticated sender, nothing checks the token addresses inside those arguments against the declared tokens, and integrator, tracking_id, receiver, and destination_chain_id reach the event stream unvalidated.
Dispatch and measurement#
Before dispatch, healthz requires the entry to be enabled and runs validate_contract, which compares the target's executable against the pinned wasm_hash only when one is present and performs no check at all when it is null. The router then records the sender's balances, calls invoke_contract on the registered address with the caller's arguments, and computes the deltas. A swap must deliver at least min_amount_out and must not spend more than the declared amount, while a bridge is checked only for over-spend. Under-spend is permitted on both, the adapter's return value is ignored in favor of the measured deltas, and bridge.min_amount_out is a destination-side figure that is emitted in the event rather than enforced.
swap and bridge (the standalone legs)#
Both entrypoints authenticate the supplied sender, check the pause state, resolve and health-check the interface, read the amount out of the arguments, collect the step's own fees, patch the net amount back, and dispatch. Neither carries a deadline field, so a signed standalone step has no price window at all. As audited, neither ran validate_route_plan, so the non-negative floor that the chained route enforces on min_amount_out was not applied here. The client has since added the same check directly to both standalone entrypoints and tightened it to also reject a zero floor. The standalone bridge hardcodes the integrator to lifi.
Fee collection#
For each entry in the fee list, collect_fees rejects a negative rate, accumulates the basis points and rejects a total above the denominator, computes the fee by floor division, raises a zero result to one unit whenever the rate is positive, rejects a running total above the amount, transfers directly from the sender to the caller-supplied fee_destination, and publishes a LifiFee event for each leg.
Proposal lifecycle#
An operator calls create_proposal, which authenticates against the live operator set, validates an operator-configuration payload for a non-zero threshold no greater than the operator count, validates an interface payload with validate_contract, snapshots the current OperatorConfig into the proposal record, and sets proposal_ends_at to the current timestamp plus the timelock. Operators then call approve_proposal, which authenticates against the live set and rejects a duplicate approval from the same address, but performs no timelock check and no check that the proposal is still unexecuted. Finally any live operator calls execute_proposal, which rejects an already-executed proposal, requires the timestamp to have passed proposal_ends_at, compares the approval count against the threshold held in the snapshot, and applies the change through adjust_operator_config or adjust_interface. No governance step is gated on the pause state, and no governance step extends the TTL of the proposal or interface entry it writes. As audited, no governance step emitted an event either. The client has since added one on proposal creation, approval and execution.
pause and upgrade#
pause authenticates the admin, writes the flag idempotently, and publishes ContractPaused, which is the one administrative event the contract itself defines. The constructor reaches pause as a sub-call, so a deployment must also carry the admin's authorization. upgrade authenticates the admin and calls update_current_contract_wasm with no quorum, no timelock, no requirement that the contract be paused first, no validation of the new hash, no migration hook, and no version record. Every upgrade is observable on chain regardless: Soroban itself publishes a protocol-level executable_update event carrying the old and new executable on every call to update_current_contract_wasm, confirmed in soroban-env-host 27.0.1. The contract does not additionally publish an application-level event of its own for the upgrade, which would only add a schema version on top of what the host event already records, so this is a monitoring-convenience gap rather than a silent upgrade.
Attack Surface#
Entry Points#
Three surfaces accept untrusted input. The trade entrypoints swap_bridge, swap, and bridge are callable by any account that can sign for itself. The read paths get_operator_config, get_proposal, and get_interface are callable by anyone with no authentication at all, and one of them writes to persistent storage as a side effect. The governance entrypoints are restricted to operators, and set_admin, pause, and upgrade to the admin.
What a trade caller controls#
The adapter selection, by
Symbolname. The caller cannot name an arbitrary address, but it chooses freely among every entry the operators have registered, including any entry that is stale or intended to be retired but still present.The adapter arguments. The
args: Vec<Val>vector is opaque to the router and forwarded verbatim apart from the single amount slot identified byamount_arg_indexandamount_field. Recipients, spenders, token addresses, and any deadline inside those arguments are never compared against the authenticated sender or against the declared tokens.The declared tokens.
token_in,token_out, andtokenare caller-supplied addresses that the router both invokes as contracts and trusts as the measurement oracle for its own spend cap and slippage checks.The slippage floor on the standalone legs.
min_amount_outis the only slippage protection onswap. At the time of the audit this floor could be set below zero on the standalone legs becausevalidate_route_planran on the chained route alone. The client has since applied the same non-negative check directly to the standalone entrypoints and tightened it to also reject a zero floor.The fee list. Both the rate and the
fee_destinationare caller-authored, there is no allowlist of recipients, and the list can be omitted entirely.The route metadata.
tracking_id,integrator,receiver, anddestination_chain_idreach the event stream without validation.
The registry as a trust boundary#
Because dispatch is an invoke_contract against a registry-held address using a registry-held function name, the registry is the boundary between a caller-authored route and an arbitrary cross-contract call made by the router. Two properties widen that boundary. execute_fn is unconstrained, so a registered target need not be an adapter at all. wasm_hash is optional, so an entry can be registered against an address whose code is never checked and which need not even be a contract. Revocation compounds this because it is keyed by name rather than by target address, so a second entry pointing at the same contract survives removal of the first.
The router's position on the call stack#
When the router invokes a target, it is that target's direct invoker. Soroban's contract-invoker rule means that a require_auth on the router's own address succeeds inside that frame with no signature from anyone. Any authority the router holds, and any balance it carries, is therefore exposed to whatever it has been configured to call, which is why the contents of the registry are load-bearing rather than merely operational.
The administrative boundary#
The admin is a single address and upgrade is unconditioned, so compromise of that key is equivalent to full control of the contract and of any allowance a user has granted it. The converse is also true: because operators cannot unpause or upgrade, loss of that one key is equivalent to a permanent shutdown. Transfer of the role is one-step and takes effect immediately, with no acceptance by the incoming address and no pending state to cancel.
Threat Model#
What The Platform Must Protect#
The router is designed to hold no funds, so the questions that matter are whether a caller can lose value on a route they signed, whether anyone can reach a token or an allowance through the router that they could not reach directly, whether the configuration that decides where the router dispatches can be changed and observed only by the principals intended to hold that power, and whether governance can recover from the loss or compromise of a key.
Actors#
Admin: a single stored
Address, fully trusted by design and treated as such in this report. The relevant question is not whether the admin can be malicious, but how much the design concentrates in one key given thatupgradealone bypasses every other control.Operators: an M-of-N set that curates the registry and rewrites its own membership. Trusted to be honest but assumed to make mistakes, because their payloads are hand-written JSON and several findings turn on a single mis-edited field.
Sender: any account, treated as potentially malicious. A sender authorizes its own trade and nothing else.
Route author: whoever composes the
RoutePlanthat the sender signs. In the intended deployment this is the LI.FI backend, and the gap between the route author and the signer is where the fee findings live.Registered adapter: an external contract, out of scope and therefore untrusted, which executes as the router's direct callee.
Anonymous caller: any network actor holding no role and no relationship to the protocol. Whether such an actor can cause a transfer or a state change is a load-bearing question, and in two cases the answer is yes.
Trust Boundaries And Assumptions#
Several controls do hold and were verified as holding. A sender's own authorization is genuinely required for their trade. An attacker cannot make a real token move by supplying a fake one. A fabricated token cannot forge a transfer out of a third party. The pause gate does block trading when it is set.
Three assumptions did not survive testing. The first is that the balance-delta spend cap constrains an adapter: it does not, because it is measured on tokens the caller declares rather than on the tokens the adapter moves, so it is vacuous in precisely the case that matters. The second is that a registered interface is an adapter: nothing enforces this, and a plain token registered with execute_fn set to transfer is enough to let an anonymous caller move the router's own balance. The third is that governance reflects the current operator set: at execution time it does not, so approvals banked by operators who have since been removed still count toward quorum.
A fourth assumption is operational rather than in code. docs/operations.md describes admin multisig, multiple approvers, and WASM pinning as the intended posture, but the contract enforces none of them, so a deployment that skips any of the three is indistinguishable on-chain from one that follows the runbook.
Assets At Risk#
The sender's input amount and any allowance granted to the router or to an adapter, which is the highest-value asset and the one the fee and slippage findings act on.
Any balance the router itself holds, nominally zero by design, yet reachable by an anonymous caller once a token is registered as an adapter and unrecoverable once it accumulates.
The integrity of the registry, because the address the router dispatches to determines where the value of every future route goes.
Governance availability, which an unbounded timelock can destroy permanently, and which a proposal entry's six-hour TTL can interrupt whenever the timelock is longer than that. A duplicated operator address could destroy it too until the client added a distinctness check on the operator list as part of remediation.
The event stream, which is the only off-chain record of what the protocol did. As audited it omitted governance entirely. The client has since added events on proposal creation, approval and execution. It still carries swap figures that a fake token can fabricate and a bridge figure the router never measured.
Dynamic Testing, Proof-of-Concept and Mutation Campaign#
The assessment was backed by an executable proof-of-concept campaign rather than by fuzzing, because the risks in a thin dispatcher are authorization and configuration invariants rather than parser or arithmetic corruption. The scenarios run in a scratch Rust project that builds against the audited crate and never modifies it, so no result depends on a change to the code under review.
Across the campaign, 142 proof-of-concept tests were executed against soroban-sdk 27.0.1, all green: 132 in the first pass, and a further 10 in the second-pass probe module described below. The 35 test cases recorded for this engagement are each backed by one of them, and all 35 are recorded as failing against the audited revision, meaning that the security property the case asserts did not hold in the implementation as reviewed. The findings section records, case by case, which of those properties the client has since corrected.
Two methodological choices shape the results. Attacks were re-run under real authorization trees instead of a global authorization mock, so a severity reflects what a victim would actually have to sign. This is what separates the anonymous drain, which needs only the attacker's own root call with no nested authorization, from the fee attacks, which need the victim to sign a transfer that was shown to them. Every attack also ships with a control probe: the drain has a control that aims the identical attack at an ordinary user and is correctly rejected, which attributes the result to the router's position on the call stack rather than to a permissive fixture.
What The Campaign Looked For#
Dispatch and the deputy problem: whether the router can be induced to act against itself or a third party, and what its authority is worth to a registered target. Confirmed in the strongest available form, since an anonymous caller holding no role drains the router's balance in the registered token.
Measurement integrity: whether caller-declared tokens can defeat the spend cap and forge the reported output and event. Confirmed, and bounded by a control showing that a fake token cannot make a real token move.
Governance state and recovery: snapshot against live operator set, banked approvals after operator removal, threshold raises, proposal expiry, duplicate operator addresses, unbounded timelocks, and whether operators can recover from a pause. Confirmed on every count.
Fee and value arithmetic: the ceiling on total fees, the effect of the one-unit floor across a split list, fee-free usage, and stranded balances. Confirmed, with boundary probes establishing that the guard admits exactly 100 percent and that a split list can charge many times the declared rate.
Observability and error hygiene: which governance actions are visible off-chain, whether event fields carry the information their names imply, and which declared errors are reachable and asserted. Confirmed as defective.
Controls that should hold: sender authorization on a trade, the pause gate on trading, rejection of step-level fees on a chained route, deadline enforcement on
swap_bridge, and the anti-lockout guard against ordinary bad input. Verified as holding.
Mutation Testing Of The Shipped Suite#
A passing suite tells you that the code does what the tests check. It does not tell you which of the code's safety checks are exercised. To measure that, each of the 21 guards in the contract was rewritten to false one at a time and only the 20 shipped tests were run against the mutated source, with a build failure reported distinctly from a surviving mutant so that a mutation which does not compile is never miscounted as coverage. As audited, eleven of the 21 survived.
The sharpest result was a pair of guards that raise the same error. The inter-leg slippage check in swap_bridge was caught, while the balance-delta spend cap in internal_swap, the router's single substantive control over an adapter, could be deleted with all twenty shipped tests still passing. The control that another finding shows to be vacuous was also the control that had never been tested, and those two facts were independent of each other. The entire pause gate also survived, which was consistent with ContractPaused being one of ten declared errors that no test asserted at the time. A dedicated probe confirmed that the gate does work, so this was a coverage gap rather than a dead path. The client has since added tests covering both the spend cap and the pause gate, along with the pausing mechanism and the ProposalAlreadyExecuted error.
Second-Pass Reconciliation Against An Independent Analysis#
After the first pass closed, the same revision was re-examined against 36 candidate issues produced by an automated analysis of the code. Each was treated as a hypothesis to verify against the source, not as a result to accept. Twelve restated findings already in this report. Eight did not survive verification and were discarded with a recorded reason. The most common failure was a threat model in which the only party harmed was the attacker. Four further candidates had been discarded before this pass began, principally because overflow-checks = true in the release profile makes the arithmetic they described abort rather than wrap. Twelve were confirmed and are included here.
Seven of the confirmed on-chain candidates were backed by 10 new proofs of concept, written this time as a probe module inside the audited crate's own test harness, so that the results come from the shipped test environment rather than from a reconstruction of it. Two facts emerged there that reading alone had not settled. A persistent entry that this contract writes and never reads receives a TTL of 4,095 ledgers, roughly 5.7 hours, against the 17,280 ledgers the contract applies on read. That measurement is what turns both TTL findings from a theoretical gap into a quantified one, and it is why a timelock of more than about six hours outlives the proposal it governs. Separately, deploying the contract without a global authorization mock fails outright, because the constructor's pause sub-call requires the admin's authorization. Every one of the twenty shipped tests mocks all authorization, so no existing test could have surfaced that constraint.
Refutations And Bounds#
Hypotheses that did not survive testing are not reported as findings. The stranded-funds path was shown to let an attacker strand only their own funds, and the probe is deliberately framed around that limit. The permissionless persistent-storage write reachable through get_interface was tested for a griefing and cost-shifting angle and shown to harm nobody, so it is reported as informational rather than as an availability issue. The single-signature upgrade is recorded as a centralization property rather than an exploitable flaw, because it requires the key of a principal who is trusted by design. Other severities are bounded rather than dismissed, and those bounds are now carried in the BVSS vectors rather than in prose alone: the registry attack requires a governance mistake to set up, scored as AX:H, and the fee attacks require the victim to sign the transfer they are shown, scored as AX:M.
Risk Methodology#
14.1 EXPLOITABILITY
Attack Origin (AO):
Attack Cost (AC):
Attack Complexity (AX):
Metrics:
| EXPLOITABILITY METRIC () | METRIC VALUE | NUMERICAL VALUE |
|---|---|---|
| Attack Origin (AO) | Arbitrary (AO:A) | 1 |
| Specific (AO:S) | 0.2 | |
| Attack Cost (AC) | Low (AC:L) | 1 |
| Medium (AC:M) | 0.67 | |
| High (AC:H) | 0.33 | |
| Attack Complexity (AX) | Low (AX:L) | 1 |
| Medium (AX:M) | 0.67 | |
| High (AX:H) | 0.33 |
14.2 IMPACT
Confidentiality (C):
Integrity (I):
Availability (A):
Deposit (D):
Yield (Y):
Metrics:
| IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
|---|---|---|
| Confidentiality (C) | None (C:N) | 0 |
| Low (C:L) | 0.25 | |
| Medium (C:M) | 0.5 | |
| High (C:H) | 0.75 | |
| Critical (C:C) | 1 | |
| Integrity (I) | None (I:N) | 0 |
| Low (I:L) | 0.25 | |
| Medium (I:M) | 0.5 | |
| High (I:H) | 0.75 | |
| Critical (I:C) | 1 | |
| Availability (A) | None (A:N) | 0 |
| Low (A:L) | 0.25 | |
| Medium (A:M) | 0.5 | |
| High (A:H) | 0.75 | |
| Critical (A:C) | 1 | |
| Deposit (D) | None (D:N) | 0 |
| Low (D:L) | 0.25 | |
| Medium (D:M) | 0.5 | |
| High (D:H) | 0.75 | |
| Critical (D:C) | 1 | |
| Yield (Y) | None (Y:N) | 0 |
| Low (Y:L) | 0.25 | |
| Medium (Y:M) | 0.5 | |
| High (Y:H) | 0.75 | |
| Critical (Y:C) | 1 |
14.3 SEVERITY COEFFICIENT
Reversibility (R):
Scope (S):
Metrics:
| SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
|---|---|---|
| Reversibility () | None (R:N) | 1 |
| Partial (R:P) | 0.5 | |
| Full (R:F) | 0.25 | |
| Scope () | Changed (S:C) | 1.25 |
| Unchanged (S:U) | 1 |
| Critical | High | Medium | Low | Informational |
| 9 - 10 | 7 - 8.9 | 4.5 - 6.9 | 2 - 4.4 | 0 - 1.9 |
Scope#
Assessment Summary & Findings Overview#
# | Title | Severity | Score | Status |
|---|---|---|---|---|
| Caller-supplied token addresses are invoked as contracts and trusted as measurement oracles, letting anyone forge swap outputs and events | Low | 3.8 | Risk Accepted | |
| The balance-delta spend cap is vacuous whenever the declared tokens differ from the tokens named in args | Low | 3.8 | Risk Accepted | |
| A single admin signature replaces the entire contract, bypassing the configured quorum and timelock | Low | 2.5 | Risk Accepted | |
| There is no maximum fee below total confiscation: a caller-authored route can charge 99.99 percent of the input | Low | 2.5 | Risk Accepted | |
| A swap that delivers nothing is reported as a success: fees are charged and a LifiSwap event is published for a trade that did not occur | Low | 2.1 | Solved | |
| The one-unit fee floor lets a split fee list charge many times the declared rate on small amounts | Informational | 1.7 | Acknowledged | |
| The router accepts itself as a fee destination and has no recovery path, so any balance it accumulates is stranded | Informational | 1.7 | Solved | |
| Three event schema defects: a topic slot spent on a constant, a receiver field that always holds the sender, and a self-referential admin change at deployment | Informational | 1.3 | Solved08/03/2026 | |
| Duplicate addresses in an operator-set update defeat the anti-lockout check and permanently brick governance | Informational | 1.2 | Solved | |
| An unbounded timelock in a routine Update permanently disables proposal creation and surrenders governance to the admin key | Informational | 1.1 | Acknowledged | |
| A token contract registered as an adapter lets any anonymous caller drain the router's balance | Informational | 1.0 | Acknowledged | |
| No governance action emits any event, so adapter registration and retargeting are invisible to off-chain monitoring | Informational | 1.0 | Solved | |
| The standalone swap and bridge entry points have no deadline, while the chained entry point enforces one | Informational | 1.0 | Acknowledged | |
| The check that rejects a negative slippage floor exists but is wired only to the chained entry point, so the standalone swap accepts a floor below zero | Informational | 1.0 | Solved | |
| Registering an adapter grants it the router's own authority for the duration of the call | Informational | 0.8 | Acknowledged | |
| No administrative path extends the instance TTL, so a paused contract with no proposal traffic is not self-sustaining | Informational | 0.8 | Solved | |
| Proposals never expire: proposal_ends_at is a floor with no ceiling, so an approved proposal stays executable indefinitely | Informational | 0.7 | Acknowledged | |
| Operators have no unpause or upgrade path, so loss of the single admin key leaves a paused protocol permanently halted | Informational | 0.7 | Acknowledged | |
| Admin authority transfers in one step to an address that never signed, with no pending state to cancel, while adding a single operator requires quorum and a timelock | Informational | 0.7 | Acknowledged | |
| The constructor installs an unvalidated OperatorConfig, so a zero threshold produces governance that executes proposals with no approvals | Informational | 0.6 | Acknowledged | |
| UnknownInterface is unreachable because get_interface extends the TTL before reading, so a revoked adapter reports an untyped host error | Informational | 0.6 | Solved | |
| get_interface is shaped like a getter but performs a permissionless persistent-storage write on every call | Informational | 0.6 | Acknowledged | |
| approve_proposal accepts approvals for an already-executed proposal, rewriting the only record of who authorised the action | Informational | 0.5 | Acknowledged | |
| validate_contract performs no check at all when wasm_hash is null, so an interface can be registered pointing at an address that is not a contract | Informational | 0.5 | Acknowledged | |
| Add and Remove operator proposals silently discard the threshold and timelock they were approved with, and the creation-time check validates those fields against the wrong operator set | Informational | 0.5 | Acknowledged | |
| Proposal quorum is evaluated against an operator-set snapshot, so removing a compromised operator does not invalidate their banked approvals | Informational | 0.3 | Acknowledged | |
| A proposal tagged Add silently overwrites a live interface, so the review signal and the effect disagree | Informational | 0.3 | Acknowledged | |
| Interface revocation is per-name rather than per-contract, so a shadow alias survives removal of a compromised adapter | Informational | 0.3 | Acknowledged | |
| The trade amount is bounded below but not above, so a large amount aborts in the fee multiplication with an untyped host error | Informational | 0.2 | Acknowledged | |
| Revoking an interface that was never registered reports success, so a failed revocation looks identical to a successful one | Informational | 0.1 | Acknowledged | |
| The upgrade path writes new code and nothing else: no migration hook, no on-chain schema version, and no instance TTL refresh | Informational | 0.1 | Acknowledged | |
| Mutation testing shows the shipped test suite does not cover eleven of twenty-one guards, including the router's central spend cap and the entire pause gate | Informational | 0.0 | Solved | |
| Nothing on-chain requires a fee: any anonymous user can omit the fee list and use the router for free | Informational | 0.0 | Acknowledged | |
| Error-code hygiene: ten of eighteen declared errors are asserted by no test, one is never raised at all, and the numbering has an unexplained gap | Informational | 0.0 | Solved | |
| The Rust toolchain is pinned only to "stable", so the deployed WASM is not reproducible and no minimum supported version is declared | Informational | 0.0 | Acknowledged | |
| Proposal entries are never TTL-extended after creation, and the project's persistent extension constants sit far below the network minimum | Informational | 0.0 | Acknowledged | |
| A newly registered interface receives no TTL extension at write, and the extension applied on read is shorter than the lifetime the network grants a new entry | Informational | 0.0 | Acknowledged | |
| The constructor's pause sub-call requires the admin's authorization, so a deployment naming a separate admin account cannot succeed on the deployer's signature alone | Informational | 0.0 | Acknowledged |
Findings & Tech Details#
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Description
Proof of Concept
Recommendation
Remediation Comment
Disclaimer#
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
