Skip to content

fix(station): make asset ledger canister id immutable after creation - #648

Open
MRmarioruci wants to merge 9 commits into
mainfrom
fix/immutable-asset-ledger-canister-id
Open

MRmarioruci wants to merge 9 commits into
mainfrom
fix/immutable-asset-ledger-canister-id

Conversation

@MRmarioruci

@MRmarioruci MRmarioruci commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Change

An asset's ledger_canister_id identifies the token that asset represents. It is resolved from asset metadata at call time by both transfer paths and by account_balance, icrc1_balance_of, transfer_fee and icrc1_fee, so changing it on an existing asset changes which token all of those operations act on.

EditAsset now rejects an edit that repoints or removes it once set, at request creation as well as in the service. Setting one that was previously absent is still allowed, since an asset without a ledger cannot transact.

The same metadata also feeds EnsureExternalCanister::is_external_canister, which decides whether a canister may be registered and called as an external canister. Because that set is derived from current asset metadata, a ledger stopped being recognised as one once the last asset naming it went away. The ICP and cycles ledgers are now pinned alongside the management canister, the station and the upgrader, so recognising them no longer depends on registry contents.

To point an asset at a different ledger, detach it from its accounts, remove it, and create a new one. That is the correct handling regardless: balances do not follow the pointer, so an in-place repoint would leave the station reporting holdings that live on the previous ledger.

Changes

RequestOperation::validate for EditAsset now calls EnsureAsset::ledger_canister_id_preserved, so a repoint is refused before the request is created rather than after it has been approved. That helper and AssetService::edit() both call Asset::changes_ledger_canister_id, so the two paths cannot drift. The rejection surfaces as a new AssetValidationError::ImmutableField, threaded through ValidationError into RequestError and RequestPolicyError. Separately, is_external_canister gained the two pinned system ledger principals.

Tests

  • repointing the ledger canister id is rejected and the stored value is unchanged
  • removing the key is rejected
  • unrelated metadata edits still succeed
  • the ICP and cycles ledgers are never external canisters, including when no asset names them

cargo test -p station --lib passes (401). Clippy and cargo fmt clean.

Follow-ups

  • The wallet's asset form still renders the ledger canister id as editable for an existing asset. It now fails at request creation with a clear error rather than after approval, but the field should be made read-only. Separate PR, wallet only.
  • Recognising a ledger still depends on the asset registry for non-system ledgers. A durable record written at AddAsset would remove that dependency entirely. Separate PR, needs stable memory.

Note

AssetService::create takes an optional with_asset_id, which is not reachable from AddAssetOperationInput and is used only by init and migrations. Worth keeping it that way.

The ledger canister id identifies the token an asset represents. It is
resolved from mutable asset metadata at call time by both transfer paths
as well as by balance and fee lookups, so changing it on an existing
asset silently changes which token those operations act on.

Reject edits that repoint or remove the ledger canister id once it is
set. Pointing an asset at a different ledger now requires detaching it
from its accounts, removing it, and creating a new one, which is the
correct semantics anyway since balances do not follow the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MRmarioruci
MRmarioruci marked this pull request as ready for review August 6, 2026 13:10
@MRmarioruci
MRmarioruci requested a review from a team as a code owner August 6, 2026 13:10
@MRmarioruci
MRmarioruci requested a lite review from Copilot August 6, 2026 13:10
@zeropath-ai

zeropath-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 8833539.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► core/station/impl/src/core/validation.rs
    Extend validation to handle ledger ledger-related asset fields and add ledger_canister_id_preserved logic
► core/station/impl/src/errors/asset.rs
    Add AssetError variants ImmutableLedgerCanisterId and InvalidLedgerCanisterId, with DetailableError details
► core/station/impl/src/errors/validation.rs
    Introduce AssetValidationError and propagate through ValidationError
► core/station/impl/src/errors/request.rs
    Wire AssetValidationError into RequestError mapping
► core/station/impl/src/errors/request_policy.rs
    Wire AssetValidationError into RequestPolicyError mapping
► core/station/impl/src/models/asset.rs
    Add ledger_canister_id, changes_ledger_canister_id, and related validation helpers
► core/station/impl/src/models/request_operation.rs
    Validate ledger_canister_id_preserved during EditAsset operations
► core/station/impl/src/services/asset.rs
    Guard edits that would repoint/drop ledger_canister_id as immutable and add explanatory comment

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 makes an asset’s ledger_canister_id effectively immutable after the asset has been created, preventing previously-approved transfers (and balance/fee lookups) from being redirected to a different ledger by later metadata edits.

Changes:

  • In AssetService::edit(), capture the pre-edit ledger_canister_id and reject edits that remove or change it once it has been set.
  • Introduce AssetError::ImmutableLedgerCanisterId for the new rejection case.
  • Add unit tests covering repointing/removal rejection and allowing unrelated metadata edits.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
core/station/impl/src/services/asset.rs Enforces immutability of ledger_canister_id during asset edits and adds regression tests.
core/station/impl/src/errors/asset.rs Adds a dedicated error variant for attempted ledger canister id changes.

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

@MRmarioruci
MRmarioruci requested a lite review from Copilot September 18, 2026 08:32

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.

🔵 Needs a closer look

Add regression coverage for assigning a ledger ID to an asset that previously lacked one.

Review details

Suppressed comments (1)

core/station/impl/src/services/asset.rs:116

  • The before.is_none() exception is part of the contract, but the added tests only exercise assets that already have a ledger. Please add a regression test starting from mock_asset_b() that sets ledger_canister_id and verifies the edit succeeds and persists; otherwise a future refactor could make the first assignment fail without detection.
        if ledger_canister_id_before.is_some()
            && ledger_canister_id_before != ledger_canister_id_after
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Introduces AssetValidationError with an ImmutableField variant and wires it
through ValidationError, RequestError and RequestPolicyError. Adds
Asset::changes_ledger_canister_id so the request validator and the asset
service share one definition of what counts as a ledger repoint.
EditAsset only checked that the asset existed, so an edit repointing the
ledger canister id was accepted, approved, and only refused by the service at
execution. The check now runs in RequestOperation::validate as well, and the
service reuses the shared helper instead of comparing metadata before and
after applying the edit.
is_external_canister derives the ledger set from current asset metadata, so a
ledger stops being recognised once the last asset naming it is edited or
removed. Pin the ICP and cycles ledgers alongside the management canister,
the station and the upgrader so they no longer depend on registry contents.
The immutability guard deliberately allows the first assignment, since an
asset without a ledger cannot transact, but only assets that already had one
were exercised. Adds coverage on both the service and the request validator
starting from an asset with empty metadata, so a refactor cannot make the
first assignment fail unnoticed.
@MRmarioruci

Copy link
Copy Markdown
Contributor Author

Added in 36d2f4a. Good catch: the first-assignment path was contract but untested.

Covered on both paths, each starting from mock_asset_b() (empty metadata):

  • services::asset::tests::test_asset_edit_can_set_initial_ledger_canister_id sets the id, asserts the edit succeeds, and re-reads from the repository to confirm it persisted.
  • core::validation::test::test_edit_can_set_initial_ledger_canister_id covers the same case on the request validator, since that path enforces the rule independently now.

Confirmed the coverage is real by removing the before.is_none() early return locally: both tests fail, and pass again with it restored.

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.

🔵 Needs a closer look

Initial ledger canister IDs are not validated as Principals before becoming immutable.

Review details

Suppressed comments (1)

core/station/impl/src/models/asset.rs:55

  • This allows the first write to ledger_canister_id without validating that the value is a Principal. A typo such as not-a-principal is accepted by the metadata length checks, after which transfers/balance calls fail with InvalidMetadata and this new immutability rule prevents correcting or removing the value. Validate the initial ledger canister id before making it immutable (and return a validation error to the caller).
        if before.is_none() {
            return false;
        }
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

The value is only parsed when a transfer or balance read resolves it, so an
unparseable one was accepted at write time and surfaced much later as
InvalidMetadata. Since it also cannot be corrected in place once set, a typo
had to be undone by detaching the asset from its accounts and recreating it.
Asset validation now parses it, which covers both creation and the first
assignment on an existing asset.
@MRmarioruci

Copy link
Copy Markdown
Contributor Author

Added in 847a975. Confirmed the gap: Metadata::validate() only checks count and key/value lengths, and the value was previously parsed only when a transfer or balance read resolved it (internet_computer.rs:204, surfacing as InvalidMetadata).

Asset::validate() now parses it, so it is rejected at write time with AssetError::InvalidLedgerCanisterId. Putting it in the model validator covers creation and the first assignment on an existing asset in one place, since both paths validate before insert.

Tests: a principal is accepted, a non-principal is rejected, an asset without one still validates, and at the service level an unparseable first assignment is refused and nothing is persisted. 407 lib tests pass.

One scoping note: this validates the ledger canister id specifically, not metadata values generally. Typed metadata validation by key is a broader change and belongs on its own.

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.

🟡 Changes recommended

Raw textual comparison can still misclassify valid non-canonical custom ledger principals as external canisters.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

core/station/impl/src/models/request_operation.rs:1255

  • The helper tests cover the comparison logic, but no test exercises this RequestOperation::validate branch for an EditAsset repoint or removal. This dispatch could therefore be dropped while the current tests remain green, allowing requests that can only fail after approval. Add a request-operation or request-creation test that inserts an asset, attempts the edit, and asserts the immutable-field validation error.
                EnsureAsset::ledger_canister_id_preserved(
                    &op.input.asset_id,
                    &op.input.change_metadata,
                )?;
  • Files reviewed: 8/8 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread core/station/impl/src/core/validation.rs Outdated
from_text accepts spellings that to_text does not emit, so an asset holding a
ledger canister id in one of those forms compared unequal and left that
ledger treated as an external canister. Parse the stored value and compare
principals, which also covers records already written that way. Adds the
missing coverage for the EditAsset branch of RequestOperation::validate.
@MRmarioruci

Copy link
Copy Markdown
Contributor Author

Both addressed in 8833539.

Principal vs text comparison. Confirmed and fixed. Principal::from_text is case insensitive while to_text() emits lower case, so a stored RYJL3-... parses fine but compares unequal against to_text(), and that ledger would have been treated as an external canister. is_external_canister now parses the stored value and compares principals, which also covers records already written that way. Dash grouping is enforced by from_text, so case is the practical divergence. Added a test that stores a ledger id upper cased and asserts it is still not external; it fails against the previous textual comparison.

Worth noting this only affected custom ledgers. The ICP and cycles ledgers were already compared as principals.

Missing coverage on the validate branch. Fair, the dispatch could have been deleted with everything still green. Added fail_edit_asset_request_repointing_the_ledger_canister_id, which validates an EditAsset repoint through RequestOperation::validate and asserts AssetValidationError::ImmutableField. Verified it fails when the dispatch is removed.

409 lib tests pass. Clippy and fmt clean.

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