Skip to content

Add reserved native contract alias resolving to the native asset#2646

Merged
fnando merged 6 commits into
mainfrom
native-alias
Jul 15, 2026
Merged

Add reserved native contract alias resolving to the native asset#2646
fnando merged 6 commits into
mainfrom
native-alias

Conversation

@fnando

@fnando fnando commented Jul 14, 2026

Copy link
Copy Markdown
Member

What

Adds native as a built-in contract alias that resolves to the native asset (XLM) Stellar Asset Contract for the current network. native is reserved: it cannot be created, overwritten, or removed like a normal alias, and it resolves everywhere a contract alias/address is accepted (contract invoke --id native, address arguments, alias show, etc.). contract alias ls always lists it as (built-in), and a pre-existing user native alias is shown as (disabled) since the built-in shadows it (it can still be removed to clean up).

Why

Today you can already derive the native asset's Stellar Asset Contract address with:

$ stellar contract id asset --asset native
CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4

But that value isn't usable as a native name anywhere else — users still have to copy the address around or register an alias per network. This PR essentially closes that gap in the other commands by making native a first-class alias that resolves consistently wherever a contract alias/address is accepted, giving a stable, network-aware name. Reserving the name prevents a user alias from silently shadowing the native asset, which would be a confusing footgun. Reservation is enforced before any build/simulation/on-chain work, so deploy --alias=native fails fast rather than after deploying.

Known limitations

contract alias ls now lists every known network (defaults + saved), each showing the built-in native, whereas previously it only listed networks that had stored aliases. The FULL_HELP_DOCS.md diff includes unrelated regeneration drift for the stellar xdr command.

Examples

$ stellar contract alias show native
ℹ️ Contract alias 'native' references CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC on network 'Test SDF Network ; September 2015'
CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC

$ stellar contract id asset --asset native
CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
$ stellar contract alias ls
ℹ️ Aliases available for network 'Test SDF Future Network ; October 2022'
native: CB64D3G7SM2RTH6JSGG34DDTFTQ5CFDKVDZJZSODMCX4NJ2HV2KN7OHT (built-in)

ℹ️ Aliases available for network 'Public Global Stellar Network ; September 2015'
native: CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA (built-in)

ℹ️ Aliases available for network 'Test SDF Network ; September 2015'
foo: CDF7U4HZVRRONHY7IADHAQPMH2LX2CJHEA7S3IGAPETIJ7DXP7IVIFYF
native: CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE (disabled)
native: CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC (built-in)

ℹ️ Aliases available for network 'Standalone Network ; February 2017'
native: CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4 (built-in)
$ stellar contract alias add native --id=CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE
❌ error: contract alias 'native' is reserved and cannot be added, overwritten, or removed
$ stellar contract deploy --alias=native --wasm=contract.wasm --source-account=G...
❌ error: contract alias 'native' is reserved and cannot be added, overwritten, or removed
$ stellar contract alias remove native
ℹ️ Contract alias 'native' references CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE on network 'Test SDF Network ; September 2015'
✅ Contract alias 'native' has been removed

When no stored native file exists, removal errors with no contract found with alias 'native'.

Copilot AI review requested due to automatic review settings July 14, 2026 16:54
@github-project-automation github-project-automation Bot moved this to Backlog (Not Ready) in DevX Jul 14, 2026
@fnando fnando self-assigned this Jul 14, 2026
@fnando fnando moved this from Backlog (Not Ready) to Needs Review in DevX Jul 14, 2026

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

This PR introduces a reserved built-in contract alias, native, which always resolves to the network-specific native asset (XLM) Stellar Asset Contract and cannot be created/overwritten/removed like a normal user alias. It wires this behavior through config resolution, deploy flows, alias commands, and adds unit + integration tests to validate the new semantics.

Changes:

  • Add a reserved-alias mechanism (native) with fast-fail validation for commands that would write or deploy under a reserved alias.
  • Make alias resolution treat native as a built-in mapping to the native asset contract for the active network; keep removal able to delete a pre-existing shadowing native.json.
  • Update contract alias ls/show behavior and add unit + soroban-test integration coverage; regenerate help docs (includes stellar xdr drift).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
