Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions bridgelet-audit/glossary/sweep-nonce.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,64 @@

**Path:** `bridgelet-audit/glossary/sweep-nonce.md`
**Component:** `SweepController`
**Related Functions:** `execute_sweep()`, `get_nonce()`, `update_authorized_destination()`
**Related Functions:** `construct_sweep_message()`, `verify_sweep_auth()`, `execute_sweep()`, `claim()`, `get_nonce()`, `update_authorized_destination()`

---

## Overview

The **Sweep Nonce** is a monotonically increasing 64-bit unsigned integer (`u64`) maintained by the `SweepController` contract instance. It provides cryptographic replay protection for off-chain Ed25519 sweep authorizations.
The **Sweep Nonce** is a 64-bit unsigned integer (`u64`) stored in the `SweepController` contract state. It serves as the core replay-protection counter for off-chain Ed25519 authorization signatures.

By folding the current on-chain sweep nonce into the cryptographic message digest before hashing and signing, Bridgelet Core ensures that every signed sweep payload is bound to a single transaction execution and cannot be replayed.

---

## How `construct_sweep_message()` Folds the Sweep Nonce

When `SweepController::execute_sweep()` is called, the contract invokes `verify_sweep_auth()`, which internally calls `construct_sweep_message()`.

The function constructs the 32-byte SHA-256 digest using the following procedure:

1. **Read On-Chain Nonce**: Calls `storage::get_sweep_nonce(env)` to retrieve the current stored `u64` value.
2. **Serialize Destination Address**: Serializes `destination.to_xdr(env)`.
3. **Encode Nonce in Big-Endian Format**: Converts the 64-bit unsigned integer into an 8-byte big-endian byte sequence:
```rust
message.push_back(((nonce >> 56) & 0xFF) as u8);
message.push_back(((nonce >> 48) & 0xFF) as u8);
message.push_back(((nonce >> 40) & 0xFF) as u8);
message.push_back(((nonce >> 32) & 0xFF) as u8);
message.push_back(((nonce >> 24) & 0xFF) as u8);
message.push_back(((nonce >> 16) & 0xFF) as u8);
message.push_back(((nonce >> 8) & 0xFF) as u8);
message.push_back((nonce & 0xFF) as u8);
```
4. **Serialize Controller Address**: Appends `contract_id.to_xdr(env)` (the `SweepController` contract address).
5. **Hash Digest**: Computes `SHA256(destination_xdr || nonce_be_u64 || controller_id_xdr)` and returns the resulting `BytesN<32>` payload for Ed25519 verification.

---

## Technical Mechanism
## Code Path Nonce Mutations (`execute_sweep` vs `claim`)

Not all sweep pathways interact with the sweep nonce in the same manner:

1. **Initialization**: Set to `0` when `SweepController::initialize` is invoked.
2. **Signature Payload Inclusion**: Off-chain signers must construct the cryptographic message digest as:
$$\text{message} = \text{SHA256}(\text{destination.to\_xdr()} \mathbin{\Vert} \text{nonce}_{\text{be\_u64}} \mathbin{\Vert} \text{controller\_id.to\_xdr()})$$
3. **Verification & State Mutation**:
- During `execute_sweep()`, `SweepController` fetches its current stored nonce and verifies the signature against `message`.
- Upon successful signature verification, `SweepController` increments `nonce` (`nonce = nonce + 1`) **before** triggering downstream token transfers.
4. **Replay Prevention**: Any attempt to replay the same signed message fails because the on-chain nonce has incremented, causing signature verification to fail (`Error::SignatureVerificationFailed`).
### 1. `execute_sweep()` (Increments Nonce)
- **Flow**: `execute_sweep()` verifies the signature against `construct_sweep_message()`. Upon successful verification, it explicitly calls `authorization::increment_nonce(env)`.
- **State Mutation**: Increments `nonce = nonce + 1` in instance storage **before** initiating token transfers.
- **Replay Protection**: The incremented nonce immediately invalidates the signature used in this call, preventing any attacker from replaying the transaction payload.

### 2. `claim()` (Does NOT Increment Nonce)
- **Flow**: `claim()` uses Soroban native authorization (`recipient.require_auth()`) instead of an Ed25519 signature payload.
- **State Mutation**: `claim()` invokes `EphemeralAccount::sweep_claim()` directly and **does not** call `authorization::increment_nonce()`.
- **Side Effect**: Because `claim()` leaves `nonce` at its existing value (e.g. `0` if all sweeps were executed via `claim()`), `update_authorized_destination()` (which checks `nonce > 0` to lock updates) remains unlocked.

---

