Skip to content

Add external read-only contract storage access#1691

Open
mihaieremia wants to merge 3 commits into
stellar:mainfrom
mihaieremia:codex/external-storage-read
Open

Add external read-only contract storage access#1691
mihaieremia wants to merge 3 commits into
stellar:mainfrom
mihaieremia:codex/external-storage-read

Conversation

@mihaieremia

@mihaieremia mihaieremia commented Jun 14, 2026

Copy link
Copy Markdown

Discussion: stellar/stellar-protocol#1958

What

Adds protocol-28 Soroban host functions for read-only external contract storage access:

  • has_external_contract_data(contract, key, storage_type)
  • get_external_contract_data(contract, key, storage_type)

Persistent and temporary reads resolve to the target contract-data key. Instance reads resolve to the target contract instance entry and look up the embedded instance-storage map.

This also adds host tests, protocol-gate coverage, dispatch coverage, and a Wasm fixture importing the new host functions.

Why

Some contracts need read-only state owned by another contract, but do not need to execute that contract's Wasm. A lending controller is one example: it may need market config, reserve parameters, oracle references, collateral factors, or pause flags owned by market/governance/oracle contracts.

Today that read requires a cross-contract invoke to a getter. This PR adds a direct read-only path for intentionally public storage while preserving contract ownership boundaries.

Local P28 preflight measurement

Measured with local protocol-28 RPC preflight using two minimal contracts:

  • Store: noop, persistent getter, temporary getter, instance getter
  • Caller: noop, cross-contract invoke-to-getter methods, direct external-storage read methods

The instance case uses a 32-entry target instance map.

Case Instructions RO entries Resource fee
caller noop 454,842 2 50,399
caller -> store noop 924,152 4 53,873
store persistent getter 558,111 3 52,052
caller -> persistent getter 950,847 5 55,197
caller external persistent read 483,703 3 52,563
store temporary getter 558,107 3 52,052
caller -> temporary getter 950,843 5 55,197
caller external temporary read 483,703 3 52,563
store instance getter 537,882 2 50,955
caller -> instance getter 929,322 4 54,096
caller external instance read 586,622 3 52,611

Result:

  • Persistent external read: 483,703 instructions vs 950,847 for cross-contract getter.
  • Temporary external read: 483,703 instructions vs 950,843 for cross-contract getter.
  • Instance external read: 586,622 instructions vs 929,322 for cross-contract getter.

Persistent and temporary external reads are close to a direct getter baseline and avoid the nested VM dispatch plus extra footprint entries from invoking the target contract. Instance reads still save work compared to a getter call, but are less close to native reads because the target instance entry and storage map must be loaded and searched.

Open protocol questions

  • Should raw external reads require public storage declarations or layout metadata?
  • Should instance storage be included in v1?
  • What exact same-transaction visibility rules should apply when the target contract is active on the call stack?
  • When should the SDK expose this API relative to storage layout standardization?

Limitations

  • Protocol 28 only.
  • Read-only only: no external writes or TTL extension.
  • Does not run target contract code, auth, schema validation, or public-storage checks.
  • Companion SDK/RPC changes are needed for end-to-end use.

Validation

  • cargo test -p soroban-env-host external_contract_data --features next
  • cargo check -p soroban-sdk --features next
  • cargo test -p soroban-sdk --test external_storage_next --features "next testutils"
  • local P28 RPC preflight scratch test comparing getter calls with external reads
  • git diff --check

@mihaieremia
mihaieremia marked this pull request as ready for review June 14, 2026 17:34
Copilot AI review requested due to automatic review settings June 14, 2026 17:34
@mihaieremia
mihaieremia requested a review from dmkozh as a code owner June 14, 2026 17:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR adds new host APIs (and corresponding test Wasm) to support “try-get” style reads that preserve stored Void values, and introduces protocol-28-gated external contract storage reads that record read-only footprint entries.

Changes:

  • Added try_get_contract_data and external contract data read host functions (has/get/try_get) gated at protocol 28.
  • Introduced a new test contract Wasm (test_external_storage) and wired it into the test Wasm workspace and exported byte-include list.
  • Expanded host tests and added an ignored benchmark to validate footprint behavior and compare performance.

Reviewed changes

Copilot reviewed 14 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
soroban-test-wasms/wasm-workspace/external_storage/src/lib.rs New test contract that reads another contract’s storage via *_external SDK APIs.
soroban-test-wasms/wasm-workspace/external_storage/Cargo.toml Defines new test Wasm crate and enables soroban-sdk next feature.
soroban-test-wasms/wasm-workspace/Cargo.toml Adds external_storage workspace member and bumps env-common dep for building protocol-28 Wasms.
soroban-test-wasms/src/lib.rs Exposes protocol-28 optimized Wasm bytes for host tests.
soroban-env-host/src/test/storage.rs Adds tests for stored-Void semantics, external reads, and footprint recording.
soroban-env-host/src/test/protocol_gate.rs Adds protocol gating test for external contract data reads in native mode.
soroban-env-host/src/test/metering_benchmark.rs Adds an ignored benchmark comparing external read path vs contract view call.
soroban-env-host/src/host/data_helper.rs Refactors put_contract_data to update existing ledger entries via a single full-entry read.
soroban-env-host/src/host/conversion.rs Adds helper to build ledger keys from (contract address, key) pairs for external reads.
soroban-env-host/src/host.rs Implements new VM host functions: try_get_contract_data and external read variants.
soroban-env-host/src/builtin_contracts/storage_utils.rs Adds internal helpers for try-get value retrieval and external contract data reads.
soroban-env-host/src/builtin_contracts/stellar_asset_contract/balance.rs Switches internal reads to the new try_get_contract_data_value helper.
soroban-env-host/src/builtin_contracts/stellar_asset_contract/allowance.rs Switches internal reads to the new try_get_contract_data_value helper.
soroban-env-common/env.json Declares new host functions and their protocol gating / docs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +61 to +80
fn try_get_external_instance_data_value(
&self,
contract: AddressObject,
k: Val,
) -> Result<Option<Val>, HostError> {
let contract_id = self.contract_id_from_address(contract)?;
let instance_key = self.contract_instance_ledger_key(&contract_id)?;
let entry = self
.try_borrow_storage_mut()?
.try_get(&instance_key, self, None)?;
let Some(entry) = entry else {
return Ok(None);
};
let instance = self.extract_contract_instance_from_ledger_entry(&entry)?;
let instance_storage = InstanceStorageMap::from_instance_xdr(&instance, self)?;
instance_storage
.map
.get(&k, self)
.map(|value| value.copied())
}
Comment thread soroban-env-host/src/host.rs Outdated
Comment on lines +2319 to +2327
self.try_get_external_contract_data_value(contract, k, t)?
.ok_or_else(|| {
self.err(
ScErrorType::Storage,
ScErrorCode::MissingValue,
"key is missing from external contract storage",
&[contract.to_val(), k],
)
})
Comment on lines +20 to +59
pub(crate) fn try_get_contract_data_value(
&self,
k: Val,
t: StorageType,
) -> Result<Option<Val>, HostError> {
if self.has_contract_data(k, t)?.into() {
Ok(Some(self.get_contract_data(k, t)?))
} else {
Ok(None)
match t {
StorageType::Temporary | StorageType::Persistent => {
let key = self.storage_key_from_val(k, t.try_into()?)?;
let entry = self
.try_borrow_storage_mut()?
.try_get(&key, self, Some(k))?;
entry
.map(|entry| self.contract_data_entry_value(&entry.data))
.transpose()
}
StorageType::Instance => {
self.with_instance_storage(|s| s.map.get(&k, self).map(|v| v.copied()))
}
}
}

pub(crate) fn try_get_external_contract_data_value(
&self,
contract: AddressObject,
k: Val,
t: StorageType,
) -> Result<Option<Val>, HostError> {
match t {
StorageType::Temporary | StorageType::Persistent => {
let key = self.storage_key_from_address_val(contract, k, t.try_into()?)?;
let entry = self
.try_borrow_storage_mut()?
.try_get(&key, self, Some(k))?;
entry
.map(|entry| self.contract_data_entry_value(&entry.data))
.transpose()
}
StorageType::Instance => self.try_get_external_instance_data_value(contract, k),
}
}
Comment on lines +525 to +533
let missing_key = Symbol::try_from_small_str("missing").unwrap().to_val();
assert_eq!(
bool::from(host.has_external_contract_data(target_addr, missing_key, storage_type)?),
false
);
assert!(HostError::result_matches_err(
host.get_external_contract_data(target_addr, missing_key, storage_type),
(ScErrorType::Storage, ScErrorCode::MissingValue)
));
Comment on lines +13 to +18
pub fn get_persistent(env: Env, contract: Address, key: Symbol) -> u64 {
env.storage()
.persistent()
.get_external(&contract, &key)
.unwrap()
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01341fe32b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +68 to +70
let entry = self
.try_borrow_storage_mut()?
.try_get(&instance_key, self, None)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read active instance storage for external instance reads

When has/get_external_contract_data is used with StorageType::Instance for a contract that is currently on the call stack (including the current contract's own address), this reads the instance ledger entry instead of the frame's in-memory Context.storage. Instance storage writes are only persisted on frame exit, so a contract that does put_instance(k, v) and then reads get_external(self, k) in the same invocation will see the old ledger value or None rather than the just-written value. The external instance path should consult the active frame's instance storage when the target contract is active.

Useful? React with 👍 / 👎.

@dmkozh

dmkozh commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Thank you for opening this PR! However, as this is a protocol change, we need a CAP and a protocol discussion first. Would you mind starting a discussion at https://github.com/orgs/stellar/discussions? You're also welcome to make the respective CAP contribution at https://github.com/stellar/stellar-protocol/tree/master/core, but it's also fine to just start with the discussion and we can work on the CAP on our side (either way, it should be rather straightforward).

Also, I must say that while the change is straightforward implementation-wise, there might be some security implications for the existing contracts, which is why the discussion is necessary, and it's also not clear if we can proceed with this approach in the first place.

@dmkozh dmkozh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given my response on the PR re CAP, I haven't looked into this beyond the function interface (which is relevant for the discussion and spec).

Comment thread soroban-env-common/env.json Outdated
},
{
"export": "k",
"name": "try_get_external_contract_data",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interface is rather awkward and inconsistent with the existing storage functions. I think we should either:

  • get rid of this function - has and get have us covered on the SDK-side for the try_ function implementation
  • if there is a good evidence that dedicated try_ function saves us a lot of instructions vs has + get, then we should return an 'option' value from the function instead of writing the output to linear memory. But I really think just dropping this is easier and not significantly more expensive.

@mihaieremia

Copy link
Copy Markdown
Author

Thank you for opening this PR! However, as this is a protocol change, we need a CAP and a protocol discussion first. Would you mind starting a discussion at https://github.com/orgs/stellar/discussions? You're also welcome to make the respective CAP contribution at https://github.com/stellar/stellar-protocol/tree/master/core, but it's also fine to just start with the discussion and we can work on the CAP on our side (either way, it should be rather straightforward).

Also, I must say that while the change is straightforward implementation-wise, there might be some security implications for the existing contracts, which is why the discussion is necessary, and it's also not clear if we can proceed with this approach in the first place.

Hey,

I will start a discussion right away 👍

I drafted this PR quickly to get some feedback since I hit different issues building our lending protocol which went a bit over the limits and made my read more of how it actually works behind 🙏

@dmkozh

dmkozh commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

I drafted this PR quickly to get some feedback since I hit different issues building our lending protocol which went a bit over the limits and made my read more of how it actually works behind

BTW on this note, I'm not sure I trust the numbers you've shared in the PR - what was the methodology for collecting these? I would recommend testing your contract setup on the testnet or a local network and lookup the view call numbers that are coming from the simulation (e.g. compare a no-op function call with a function call that does a cross-contract call to a different function). The reason for why I'm skeptical about this is that 80M instructions seems like too much for a VM instantiation, even if your contract is large. I could be wrong, but it wouldn't hurt to double-check - the issue might not be as bad as tests present.

@mihaieremia

Copy link
Copy Markdown
Author

I drafted this PR quickly to get some feedback since I hit different issues building our lending protocol which went a bit over the limits and made my read more of how it actually works behind

BTW on this note, I'm not sure I trust the numbers you've shared in the PR - what was the methodology for collecting these? I would recommend testing your contract setup on the testnet or a local network and lookup the view call numbers that are coming from the simulation (e.g. compare a no-op function call with a function call that does a cross-contract call to a different function). The reason for why I'm skeptical about this is that 80M instructions seems like too much for a VM instantiation, even if your contract is large. I could be wrong, but it wouldn't hurt to double-check - the issue might not be as bad as tests present.

You were right to be skeptical of the original numbers. The 80-85M figure was not a single simulation result; it was an aggregate across 100 iterations, and it measured the old cross-contract getter path rather than the new external read path.

I reran the comparison using local P28 simulation/preflight with the local env + SDK + RPC changes.

Default Rust/Wasm stack:

Case Instructions Memory bytes
caller no-op 454,842 1,157,304
persistent: external read 483,703 1,165,474
persistent: cross-contract getter 950,847 2,380,138
temporary: external read 483,703 1,165,474
temporary: cross-contract getter 950,843 2,380,138
instance: external read 586,622 1,227,910
instance: cross-contract getter 929,322 2,373,328

So external reads are roughly 2x cheaper than cross-contract getters for persistent/temporary storage, and still cheaper for instance storage.

While looking into the memory numbers, I found that recent contracts built with the default Rust/Wasm settings seem to reserve about 1MiB of Wasm linear memory per contract invocation. That explains why cross-contract calls can become expensive in memory: each invoked contract brings its own baseline reservation.

After digging into the compile flags, I rebuilt my own contracts with:

RUSTFLAGS='-C link-arg=-zstack-size=16384'

That reduced the baseline memory a lot. Cross-contract calls between contracts deployed with this smaller stack are much cheaper in metered memory. But if my contract calls an already deployed oracle contract that was built with the default stack, the call still pays that contract’s larger memory reservation.

With RUSTFLAGS='-C link-arg=-zstack-size=16384':

Case Instructions Memory bytes
caller no-op 332,098 174,288
persistent: external read 360,959 182,458
persistent: cross-contract getter 705,002 413,840
temporary: external read 360,959 182,458
temporary: cross-contract getter 704,998 413,840
instance: external read 463,878 244,894
instance: cross-contract getter 683,477 407,030

This matters for my lending deployment because instructions usually fit under the 400M limit, but many oracle price reads pushed memory toward the limit.

So I have two questions:

  1. Does this simulation/preflight methodology look more accurate ?
  2. Is the 1MiB default stack reservation expected/recommended for Soroban contracts, or should contracts generally use a smaller zstack-size?

@dmkozh

dmkozh commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

So external reads are roughly 2x cheaper than cross-contract getters for persistent/temporary storage, and still cheaper for instance storage.

I think 2x is not exactly the right number to look at here; in the end we're talking about a few hundred thousand instructions per call. I realize it's not nothing, but it's also not that high either compared to the limit.

Does this simulation/preflight methodology look more accurate ?

Well, I haven't seen your exact setup/code, so I can't tell if it's fully accurate, but the numbers seem to be in the right ballpark, so this is probably good enough.

Is the 1MiB default stack reservation expected/recommended for Soroban contracts, or should contracts generally use a smaller zstack-size?

I don't think it's expected, but I need to double-check with the team. 1 MB seems excessive to me. 16K may or may not be sufficient. Default probably should be somewhere in-between, but closer to 16K - I wouldn't expect contracts to do many nested function calls and use many locals. Basically this requires some additional research on our side. But as long your contract works fine it definitely makes sense to reduce the stack size (if you can still upgrade it, that is).

@mihaieremia

Copy link
Copy Markdown
Author

So external reads are roughly 2x cheaper than cross-contract getters for persistent/temporary storage, and still cheaper for instance storage.

I think 2x is not exactly the right number to look at here; in the end we're talking about a few hundred thousand instructions per call. I realize it's not nothing, but it's also not that high either compared to the limit.

Does this simulation/preflight methodology look more accurate ?

Well, I haven't seen your exact setup/code, so I can't tell if it's fully accurate, but the numbers seem to be in the right ballpark, so this is probably good enough.

Is the 1MiB default stack reservation expected/recommended for Soroban contracts, or should contracts generally use a smaller zstack-size?

I don't think it's expected, but I need to double-check with the team. 1 MB seems excessive to me. 16K may or may not be sufficient. Default probably should be somewhere in-between, but closer to 16K - I wouldn't expect contracts to do many nested function calls and use many locals. Basically this requires some additional research on our side. But as long your contract works fine it definitely makes sense to reduce the stack size (if you can still upgrade it, that is).

We’ll most likely deploy with the 16 KB Wasm page-size flag if final tests keep confirming it’s safe.

This should help our lending protocol, but also anything built on top of it, like vaults or more complex position managers.

What I’d like to check is whether teams like Reflector or RedStone could also safely build with this flag. If yes, it could help us, Blend, and other DeFi protocols support more positions per user and more complex logic.

For reference, Rust’s Wasm target already exposes these kinds of low-level linker settings here:
https://github.com/rust-lang/rust/blob/main/compiler/rustc_target/src/spec/base/wasm.rs#L14

Not saying everyone should blindly use it, but it’s probably worth testing/reviewing at ecosystem level.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants