Summary

Type: DeFi
Timeline: March 3, 2025 → March 6, 2025
Languages: Golang

Findings
Total issues: 16 (14 resolved)
Critical: 0 (0 resolved)
High: 5 (5 resolved)
Medium: 2 (2 resolved)
Low: 3 (3 resolved)

Notes & Additional Information
5 (4 resolved)

Client Reported Issues
1 (0 resolved)

Scope

We performed a diff audit of pull request #200 of the lombard-finance/ledger repository at commit 0e25fc0 against commit 04ac685.

In scope for the diff audit were the following files:

 ledger
├── app
│   ├── upgrade.go
│   └── upgrades
│       └── v040
│           ├── constants.go
│           └── upgrades.go
├── notaryd
│   ├── config
│   │   └── config.go
│   ├── start.go
│   ├── types
│   │   ├── deposit_btc_msg.go
│   │   ├── unstake_msg.go
│   │   └── update_val_set_msg.go
│   └── verifier
│       ├── deposit_strategy.go
│       ├── errors.go
│       ├── solana
│       │   ├── config.go
│       │   └── fetcher.go
│       ├── sui
│       │   └── fetcher.go
│       ├── unstake_strategy.go
│       └── verifier.go
└── x
    └── notary
        ├── exported
        │   └── types.go
        ├── keeper
        │   └── val_set.go
        └── types
            ├── errors.go
            ├── message_submit_payload.go
            └── val_set.go

After the previous audit, we performed another diff audit of pull request #230 at commit 037ead0 against commit bd2d376, which is an architecture-level security enhancement.

In scope for this diff audit were the following files:

 ledger
├── notaryd
│   ├── types
│   │   └── unstake_msg.go
│   └── verifier
│       ├── solana
│       │   └── fetcher.go
│       └── unstake_strategy.go
└── x
    └── notary
        └── types
            └── message_submit_payload.go

System Overview

Lombard bridges the gap between Bitcoin staking and DeFi by enabling BTC holders to provide economic security to DeFi protocols. At its core, the protocol allows users to stake Bitcoin through Babylon and receive LBTC, which can then be used to provide liquidity across multiple blockchain networks. Babylon is a protocol that extends Bitcoin's utility by allowing it to secure Proof-of-Stake networks and applications. While Babylon enables Bitcoin staking, it requires locking up the BTC, which limits its broader utility. Lombard addresses this limitation through LBTC, creating a liquid representation of Babylon-staked positions that remain active in DeFi markets.

When users interact with Lombard, they deposit Bitcoin into Lombard addresses which is then staked through Babylon to generate rewards. Users receive LBTC tokens on their chosen blockchain at a 1:1 ratio with their deposited BTC. These tokens can be used in DeFi applications while continuing to earn staking rewards. Users can later redeem their LBTC for BTC by burning the tokens, which triggers the release of the original BTC deposit from the Lombard address. They can also transfer LBTC across supported chains through either a canonical bridge or, on certain chains, through the Layer Zero Omnichain ERC-20 bridge.

The audited scope introduces modifications to the Ledger and the companion service, notaryd, to enable support for depositing BTC and minting LBTC on the Solana blockchain, as well as unstaking from Solana to receive BTC on the Bitcoin blockchain. However, transferring LBTC from Solana to other blockchains is not supported. The Ledger currently supports both legacy and version 0 Solana transactions.

The scope of this audit was limited to the changes made to the Ledger codebase. Therefore, various assumptions were made about the development and functionality of the Solana blockchain's smart contracts and their interaction with the Ledger blockchain.

Security Model and Trust Assumptions

This audit focused on the addition of the aforementioned new features to the Ledger, with the security model and trust assumptions remaining consistent with those established in the initial Lombard audit. The changes made to support the Solana blockchain as a destination chain were only audited from the Ledger side. Therefore, it was assumed that the Solana programs would function equivalently to the implementation of the EVM smart contracts.

The notaryd companion service does not prevent duplicate deposit and unstaking payloads from being notarized twice. Thus, it is up to the consumer (i.e., the payload spender) to prevent double-spending. Hence, for deposits, the Solana program, and all other relevant contracts, should ensure that duplicate notarized deposits do not result in double-minting LBTC. Similarly, for unstaking operations, the off-chain component transferring BTC back to the user should ensure that BTC is transferred once for each unique unstake.

It is assumed that the Solana LBTC program will maintain its accounts' active status and prevent erasure (e.g., due to insufficient rent fees) until the corresponding request is completed, ensuring process continuity.

Solana Logs

The Ledger relies on Solana logs for ensuring that LBTC has been redeemed on the Solana blockchain and notarizes unstake payloads enabling the transfer of BTC back to the user. However, relying on Solana logs is unreliable, as they are not stored on-chain, and could lead to several issues:

  • Solana VM default logging might be changed in the future, breaking the event log parser and causing downtime or exploitation.
  • The Solana LBTC program might be upgraded in the future, breaking the event log parser and causing downtime or exploitation.
  • A malicious or compromised RPC operator could exploit the protocol, stealing BTC, by returning forged logs to trick the notaryd into notarizing an unstake that has not happened, or manipulating the data in the logs (e.g., changing the from address or the amount).
  • The Solana LBTC program might be vulnerable to emitting similar events as the redeem function without actually redeeming LBTC, resulting in BTC being released without a corresponding LBTC burn.

Update: The Lombard team has refined the notarization process for Solana unstake operations in line with our recommendation. Pull request #230 replaces log parsing with an approach that leverages data stored in a Program Derived Address (PDA), which the LBTC program creates for each unstake operation. We reviewed it in a new diff audit, as described in the scope section. The assumption for this pull request is that the ledger can always parse PDAs created by the LBTC program in the future.

 

High Severity

Incorrect Value of UnstakeEventDataOffset

The UnstakeEventDataOffset constant serves as an index to retrieve the UnstakeEvent log, which is emitted during the invocation of the unstake function of the LBTC program on the Solana blockchain. According to the comments on UnstakeEventDataOffset, the unstake invocation generates four logs related to the transfer process, four associated with the burn process, and finally, the UnstakeEvent log. Furthermore, the first log in every invocation is consistently the invocation log. Therefore, the offset between the invocation log and the UnstakeEvent log should be calculated as 1 + 4 + 4, which equals 9.

However, in the current implementation, UnstakeEventDataOffset is set to 8 rather than 9. The system checks that LogMessages[eventIndex] corresponds to the invocation log and that LogMessages[eventIndex+UnstakeEventDataOffset] corresponds to the UnstakeEvent log. Due to this discrepancy, the wrong log is retrieved, leading to a failure in the decoding process, resulting in all unstaking payloads being unable to be notarized and preventing BTC transfers back to their legitimate users.

Consider adjusting the value of UnstakeEventDataOffset to 9 to ensure accurate log retrieval and proper notarization of the unstaking process.

Update: Resolved in pull request #210 at commit 8eb0535. UnstakeEventDataOffset has been adjusted to 10 since the Solana LBTC program logs the instruction being executed (i.e., Program log: Instruction: Redeem) right after the invocation log.

Incorrect Check on Invocation Log

The Solana GetUnstake function examines the log string to confirm that it constitutes a Solana runtime invocation log. However, when evaluating the invoke depth field, it incorrectly ensures that the regex match test returns false instead of true. This incorrect condition prevents the logs from being parsed successfully.

Consider revising the code to ensure that the regex match test returns true.

Update: Resolved in pull request #210 at commit 0780c27. The Lombard team stated:

Issue fixed according to recommendation.

Potential Runtime Panic in DecodeUnstakeOnchainMsgFromSolanaAccountData Due to Unchecked User-Controlled PDA Data Length

As part of the updated scope at commit 037ead0, the new implementation could cause an Out-Of-Bounds runtime panic due to unchecked user-controlled PDA data lengths.

The DecodeUnstakeOnchainMsgFromSolanaAccountData function attempts to extract the first 8 bytes of PDA account data to validate against SolanaUnstakeInfoDiscriminator. However, since the unstakePDA address in GetUnstake is user-controlled, attackers could submit malicious PDAs with data shorter than 8 bytes. This would cause a slice bounds out-of-range panic during the discriminator check, crashing the notaryd service.

To prevent runtime crashes, consider verifying that the data length is at least 8 bytes (for the discriminator) plus the size of the unstake object before slicing or decoding.

Update: Resolved in pull request #240 at commits 29a97fe, d8904f4, and cca82c6.

Incorrect Unstake Amount in PDA Data Enables Fee Evasion and Invariant Violation

This issue was observed in the LBTC Solana program, though it fell outside the formal audit scope.

The redeem function of the Solana LBTC program transfers an LBTC fee to the treasury and burns amount - fee tokens from the user. However, it incorrectly stores the original pre-fee value in the PDA’s accounts.unstake_info.amount field. While the stored amount is validated against the user’s unstake payload message, users can exploit this mismatch by submitting payloads matching the full (pre-fee) amount. This allows them to claim the entire BTC value, bypassing the fee and breaking the protocol’s core locked_BTC == LBTC invariant.

Consider updating the PDA to store amount - fee to ensure accurate fee enforcement and maintain supply parity.

Update: Resolved in pull request #6 at commit 450921a of the lombard-finance/sol-contracts repository, which is out of scope. The Solana programs were not reviewed as part of this audit.

Unvalidated PDA Address Retrieval Exposes System to Resource Exhaustion Attacks

As part of the updated scope at commit 037ead0, the new implementation exposes the notaryd process to potential resource exhaustion due to unchecked user-controlled PDA data lengths.

The current implementation retrieves account data via Solana RPC’s GetAccountInfo using unvalidated user-provided PDA addresses without first verifying ownership, creating a risk of Denial-of-Service (DoS) attacks. Since Solana accounts can store up to 10MiB of data, attackers could exploit this by repeatedly submitting PDAs referencing large, non-LBTC-owned accounts, forcing the system to process excessive data payloads and leading to resource exhaustion.

Consider enforcing strict data retrieval limits by configuring the RPC request’s dataSlice parameter to restrict unnecessary data processing.

Update: Resolved in pull request #240 at commits 29a97fe, d8904f4, and cca82c6.

Medium Severity

Potential Integer Cast Overflow and Out-of-Bounds Access Panic on 32-Bit Platforms

In Golang, the size of an int varies by platform: 32 bits on 32-bit systems and 64 bits on 64-bit systems. After decoding the unstake message and retrieving uint32 EventIndex, this value is cast to an int in both solanaFetcher.GetUnstake and suiFetcher.GetUnstake. Subsequently, the int value is checked with the lengths of resp.Meta.LogMessages and resp.Events, and used as an index to access data. On a 32-bit platform, if a large EventIndex value is provided (e.g., math.MaxInt32 + 1), the cast may overflow with a negative value, passing the lengths check and triggering an out-of-bounds access panic afterward, crashing the notaryd process.

Consider casting eventIndex to architecture-agnostic int64 instead of int for safety and cross-platform consistency.

Update: Resolved in pull request #218 at commit 5087a5d.

Unstake Request Log Can Be Forged

The GetUnstake function within the Solana verifier currently retrieves logs and verifies the following conditions:

  • LogMessages[eventIndex] matches the Program <lbtcAddress> invoke [1-4] pattern. Here, eventIndex represents an index provided by users to denote the beginning of unstake logs, while lbtcAddress refers to the LBTC program's address on Solana.
  • LogMessages[eventIndex+UnstakeEventDataOffset] is a unstake request log, which is verified by decoding it into a SolanaUnstakeRequestEvent object. The constant UnstakeEventDataOffset is 8.
  • LogMessages[eventIndex+UnstakeEventLogEntries-1] corresponds to Program <lbtcAddress> success. The constant UnstakeEventLogEntries is defined as UnstakeEventDataOffset+3, resulting in a value of 11.