## Important Interactions & Known Behaviors
## Operational Rule: Read `get_nonce()` Live From Chain

> **CRITICAL FOR OFF-CHAIN SIGNERS**:
> Off-chain applications, SDKs, and HSM services **must read `SweepController::get_nonce()` live from the Stellar ledger** immediately before constructing and signing the sweep message.

- **`claim()` Interaction**: The gas-free `claim()` function uses Soroban native authorization (`recipient.require_auth()`) instead of an Ed25519 signature payload, and **does not** increment the controller's `sweep_nonce`.
- **Destination Immutability Lock**: `update_authorized_destination()` checks `nonce > 0` to determine if a sweep has occurred and lock the authorized destination.
### Why Local Off-Chain Nonce Tracking Fails:
- If off-chain services maintain an internal/cached nonce counter, any out-of-order transaction submission, failed transaction, or concurrent sweep will desynchronize the local counter from the true on-chain nonce.
- Constructing a message with a stale or mismatched nonce causes `construct_sweep_message()` on-chain to hash a different byte sequence than the off-chain signer, resulting in signature verification failure (`Error::SignatureVerificationFailed`).
- Always query `SweepControllerClient::get_nonce()` via RPC prior to signing.
110 changes: 110 additions & 0 deletions bridgelet-audit/runbooks/diagnose-failed-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Operational Runbook: Diagnosing a Failed `execute_sweep` or `claim` Call

**Path:** `bridgelet-audit/runbooks/diagnose-failed-sweep.md`
**Component:** `SweepController`, `EphemeralAccount`, SEP-41 Tokens
**Target Invocations:** `SweepController::execute_sweep()`, `SweepController::claim()`

---

## Purpose & Scope

This runbook provides a structured triage checklist and diagnostic tree for operators, relayers, and developers investigating failed sweep transactions on Bridgelet Core.

Sweep operations can fail due to account state invalidity, signature verification mismatch, parameter locking violations, or SEP-41 token transfer issues. Follow the steps below in order to systematically isolate and resolve the root cause.

---

## Step-by-Step Triage Checklist

```
[ Sweep Transaction Failed ]
Step 1: Read can_sweep()
/ \
[False] [True]
/ \
Account State Issue Step 2: Check Call Type
(Expired/AlreadySwept/ / \
No Payment) [execute_sweep] [claim]
│ │
Step 3: Signature & Step 4: Token Trustlines
Nonce Verification & Balance Checks
```

---

### Step 1: Rule Out Account-State Issues via `can_sweep()`

Before inspecting cryptographic signatures or token contract state, verify the readiness of the `EphemeralAccount`.

#### Action
Call `SweepController::can_sweep(env, ephemeral_account_address)` or query state via `EphemeralAccount::get_info()`.

#### Evaluation
- **If `can_sweep()` returns `false`**, inspect `EphemeralAccount::get_info()` to determine the exact state failure:
1. **`AccountStatus::Active` (0)**: No payment has been recorded yet (`payment_received == false`). Sweeping requires at least one recorded payment (`Error::AccountNotReady` / `Error::NoPaymentReceived`).
2. **`AccountStatus::Swept` (2)**: The account has already been swept. Re-sweeping is blocked (`Error::AlreadySwept`).
3. **`AccountStatus::Expired` (3)**: The account passed its `expiry_ledger` and was expired or recovered. Funds can no longer be swept (`Error::AccountExpired`).
4. **Ledger Sequence Check**: Check if `current_ledger >= expiry_ledger`. If so, `is_expired()` returns `true`, blocking sweep calls.

---

### Step 2: Triage Cryptographic & Signature Failures (`execute_sweep` Branch)

If `can_sweep()` is `true` but `execute_sweep()` fails or reverts during execution, the failure is typically caused by signature verification or parameter mismatch.

#### Diagnostic Checklist for `execute_sweep`:

1. **Verify On-Chain Nonce Alignment**:
- Query `SweepController::get_nonce()`.
- **Check**: Did the off-chain signer construct the message using the exact current on-chain nonce? If the off-chain service used a cached or stale nonce, signature verification fails with `Error::SignatureVerificationFailed`.
- **Resolution**: Re-read `get_nonce()` live from the contract and regenerate the signature payload.

2. **Verify SHA-256 Message Layout**:
- The message payload signed off-chain **must** match:
$$\text{message} = \text{SHA256}(\text{destination.to\_xdr()} \mathbin{\Vert} \text{nonce}_{\text{be\_u64}} \mathbin{\Vert} \text{controller\_id.to\_xdr()})$$
- **Check**: Ensure no extra bytes (e.g. timestamps or legacy headers) were included. `destination` and `controller_id` must be valid Soroban XDR byte encodings.

3. **Verify `authorized_signer` Public Key**:
- Inspect `storage::get_authorized_signer()`.
- Confirm the Ed25519 private key used to sign matches the 32-byte public key registered during `SweepController::initialize()`.

4. **Verify Destination Lock (`authorized_destination`)**:
- If `SweepController` was initialized in **Locked Mode** (`authorized_destination = Some(locked_addr)`), passing any `destination != locked_addr` returns `Error::UnauthorizedDestination`.

---

### Step 3: Triage Authorization & Token Transfer Issues (`claim` Branch)

The gas-free `claim()` function bypasses Ed25519 signatures and relies on Soroban native auth (`recipient.require_auth()`). If `can_sweep()` is `true` but `claim()` fails:

#### Diagnostic Checklist for `claim`:

1. **Verify Outer Transaction Authorization**:
- Confirm the transaction submitted by the relayer includes a valid Soroban auth entry signed by `recipient`.
- If `recipient` did not sign the `claim(recipient, ephemeral_account)` contract invocation, Soroban auth fails.

2. **Verify Destination Lock (`authorized_destination`)**:
- In locked mode, `claim(recipient, ephemeral_account)` requires `recipient == authorized_destination`. If `recipient` differs, `claim()` returns `Error::UnauthorizedDestination`.

3. **Check Token Trustlines & Asset Balances**:
- Iterate over each payment asset in `EphemeralAccount::get_info().payments`.
- For each `payment.asset` (SEP-41 token contract):
a. **Trustline Check**: Does the `recipient` address have an established trustline / active balance entry for the asset? On Stellar, non-native tokens require a trustline to receive funds.
b. **Token Contract Status**: Is the SEP-41 token contract active and un-paused?
c. **Balance Verification**: Does the `EphemeralAccount` hold a liquid token balance $\ge \text{payment.amount}$ inside the SEP-41 token contract? If recorded payment amounts exceed the actual token balance held by the account, `token.transfer()` will fail with `Error::TransferFailed`.

---

## Common Error Code Reference

| Error Variant | Code | Root Cause | Primary Fix |
| :--- | :--- | :--- | :--- |
| `AccountNotReady` | 5 | Payment count is zero or total amount is 0. | Ensure `record_payment()` was called prior to sweep. |
| `AccountExpired` | 6 | Current ledger $\ge$ `expiry_ledger`. | Process via `expire()` or `recover()` instead of sweep. |
| `AccountAlreadySwept` | 7 | Account status is already `Swept`. | Do not retry sweep; inspect prior `SweepExecutedMulti` events. |
| `SignatureVerificationFailed` | 9 | Nonce desync or incorrect message payload layout. | Re-read `get_nonce()` live and re-sign SHA-256 message payload. |
| `AuthorizedSignerNotSet` | 10 | `SweepController` was not initialized. | Invoke `SweepController::initialize()` with valid public key. |
| `UnauthorizedDestination` | 13 | Destination does not match locked address. | Pass the registered `authorized_destination` address. |
| `TransferFailed` | 2 | Underlying SEP-41 `token.transfer()` call failed. | Check recipient trustlines and account token balances. |
142 changes: 142 additions & 0 deletions bridgelet-audit/runbooks/restore-archived-instance-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# Operational Runbook: Restoring an Archived (TTL-Expired) Contract Instance

**Path:** `bridgelet-audit/runbooks/restore-archived-instance-storage.md`
**Component:** Soroban State Management (`EphemeralAccount`, `SweepController`, `ReserveContract`, `AccountFactory`)
**Operation:** Soroban `RestoreFootprintOp`

---

## Purpose & Overview

In Soroban, all contract instances, WASM bytecode entries, and contract storage entries are subject to Time-To-Live (TTL) storage expiration. If a contract instance or its storage entries are not periodically extended via rent bumps (`BumpFootprintExpirationOp`), their TTL drops to zero, transitioning the entry into an **archived state**.

Once a contract instance is archived, regular transaction invocations (e.g. `sweep()`, `get_info()`, `can_sweep()`) will fail with storage footprint errors (`HostError: Error(Storage, ExceededRentLimit)` or missing key errors).

This runbook details the conceptual framework and step-by-step operational procedure for executing a Soroban `RestoreFootprintOp` transaction to restore an archived contract instance to active status.

---

## High-Risk Components & Exposure Profile

While all four contracts in Bridgelet Core utilize Soroban instance storage, their exposure profiles differ:

| Contract | Exposure Level | Primary Cause |
| :--- | :--- | :--- |
| **`EphemeralAccount`** | **CRITICAL (Highest Exposure)** | Deployed dynamically per transfer/payment. Accounts waiting for long-expiry ledgers or delayed payments may sit idle past default TTL without automated rent extensions. |
| **`SweepController`** | Moderate | Shared controller contract instance. Frequently invoked during sweeps, naturally bumping TTL, but can expire if unused during low-volume periods. |
| **`ReserveContract`** | Moderate | Standalone config store. Rarely updated after initial setup, making it prone to TTL expiration if unmonitored. |
| **`AccountFactory`** | Low / Moderate | Shared deployment factory. Active during batch creation, but instance state can expire if no deployments occur within TTL window. |

---

## Conceptual Architecture: The `RestoreFootprintOp` Pattern

Soroban separates storage restoration into a specialized transaction structure:

```
┌────────────────────────────────────────────────────────────────────────┐
│ Stellar RPC / Horizon │
│ 1. Simulate invocation to obtain Read-Only footprint for archived key │
└───────────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ RestoreFootprintOp Transaction │
│ 2. Package footprint keys into RestoreFootprintOp │
│ 3. Submit transaction to restore archived entry back to active state │
└───────────────────────────────────┬────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ Post-Restoration Verification │
│ 4. Execute read-only contract call (e.g. get_info()) to confirm │
└────────────────────────────────────────────────────────────────────────┘
```

1. **Footprint Assembly**: Archived keys are specified in the transaction's `readOnly` footprint ledger bounds.
2. **Resource Payment**: The fee-payer submits `RestoreFootprintOp` and pays the required state restoration fee (rent restoration stroops).
3. **Re-Activation**: The Soroban host un-archives the storage entry and resets its TTL to the minimum network live threshold.

---

## Step-by-Step Restoration Procedure

### Step 1: Identify the Archived Instance & Fetch Footprint
When an invocation fails due to archived state, simulate the transaction using the Soroban RPC endpoint or Stellar CLI to extract the required footprint keys.

```bash
# Example: Simulate read invocation to capture footprint for an archived EphemeralAccount
stellar contract read \
--id <EPHEMERAL_ACCOUNT_CONTRACT_ID> \
--network testnet \
-- \
get_info
```

*RPC Response will indicate storage eviction / archived key presence in the footprint requirement.*

### Step 2: Execute `RestoreFootprintOp` via CLI / SDK

Submit a footprint restoration transaction covering the archived contract instance and WASM code keys.

#### Using Stellar CLI:
```bash
stellar contract restore \
--id <EPHEMERAL_ACCOUNT_CONTRACT_ID> \
--source <FEE_PAYER_SECRET> \
--network testnet
```

#### Using JS/TS SDK:
```typescript
import { Operation, TransactionBuilder, Horizon } from '@stellar/stellar-sdk';

// Construct RestoreFootprint transaction using footprint from simulation
const restoreOp = Operation.restoreFootprint({});
const tx = new TransactionBuilder(sourceAccount, { fee: '100000', networkPassphrase })
.addOperation(restoreOp)
.setTimeout(30)
.build();

tx.preflightUploadAndRestore(server);
```

---

## Step 3: Post-Restoration Verification

After the `RestoreFootprintOp` transaction confirms on-chain, verify that the contract instance has successfully returned to active status.

### Verification Matrix by Contract:

| Target Contract | Verification Command | Expected Successful Response |
| :--- | :--- | :--- |
| **`EphemeralAccount`** | Call `get_info()` or `get_status()` | Returns `AccountInfo` struct with valid status (`Active`/`PaymentReceived`). |
| **`SweepController`** | Call `get_nonce()` | Returns current `u64` sweep nonce without storage error. |
| **`ReserveContract`** | Call `has_base_reserve()` | Returns `true` or `false` boolean value. |
| **`AccountFactory`** | Simulate `batch_initialize()` query | Responds without footprint eviction error. |

#### Verification Execution Example:
```bash
# Confirm EphemeralAccount is readable again
stellar contract invoke \
--id <EPHEMERAL_ACCOUNT_CONTRACT_ID> \
--network testnet \
--source <READ_ONLY_ACCOUNT> \
-- \
get_status
```

---

## Post-Restoration Best Practice: Extend TTL Immediately

Once restored, execute a `BumpFootprintExpirationOp` to extend the contract instance's TTL for a substantial ledger duration (e.g. 500,000 ledgers) to prevent immediate re-archiving.

```bash
stellar contract extend \
--id <EPHEMERAL_ACCOUNT_CONTRACT_ID> \
--ledgers-to-extend 500000 \
--source <FEE_PAYER_SECRET> \
--network testnet
```
Loading
Loading