You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
structLoggingSnapshotSource;implSnapshotSourceforLoggingSnapshotSource{fnget(&self,key:&Rc) -> Result<Option<(Rc,Option)>,HostError>{eprintln!("Attempted to get ledger key: {:?}", key);Ok(None)}}#[test]fntest(){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:
The empty WASM hash is an implementation detail for native test contracts. It should be:
Filtered from SnapshotSource requests - The SDK should intercept requests for the empty WASM hash and return None without forwarding to the underlying source
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
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.
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.
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 theSnapshotSource, including aContractCodeentry with the empty WASM hash (e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855- the SHA256 of zero bytes).This can be observed by implementing a custom
SnapshotSourcethat logs allgetrequests:Output:
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:
SnapshotSourcerequests - The SDK should intercept requests for the empty WASM hash and returnNonewithout forwarding to the underlying sourceWhat did you see instead?
The empty WASM hash leaks through both interfaces:
SnapshotSourceimplementations receive requests for itWhy This Is Problematic
The
SnapshotSourcetrait enables powerful use cases like loading ledger state from external sources - RPC endpoints, network archives, or other remote data stores. These sources may involve: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 endProposed Solution
Wrap the
SnapshotSource: InEnv::new_for_testutils, wrap the providedSnapshotSourcein a filtering wrapper that intercepts requests forContractCodeentries with the empty WASM hash and returnsNoneimmediately without forwarding to the underlying source.Filter
to_ledger_snapshot()output: When generating aLedgerSnapshotviato_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 theSnapshotSourceside 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.