However, the current implementation lacks validation to ensure that the unstake request log at LogMessages[eventIndex+UnstakeEventDataOffset] is authentically emitted by the lbtcAddress program. This omission allows an attacker to forge a log that mimics the discriminator of the UnstakeRequest event and can be decoded into a SolanaUnstakeRequestEvent object, even if it originated from a malicious or unrelated program. Furthermore, according to the code comments, the values of UnstakeEventDataOffset and UnstakeEventLogEntries are subject to change.

Consider a scenario where UnstakeEventDataOffset remains 8, but UnstakeEventLogEntries adjusts to UnstakeEventDataOffset+4. The logs below represent an exploit scenario that would pass the verification for the unstake request:

  • LogMessages[eventIndex-z]: Program xxx invoke [1].
  • LogMessages[eventIndex]: Program lbtcAddress invoke [2]. In this case, Program xxx invokes the LBTC program, but the invocation is unrelated to an unstake operation.
  • LogMessages[eventIndex+3]: Program lbtcAddress ... compute units. Assume that the LBTC program emits two custom logs, which does not impact the exploit.
  • LogMessages[eventIndex+4]: Program lbtcAddress success.
  • LogMessages[eventIndex+8]: Program data: 0x.... Program xxx emits a log that can be decoded into a SolanaUnstakeRequestEvent object.
  • LogMessages[eventIndex+9]: Program lbtcAddress invoke [2]. Program xxx invokes the LBTC program again, unrelated to an unstake operation.
  • LogMessages[eventIndex+10]: Program lbtcAddress ... compute units. Assume that this invocation produces no custom logs.
  • LogMessages[eventIndex+11]: Program lbtcAddress success.
  • LogMessages[eventIndex+11+y]: Program xxx success.

Consider verifying all logs during the unstake invocation to ensure that they collectively represent a complete and legitimate process.

Update: Resolved in pull request #219 at commit 02399dc. The fix adds log validation to check that the second log is the Instruction: Redeem log, ensuring that the Redeem instruction is being called. This fix assumes that the verifier will always align with the Solana LBTC program regarding log content and sequence. Additionally, it is recommended to add more documentation to serve as a reference for future upgrades.

Low Severity

Missing Validation in Payload

The Solana unstake payload's txHash field is expected to contain the SHA256 hash of the transaction signature (validated off-chain), but no on-chain validation exists to enforce this.

Consider documenting the non-standard use of the txHash for Solana transactions, enforcing this rule on-chain to reject unstake payloads where txHash ≠ SHA256(signature) and thereby ensuring cross-component consistency.

Update: Resolved in pull request #220 at commit c297ec6.

Misleading Error Message

In the runNotaryd function, if one of the SolanaConfig RPC URLs cannot be parsed, the process fails with the "failed to parse rpc url for sui chain" error message.

Consider updating the error message to "failed to parse rpc url for solana chain".

Update: Resolved in pull request #221 at commit 178ed7e.

Retained Dead Code in Updated Implementation

As part of the updated scope at commit 037ead0, the new implementation fails to remove obsolete code.

Throughout the codebase, multiple instances of unused code, including constants, errors, and variables, were identified:

Consider removing all unused code artifacts and enforcing static analysis tooling to prevent accumulation of dead code in future iterations.

Update: Resolved in pull request #241 at commit 00320a7.

Notes & Additional Information

Reliance on Default RPC Configuration

The GetParsedTransaction function is called without a commitment configuration, defaulting to finalized commitment as per the Solana documentation. If the default configuration of the Solana Go implementation changes, it could leave the protocol vulnerable to notarizing unstake operations that have not been finalized and might not be included in the canonical blockchain.

Consider explicitly enforcing a finalized commitment configuration when querying the getTransaction RPC method.

Update: Resolved in pull request #222 at commit 7903bc4.

Delayed Failure in verifyTx

When the verifyTx function is called to verify a DepositBtcMsg with an invalid chain ID, which represents a non-existent ecosystem, the NewLChainId function returns a GenericLChainId value rather than an error.

Although an invalid chain ID in the payload would ultimately lead to a verification failure during address normalization, to enhance efficiency and clarity in the verification process, consider returning an error if the NewLChainId function produces a GenericLChainId.