FULL_HELP_DOCS.md Regenerated help output; reflects updated stellar xdr usage text.
cmd/soroban-cli/src/config/locator.rs Enforces reserved aliases on save; resolves native to the native asset contract; adds unit tests.
cmd/soroban-cli/src/config/alias.rs Defines reserved-alias registry and validation helpers; adds unit tests.
cmd/soroban-cli/src/commands/contract/deploy/wasm.rs Validates --alias early so reserved aliases fail fast.
cmd/soroban-cli/src/commands/contract/deploy/asset.rs Validates --alias early so reserved aliases fail fast.
cmd/soroban-cli/src/commands/contract/arg_parsing.rs Adds test ensuring JSON address arguments resolve native correctly.
cmd/soroban-cli/src/commands/contract/alias/remove.rs Uses stored-only lookup so removing a shadowed reserved alias reports the removed value.
cmd/soroban-cli/src/commands/contract/alias/ls.rs Always lists built-in native per known network; marks shadowed user native as disabled.
cmd/soroban-cli/src/commands/contract/alias/add.rs Prevents adding/overwriting reserved aliases.
cmd/crates/soroban-test/tests/it/config.rs Adds integration tests covering native resolution, reservation enforcement, and listing behavior.

Comment thread cmd/soroban-cli/src/commands/contract/alias/ls.rs
Comment thread cmd/soroban-cli/src/commands/contract/alias/ls.rs
Comment thread cmd/soroban-cli/src/config/locator.rs Outdated
@aristidesstaffieri

Copy link
Copy Markdown
Collaborator

Keys/identities named native are silently shadowed in Address argument resolution — can misdirect funds

The new short-circuit in get_contract_id makes native always resolve as a contract, but the reservation is only enforced for contract aliases — not for key names. Those two facts combine badly for Address-typed contract arguments:

  1. UnresolvedScAddress::resolve (config/sc_address.rs) looks a bare name up as both a contract alias and a key. On ambiguity it warns to stderr and prefers the contract.
  2. Before this change, resolve_alias("native") failed with ContractNotFound (no stored alias file), so a key named native resolved to the key's G-address. After this change, the contract lookup always succeeds, so the same command now resolves to the XLM SAC's C-address.
  3. A key named native remains creatable on this branch: keys generatewrite_identity (locator.rs:195) only checks stored alias files via load_contract_from_alias, which the built-in alias doesn't have, and keys addwrite_key (locator.rs:210) has no check at all.

Repro / impact:

stellar keys generate native   # still succeeds on this branch
stellar contract invoke --id CMYTOKEN... -- \
  transfer --from admin --to native --amount 1000000000

Before: tokens go to the native identity's account. After: tokens go to the XLM SAC contract address — the transfer succeeds on-chain and the tokens are effectively stranded, since the SAC has no path for spending token balances it holds. The only signal is a stderr warning ("alias is ambiguous, assuming it is a contract"), and the exit code is 0 — invisible in scripts. Same failure mode for any Address arg: --to native on an XLM transfer donates to the SAC itself; set_admin --new_admin native bricks the admin.

This is a silent behavior change for pre-existing setups: an identity named native was legal and unambiguous before this PR, and the exact commands that worked yesterday now send funds elsewhere.

Suggested fix (two parts):

  1. Close the front door: call alias::validate_reserved_aliases(name) in write_identity and write_key, alongside the existing KeyCannotOverlapWithContractAlias check, so native can't be created as a key name.
  2. Protect existing keys: in UnresolvedScAddress::resolve, hard-error on the ambiguous (Ok(contract), Ok(key)) arm instead of warn-and-prefer-contract — ask the user for an explicit C.../G... address. Preferring either side silently sends someone's funds to the wrong place.

@aristidesstaffieri

Copy link
Copy Markdown
Collaborator

Pre-existing stored native aliases get silently re-pointed to the XLM SAC

stellar contract alias add native --id C... was allowed before this PR (AliasName only checks the naming pattern), so stored contract-ids/native.json files can exist. This PR reserves the name going forward but doesn't do anything about existing files. The short-circuit in get_contract_id returns the SAC id before reading the stored file:

// locator.rs
if alias == "native" {
    return Ok(Some(crate::utils::contract_id_hash_from_asset(
        &xdr::Asset::Native, network_passphrase,
    )));
}

Every command that resolves a contract alias goes through this — invoke, fetch, events, snapshot create, contract info, ledger entry fetch, and Address-typed args. So after an upgrade, all of them target the SAC instead of the stored contract, with no warning at resolution time. The shadowing is only visible in alias ls (the (disabled) tag), and alias show native returns the SAC id.

The SAC implements the SEP-41 token interface, so if the stored alias pointed at a token contract, calls against the wrong contract can still succeed:

# before upgrade: transfers 500 units of the aliased token
# after upgrade: transfers 50 XLM, exit 0
stellar contract invoke --id native --source treasurer -- \
  transfer --from treasurer --to GPLAYER... --amount 500_0000000

balance reads have the same issue (a script gets an XLM balance where it expects a token balance), and events --id native starts filtering on the SAC id, so events from the user's contract stop arriving.

Suggested change: the built-in-wins precedence looks intentional (per the code comment and the (disabled) marker in ls), so I'm not suggesting changing that — just detecting the collision instead of ignoring it. get_stored_contract_id already exists on this branch, so the short-circuit can check for a stored mapping and return an error when one is present:

if alias == "native" {
    let native = crate::utils::contract_id_hash_from_asset(&xdr::Asset::Native, network_passphrase);
    if let Some(stored) = self.get_stored_contract_id(alias, network_passphrase)? {
        if stored != native {
            return Err(Error::ShadowedReservedAlias {
                alias: alias.to_owned(),
                stored,
            });
        }
    }
    return Ok(Some(native));
}

with a message that includes the fix:

alias 'native' is reserved for the native asset contract, but a stored alias with that name points to CGAME... — remove it with stellar contract alias remove native, or invoke the contract by id

alias remove native already deletes the stored file on this branch, so recovery is one command. Users without a stored native alias see no behavior change beyond one extra file read on the native path. A warning instead of an error would also surface the problem, but since the failure mode is submitting a signed transaction to the wrong contract, an error until the collision is resolved seems more appropriate.

@aristidesstaffieri

Copy link
Copy Markdown
Collaborator

Workspace deploys can abort mid-loop on a reserved package name, leaving a partial deployment

The fail-fast check in run() only validates the explicit --alias. In multi-contract workspace mode --alias is rejected anyway (AliasNotSupported), so that check never applies in the one scenario where package-derived aliases exist. The derived alias is assigned per-iteration inside the deploy loop and validated in run_single — after every earlier iteration has already uploaded wasm, created the contract, paid fees, and saved its alias.

With workspace packages adapter, native, token:

stellar contract deploy --source deployer --network mainnet
# adapter: uploaded + deployed + alias saved (2 txs, fees paid)
# native:  error: contract alias 'native' is reserved and cannot be added, overwritten, or removed
# token:   never reached

The result is a partial deployment: some contracts live on-chain, some not, fees spent, nonzero exit. The error is also confusing on two counts — the user never typed an alias, and the message describes alias management ("added, overwritten, or removed"), not a deploy. Retrying compounds the problem: with no explicit salt, a rerun creates a duplicate adapter contract and re-points the saved alias to it, orphaning the first instance.

There's also no escape hatch for the workspace case: --alias is banned with multiple contracts, so a workspace containing a crate named native can't be deployed by auto-build at all. The only workarounds are renaming the crate or deploying one package at a time with --package native --alias <other>.

Partial deploys can happen for other reasons too (network failures, underfunded accounts), but this failure is knowable before the first transaction — which is what the comments on both new checks say the intent is ("fails fast instead of after wasted work"). The check just needs to run before the loop rather than inside it.

@aristidesstaffieri

Copy link
Copy Markdown
Collaborator

alias remove native returns a contradictory error

