One-way xBTC-to-sBTC swap contract with custodial bridge model
| Contract | SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.xbtc-sbtc-swap-v4 |
| Receipt Token | SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.swapping-xbtc-v4 |
| Protocol | FastPool xBTC Swap |
| Clarity Version | Clarity 4 (Stacks 3.x — uses as-contract?, with-ft) |
| Lines of Code | ~130 (swap) + ~80 (receipt token) |
| Confidence | HIGH — full source available on-chain, both contracts reviewed, simple architecture |
| Audit Method | aibtcdev/skills clarity-audit framework |
| Date | April 1, 2026 |
Verdict: PASS — Core swap logic is sound. The receipt token is well-guarded. The custodial model is explicit, intentional, and appropriate for the xBTC deprecation use case. One medium finding related to the enroll function's open trait parameter. No funds at direct risk.
This contract implements a one-way xBTC-to-sBTC migration path:
The architecture is deliberately simple: two contracts (swap + receipt token), two privileged roles (deployer + operator), one custodian, and a hardcoded excess receiver. No maps, no complex state. The attack surface is minimal.
initialize sets custodian and operator exactly once. The is-none guard prevents re-initialization even by the deployer. This is a clean pattern.swapping-xbtc-v4 restricts mint/burn to the swap contract via set-swap-contract (also one-shot). No external caller can mint or burn receipt tokens.tx-sender or contract-caller matches the sender parameter. Standard SIP-010 compliant.claim-sbtc handles the case where sBTC balance is less than user's swxBTC balance — claims what's available without reverting. Users don't get stuck.withdraw-xbtc double-checks both the user's swxBTC balance AND the contract's xBTC balance before allowing withdrawal. No underflow possible.enroll accepts arbitrary trait-implementing contractsLocation: enroll function
(define-public (enroll
(enroll-contract <enroll-trait>)
(receiver (optional principal))
)
(begin
(asserts! (is-eq tx-sender deployer) err-unauthorized)
(as-contract? () (try! (contract-call? enroll-contract enroll receiver)))
)
)
Description: The enroll function calls an arbitrary contract (any contract implementing enroll-trait) using as-contract?, which executes as the swap contract's principal. While this is deployer-only, if the deployer key is ever compromised, an attacker could pass a malicious contract whose enroll function performs unauthorized actions (e.g., transferring tokens held by the swap contract).
Context: This is currently used for dual-stacking enrollment. The deployer is a trusted operator, and in practice the risk requires key compromise — not a code logic bug. The trait interface (enroll ((optional principal)) (response bool uint)) limits what can be called, but the body of a malicious implementation is unconstrained.
Impact: If deployer key is compromised, could drain contract funds via crafted enrollment contract. This is a privilege escalation vector, not a direct vulnerability.
Recommendation: Consider hardcoding the enrollment contract address or maintaining an allowlist. Alternatively, document that the deployer key must be kept in cold storage or behind a multisig.
unwrap-panic in read-only functionsLocation: get-xbtc-balance, get-swapping-xbtc-balance, get-sbtc-balance, get-swapping-xbtc-supply
(define-read-only (get-xbtc-balance (user principal))
(unwrap-panic (contract-call? 'SP3DX3H4FEYZJZ586MFBS25ZW3HZDMEW92260R2PR.Wrapped-Bitcoin
get-balance user
))
)
Description: All four read-only helper functions use unwrap-panic. If the underlying token contract ever returns an error (unlikely but possible during chain upgrades or if the token contract is replaced), these functions panic with an opaque runtime error instead of a readable error code.
Impact: Low — these are convenience wrappers. The public functions that depend on them would fail with an uninformative error, but no funds are at risk.
Recommendation: Use (unwrap! ... (err u520)) for better error diagnostics. Not urgent.
Description: The contract has no circuit breaker. If a vulnerability is discovered after deployment, there's no way to halt deposits, claims, or withdrawals without deploying a new contract version.
Context: For a simple migration contract with a finite lifespan (xBTC deprecation), this is a reasonable trade-off. Adding a pause mechanism increases complexity and introduces its own centralization risk (who controls the pause?).
Impact: Low — acceptable design choice for a time-limited migration tool.
Recommendation: For future versions, consider a minimal (define-data-var paused bool false) toggle.
withdraw-excess-sbtc is permissionlessDescription: Anyone can call withdraw-excess-sbtc, which sends any sBTC balance exceeding the swxBTC supply to the hardcoded excess-sbtc-receiver. The excess only goes to a known, fixed address — never to the caller.
Assessment: This is a design choice, not a vulnerability. Permissionless sweeping means excess sBTC doesn't sit idle. A griefing actor could trigger it repeatedly, but the effect is just moving funds to where they belong faster. No user funds are affected.
Description: The swap relies on a custodian to convert xBTC to BTC and deposit into the sBTC bridge. This is a centralized trust point by design — the xBTC-to-sBTC migration fundamentally requires someone to unwrap xBTC (which is a custodial wrapped Bitcoin) and re-wrap it as sBTC.
Assessment: Users should understand that between init-unwrap (when xBTC leaves the contract) and sBTC becoming available, their funds are in the custodian's hands. The contract provides withdraw-xbtc as an escape hatch before the unwrap cycle. This is a reasonable design for a bridge/migration tool.
| Check | Result | Detail |
|---|---|---|
| SIP-010 compliance | PASS | All required functions implemented |
| Mint access control | PASS | Only swap contract can mint (enforced by is-swap-contract-calling) |
| Burn access control | PASS | Only swap contract can burn |
| Transfer authorization | PASS | Checks tx-sender or contract-caller = sender |
| Swap contract assignment | PASS | One-shot via is-none guard, deployer-only |
| Supply cap | N/A | No cap — appropriate since supply is 1:1 backed by xBTC deposits |
The receipt token is clean and well-implemented. The set-swap-contract one-shot pattern is a good practice — it prevents the deployer from redirecting mint/burn authority after deployment.
User Swap Contract Custodian
| | |
|-- deposit-xbtc ---------->| |
|<-- swxBTC minted ---------| |
| | |
| |-- init-unwrap (operator) ->|
| | (all xBTC sent) |
| | |
| | [converts xBTC -> BTC -> sBTC bridge]
| | |
| |<-- sBTC deposited ---------|
| | |
|-- claim-sbtc ------------>| |
|<-- sBTC received ---------| |
| (swxBTC burned) | |
This is a well-scoped, minimal contract that does one thing: migrate xBTC holders to sBTC. The code is clean, the access controls are correct, and the receipt token pattern is solid. The one medium finding (open trait in enroll) is a defense-in-depth concern, not an active exploit vector — it requires deployer key compromise. The custodial model is inherent to the xBTC deprecation process and is handled transparently.
For users: Safe to use. Understand that after the operator triggers init-unwrap, your xBTC is with the custodian until sBTC arrives. Use withdraw-xbtc if you change your mind before the unwrap cycle.