Update: Resolved in pull request #224 at commit aa6be14. The fix ensures that the verifyTx function will immediately fail if the return value of NewLChainId is not a supported chain.

Unused Errors in errors.go

The errors.go file defines four errors. However, none of the errors are used since each fetcher file defines and uses its own errors.

Consider removing the errors.go file if the errors are not intended to be used.

Update: Acknowledged, not resolved. The Lombard team stated:

We need to refactor error management in notary. So, we will keep these errors until we do that.

SolanaConfigs Is not Initialized in DefaultNotaryConfig

The DefaultNotaryConfig function does not initialize the SolanaConfigs map, defaulting to a nil map.

Consider initializing the SolanaConfigs map in the DefaultNotaryConfig function.

Update: Resolved in pull request #225 at commit f57132a.

Undocumented UnstakeMsg.Payload.txHash Field Usage for Solana Payloads

As part of the updated scope at commit 037ead0, the new implementation does not document the intended data in the UnstakeMsg.Payload.txHash field for Solana unstake payloads.

The implementation lacks clear documentation for the UnstakeMsg.Payload.txHash field, which is intended to store the Unstake PDA address. This non-intuitive design leaves users unaware of the requirement to populate txHash with the PDA, leading to incorrect payload submissions and transaction failures.

Consider explicitly documenting the UnstakeMsg.Payload.txHash field’s purpose to clarify that txHash must reference the Solana unstake PDA, ensuring accurate payload construction.

Update: Resolved in pull request #239 at commits a7e5f55 and 7baebad.

Client Reported

Unsplit Message Payload Is Passed to DecodeUnstakeMsg

During the validation of the msg.Payload message payload, the ValidateBasic function splits the payload into two parts. The first part is the selector, which occupies the initial four bytes of the payload, and the second part is the payload data in raw bytes. When the selector is exported.UnstakeSelector, the payload is passed to the DecodeUnstakeMsg function to deserialize it into an UnstakeMsg object. However, the unsplit message payload, which includes the selector, is passed to the DecodeUnstakeMsg function, resulting in incorrect deserialization outcomes.

Update: This issue has been resolved in pull request #210 at commit ac3a27c. The Lombard team addressed this problem by ensuring that only the payload data, rather than the entire payload, is passed to the DecodeUnstakeMsg function. For future reference, consider implementing additional unit and integration tests to ensure the functionalities of the validation functions. 

Conclusion

This audit reviewed updates to the Lombard Ledger codebase and its companion service, notaryd, which introduce support for Solana as a destination chain for BTC staking, LBTC minting, and unstaking functionality. Overall, the code was found to be clean, well-organized, and reflective of a thoughtful effort to integrate Solana compatibility.

However, in addition to the recommendations provided in the report, we identified the following areas of concern:

  • The notaryd service lacks protection against replayed payloads, which could lead to double notarization. Thus, it requires proper handling by downstream consumers.

  • The reliance on Solana logs for unstake payload notarization is problematic, as logs are subject to future changes and may vary in availability across nodes.

To address these concerns, we recommend the following:

  • Update notaryd to prevent payload replays, reducing the risk of double notarization.
  • Switch from Solana logs to on-chain Solana state verification for more reliable unstake notarization. Alternatively, use Anchor’s emit_cpi with a reliable indexer to address log issues, or, if logs are retained, implement checks such as full log validation, program ID confirmation, and cryptographic proofs (e.g., Merkle trees) to enhance security.
  • Conduct thorough unit, fuzz, and integration testing to address identified vulnerabilities and ensure that the changes perform reliably under various conditions.

We thank the Lombard team for their exceptional responsiveness throughout the audit and their proactive efforts to resolve the issues highlighted.

Update: The Lombard team has refined the notarization process for Solana unstake operations in line with our recommendation. Pull request #230 replaces log parsing with an approach that leverages data stored in a PDA, which the LBTC program creates for each unstake operation. We reviewed it in a new diff audit, as described in the scope section.