show and remove answer differently for the same name. alias show native resolves through get_contract_id, hits the built-in short-circuit, and reports that the alias references the SAC. alias ls lists native: C... (built-in) on every network. But alias remove native looks up get_stored_contract_id, and when there's no stored file — the normal case, since alias add native is now blocked — it errors with:

error: no contract found with alias 'native' for network '...'

So ls and show say the alias exists, and remove says it doesn't. The message that describes what's actually going on already exists — ContractAliasReserved: "contract alias 'native' is reserved and cannot be added, overwritten, or removed" — but it's only used by add and the deploy paths, never here.

To be clear, using get_stored_contract_id in remove is right (per the comment there): it's what lets remove delete a pre-existing shadowed native file, which is the recovery path for the stored-alias shadowing issue above. The problem is only the None branch. Suggested change in remove.rs:

else {
    if crate::config::alias::is_reserved(&self.alias) {
        return Err(locator::Error::ContractAliasReserved(self.alias.to_string()).into());
    }
    return Err(Error::NoContract { ... });
};

The reserved error flows through the existing #[error(transparent)] Locator variant, so no new error plumbing is needed, and "cannot be removed" is exactly the truthful answer. This also covers someone who removes a shadowed file and runs the command a second time.

@aristidesstaffieri

Copy link
Copy Markdown
Collaborator

Cleanup: reserved-alias knowledge is spread across three modules, and the validation block is copy-pasted three times

Two related maintainability points, no behavior change implied by either.

1. The native alias name and its resolution are hardcoded in three disconnected places.

  • config/alias.rsRESERVED_ALIASES: &[&str] = &["native"] plus is_reserved() / validate_reserved_aliases()
  • config/locator.rs (get_contract_id) — if alias == "native" { contract_id_hash_from_asset(&Asset::Native, ...) }
  • alias/ls.rs — pushes an AliasEntry with a literal "native" and its own direct contract_id_hash_from_asset(&Asset::Native, ...) call

The locator compares against the literal string rather than consulting RESERVED_ALIASES, and ls re-implements the resolution instead of reusing it. RESERVED_ALIASES is plural and invites additions — a second reserved alias added to the array would validate in add/deploy but resolve nowhere and not appear in ls unless someone remembers all three sites. Similarly, if the resolution ever changes, ls and show/invoke could disagree about what native points to.

One shape for consolidating: give alias.rs a single source of truth, e.g. resolve_reserved(alias, network_passphrase) -> Option<Contract>, have get_contract_id call it instead of matching the literal, and have ls iterate RESERVED_ALIASES through the same function for its built-in rows. (Minor drive-by in ls.rs while there: format!("{}", contract_id_hash_from_asset(...)) is just .to_string().)

2. The same 3-line validation block appears three times.

if let Some(alias) = &self.alias {
    crate::config::alias::validate_reserved_aliases(alias)?;
}

— in deploy/wasm.rs run(), deploy/wasm.rs run_single(), and deploy/asset.rs run(). The run()-level copy in wasm.rs is redundant for the --wasm/--wasm-hash paths, since every path reaches run_single, which performs the identical check before any on-chain work; the two copies double-validate the same value.

This ties into the partial-deployment issue above: fixing that means moving/changing where derived aliases are validated anyway, so it's a good moment to end up with one check at the point where the alias is finalized, rather than three copies that have to stay in sync (plus the fourth in save_contract_id, which is fine as a last-line guard).

@fnando

fnando commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Keys/identities named `native` are silently shadowed in `Address` argument resolution

Fixed in `36024e79`. `write_identity`/`write_key` now call `validate_reserved_aliases`, so `keys generate native` / `keys add native` fail fast. For keys already on disk, `UnresolvedScAddress::resolve` hard-errors on the reserved-alias + key collision (`ReservedAliasShadowsKey`) and asks for an explicit `C…`/`G…`, instead of warn-and-prefer-contract. Scoped to reserved names, so unrelated ambiguous aliases keep their current behavior.

@fnando

fnando commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Pre-existing stored `native` aliases get silently re-pointed to the XLM SAC

Fixed in `36024e79`, as suggested. `get_contract_id` now returns `ShadowedReservedAlias` when a stored file points somewhere other than the SAC, with a message pointing at `contract alias remove native`. `remove` still deletes the stored file, so recovery stays one command.

@fnando

fnando commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Workspace deploys can abort mid-loop on a reserved package name, leaving a partial deployment

Fixed in `33dcdd82`. Package-derived aliases are now validated before the deploy loop via `reserved_package_alias`, so a workspace containing a `native` crate fails before any contract is uploaded. Added a `ReservedPackageAlias` error with a deploy-appropriate message that names the package and the `--package native --alias ` escape hatch.

@fnando

fnando commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

`alias remove native` returns a contradictory error

Fixed in `4ae28109`. The empty branch now returns `ContractAliasReserved` for reserved names, so `remove` agrees with `ls`/`show` instead of reporting "no contract found". The `Some` branch is unchanged, so removing a real shadowed file still works.

@fnando

fnando commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Cleanup: reserved-alias knowledge is spread across three modules, and the validation block is copy-pasted three times

Done both in `0cb97c58`. Added `alias::resolve_reserved()` as the single source of truth — `get_contract_id` and `alias ls` both go through it now, no more literal `"native"` or re-implemented resolution. Collapsed `RESERVED_ALIASES` to a single `RESERVED_ALIAS` const since there is only one. Removed the redundant `run()`-level check in `wasm.rs` so validation lives at the finalized-alias point (`run_single`) plus the pre-loop derived-alias guard.

One note: I kept `format!("{contract}")` in `ls.rs` rather than `.to_string()` — `Contract` has an inherent `to_string()` returning a `heapless::String`, which does not compile against the `String` field.

@aristidesstaffieri

Copy link
Copy Markdown
Collaborator

Fixed in 36024e79, as suggested. get_contract_id now returns ShadowedReservedAlias when a stored file points somewhere other than the SAC, with a message pointing at contract alias remove native. remove still deletes the stored file, so recovery stays one command.

Confirmed the fix works for the stored-alias case on its own — but one edge case slipped through in 36024e79: the new ShadowedReservedAlias arm in sc_address.rs is unreachable when a key exists. In UnresolvedScAddress::resolve, the new arm is placed after the key arm:

(_, Ok(key)) => Ok(xdr::ScAddress::Account(...)),                          // matches first
(Err(err @ locator::Error::ShadowedReservedAlias { .. }), _) => Err(err.into()),

A user with both pre-upgrade artifacts — a stored native alias file and a key named native — gets contract = Err(ShadowedReservedAlias) and key = Ok, so the key arm matches first and native silently resolves to the key's G-address. That bypasses both new protections at once: the shadowed-alias error is swallowed, and this is exactly the ambiguous situation ReservedAliasShadowsKey was added to hard-error on (that arm only fires when the contract lookup returns Ok, i.e. no stored file).

It takes both collisions to hit, so the population is small — but it's precisely the pre-upgrade-config population these fixes target, and the resolution is silent.

Fix is to reorder the arms so the shadowed error takes precedence over the key:

(Err(err @ locator::Error::ShadowedReservedAlias { .. }), _) => Err(err.into()),
(_, Ok(key)) => Ok(xdr::ScAddress::Account(...)),

The existing test doesn't catch this because it only writes the key; a variant that also writes contract-ids/native.json (like get_contract_id_errors_when_native_alias_is_shadowed does) would cover it.

@fnando

fnando commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Great catch — you were right, that arm was unreachable when a key also existed. Fixed in `8a3ffa2e` by moving the `ShadowedReservedAlias` arm ahead of the key arm, so the collision wins instead of silently resolving to the key's G-address. Added a regression test that writes both a `native` key and a `contract-ids/native.json` (it would have failed under the old ordering).

@fnando fnando merged commit 078abaa into main Jul 15, 2026
227 checks passed
@fnando fnando deleted the native-alias branch July 15, 2026 15:23
@github-project-automation github-project-automation Bot moved this from Needs Review to Done in DevX Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants