Skip to content

Empty WASM hash leaks to SnapshotSource and test snapshot files #1635

Description

@leighmcculloch

What version are you using?

soroban-sdk v23.2.1 (and prior versions)

Note that the SnapshotSource side of the problem is not visible to developers today even though the behaviour is present today. The behaviour will be visible after #1620 merges.

What did you do?

When registering a native test contract with Env::register(), the Host requests entries from the SnapshotSource, including a ContractCode entry with the empty WASM hash (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - the SHA256 of zero bytes).

This can be observed by implementing a custom SnapshotSource that logs all get requests:

struct LoggingSnapshotSource;

impl SnapshotSource for LoggingSnapshotSource {
    fn get(&self, key: &Rc) -> Result<Option<(Rc, Option)>, HostError> {
        eprintln!("Attempted to get ledger key: {:?}", key);
        Ok(None)
    }
}

#[test]
fn test() {
    let e = Env::from_ledger_snapshot(LoggingSnapshotSource);
    let contract_id = e.register(Contract, ());
    // ...
}

Output:

Attempted to get ledger key: ContractCode(LedgerKeyContractCode { hash: Hash(e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855) })
Attempted to get ledger key: ContractData(LedgerKeyContractData { contract: Contract(...), key: LedgerKeyContractInstance, durability: Persistent })

Additionally, the empty WASM hash entry appears in test snapshot JSON files written to disk:

{
  "contract_code": {
    "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  }
}

What did you expect to see?

The empty WASM hash is an implementation detail for native test contracts. It should be:

  1. Filtered from SnapshotSource requests - The SDK should intercept requests for the empty WASM hash and return None without forwarding to the underlying source
  2. Excluded from ledger snapshots - The empty WASM code entry should not appear in snapshot JSON files written to disk

What did you see instead?

The empty WASM hash leaks through both interfaces:

  • Custom SnapshotSource implementations receive requests for it
  • Test snapshot files contain the empty WASM entry

Why This Is Problematic

The SnapshotSource trait enables powerful use cases like loading ledger state from external sources - RPC endpoints, network archives, or other remote data stores. These sources may involve:

  • Network requests that add latency
  • Rate-limited APIs where each request counts
  • Expensive lookups across distributed systems

When the Host requests the empty WASM hash from a SnapshotSource, it forces these implementations to make a request for an entry that will never exist on any real network. This is a waste of resources and adds unnecessary latency to test setup.

The empty WASM hash is purely an internal implementation detail of how the Host and SDK handle native test contracts. Contract developers implementing custom SnapshotSources should not need to know about or handle this edge case - it should be invisible to them.

Similarly, the empty WASM entry appearing in snapshot JSON files is confusing noise. These files may be checked into source control, shared between teams, or used for debugging. The empty WASM entry adds clutter and raises questions about what it is and why it exists.

Current vs Proposed Behavior

sequenceDiagram
    participant Test
    participant SDK as Soroban SDK
    participant Host as Soroban Host
    participant Wrapper as FilteringSnapshotSource<br/>(NEW)
    participant SS as SnapshotSource
    participant Net as Network/RPC

    Test->>SDK: Env::from_ledger_snapshot(source)

    rect rgba(0, 200, 0, 0.15)
        Note right of SDK: ✅ NEW
        SDK->>SDK: Wrap source in FilteringSnapshotSource
    end

    SDK->>Host: Create Host with source
    Test->>SDK: env.register(Contract, ())
    SDK->>Host: Register native contract

    Note over Host,Net: Host requests empty WASM hash...

    alt OLD BEHAVIOR (being removed)
        rect rgba(255, 0, 0, 0.15)
            Note right of Host: ❌ REMOVED
            Host->>SS: get(ContractCode{hash: empty})
            SS->>Net: Request empty WASM (wasteful!)
            Net-->>SS: Not found
            SS-->>Host: None
        end
    else NEW BEHAVIOR (being added)
        rect rgba(0, 200, 0, 0.15)
            Note right of Host: ✅ NEW
            Host->>Wrapper: get(ContractCode{hash: empty})
            Wrapper-->>Host: None (intercepted, not forwarded)
        end
    end

    alt OLD BEHAVIOR (being removed)
        rect rgba(255, 0, 0, 0.15)
            Host->>SS: get(ContractData{instance})
            SS->>Net: Request contract instance
            Net-->>SS: Entry or not found
            SS-->>Host: Result
        end
    else NEW BEHAVIOR (being added)
        rect rgba(0, 200, 0, 0.15)
            Host->>Wrapper: get(ContractData{instance})
            Wrapper->>SS: get(ContractData{instance})
            SS->>Net: Request contract instance
            Net-->>SS: Entry or not found
            SS-->>Wrapper: Result
            Wrapper-->>Host: Result
        end
    end

    Note over Test,Net: Later, when saving snapshot...

    Test->>SDK: env.to_ledger_snapshot()
    SDK->>Host: get_stored_entries()
    Host-->>SDK: All entries including empty WASM

    alt OLD BEHAVIOR (being removed)
        rect rgba(255, 0, 0, 0.15)
            Note right of SDK: ❌ REMOVED
            SDK-->>Test: LedgerSnapshot WITH empty WASM entry
        end
    else NEW BEHAVIOR (being added)
        rect rgba(0, 200, 0, 0.15)
            Note right of SDK: ✅ NEW
            SDK->>SDK: Filter out empty WASM entry
            SDK-->>Test: LedgerSnapshot WITHOUT empty WASM entry
        end
    end
Loading

Proposed Solution

  1. Wrap the SnapshotSource: In Env::new_for_testutils, wrap the provided SnapshotSource in a filtering wrapper that intercepts requests for ContractCode entries with the empty WASM hash and returns None immediately without forwarding to the underlying source.

  2. Filter to_ledger_snapshot() output: When generating a LedgerSnapshot via to_ledger_snapshot(), filter out entries with the empty WASM hash before returning.

Why not fix this in the Host?

The Host exposes its raw storage to the SDK via get_stored_entries(). Even if the Host handled the SnapshotSource side internally, the SDK would still need to filter the entries when generating snapshots. Handling both in the SDK keeps the solution contained in one place.

It would be possible to fix this entirely in the Host, but it would require either not exposing the full storage to the SDK or changing how storage is exposed to the SDK. While doable, this would increase the amount of specialized test code in the Host, which is also not desirable.

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions