Publicación
Comparte tu conocimiento.
How can I design upgradable modules (logic upgrade)?
How can I design upgradable modules (logic upgrade) while preserving on-chain invariants and preventing honest-state corruption?
- Move CLI
- Move
- Smart Contract
- Move Script
Respuestas
2By upgrading the change code but must preserve storage invariants (no data loss, no invariant breaks). Move modules are usually immutable after publishing; upgrades are typically implemented by proxy patterns, limited-versioned storage, or migration scripts that carefully transform on-chain state.
Strategy
- Use a proxy module that holds canonical data (resources) and delegates logic calls to an implementation module address/version.
- Store an explicit
version
andmigration_state
resource to coordinate safe migrations. - Provide
migrate_vN_to_vN+1
functions in the new implementation that the proxy calls; make migration functions idempotent and proof-checked viaspec
invariants. - Use capability checks so only a governance/admin address can upgrade or run migrations.
- Write
spec
lemmas in the new module that prove the migration preserves invariants (e.g., balances sum, non-negativity).
Working-style code (proxy + implementation + migration)
// Proxy module at 0xA::proxy_token — storage is here and persistent across upgrades.
module 0xA::proxy_token {
use std::signer;
use std::vector;
use std::option;
// Admin who can upgrade / trigger migrations
struct Admin has key { addr: address }
// Persistent storage
struct TokenStore has key {
balances: vector<(address, u128)>, // simple illustrative mapping
total_supply: u128,
}
// Which implementation to delegate to (module id + version marker)
struct ImplRef has key {
impl_addr: address, // address of implementation module
version: u64,
}
public fun init(admin: &signer, impl_addr: address) {
move_to(admin, Admin { addr: signer::address_of(admin) });
move_to(admin, TokenStore { balances: vector::empty(), total_supply: 0u128 });
move_to(admin, ImplRef { impl_addr, version: 1u64 });
}
/////////////////////////////////////
// Delegation entrypoints (very thin)
/////////////////////////////////////
public fun mint(admin: &signer, to: address, amt: u128) acquires ImplRef, TokenStore {
// authorization
let impl = borrow_global<ImplRef>(signer::address_of(admin));
// delegate: call implementation's mint function by using a known interface.
// In Move you call by module path; proxy knows the impl address.
// Example: call 0xB::impl_v1::mint_impl(...) — but proxy must import the impl module at compile time.
// To support multiple impls you'd switch on impl.impl_addr and call the correct function.
// Here we show the pattern for a single friend impl at 0xB.
0xB::impl_v1::mint_impl(signer::address_of(admin), to, amt);
}
public fun set_impl(admin: &signer, new_impl_addr: address, new_version: u64) acquires ImplRef, Admin {
let a = borrow_global<Admin>(signer::address_of(admin));
assert!(a.addr == signer::address_of(admin), 1);
let r = borrow_global_mut<ImplRef>(signer::address_of(admin));
r.impl_addr = new_impl_addr;
r.version = new_version;
}
/////////////////////////////////////
// Storage access helpers (implementation calls these)
/////////////////////////////////////
public friend 0xB::impl_v1;
public fun get_store(addr: address): &mut TokenStore acquires TokenStore {
borrow_global_mut<TokenStore>(addr)
}
}
// Implementation v1 (0xB::impl_v1) — contains logic and migration entrypoints
module 0xB::impl_v1 {
use std::signer;
use std::vector;
// Mint implementation will directly update storage owned by proxy (friend).
public fun mint_impl(proxy_admin: address, to: address, amt: u128) {
// Here we assume proxy exposes friend accessors and the storage is stored under admin address
let store = 0xA::proxy_token::get_store(proxy_admin);
// very naive balance update for demo; use map in prod.
vector::push_back(&mut store.balances, (to, amt));
store.total_supply = store.total_supply + amt;
}
////////////////////////////
// Migration entrypoint for v1 -> v2
////////////////////////////
// Suppose v2 changes representation: we need an idempotent migration function.
public fun migrate_v1_to_v2(proxy_admin: address) {
// 1) Read old store
let store = 0xA::proxy_token::get_store(proxy_admin);
// 2) Create any auxiliary storage needed by v2, copy state carefully.
// For safety: make the migration idempotent by checking a migration marker in proxy storage
// (not shown) or by designing migration to be a no-op if already applied.
// Example check (pseudo):
// if (!migrated) { perform migration; mark migrated; }
}
}
How this preserves invariants
- Storage never moves addresses — data stays under
proxy_token::TokenStore
so external references to that resource remain valid after upgrade. - New implementation provides
migrate_*
functions that are explicit and idempotent; the proxy admin triggers them and can verify results. spec
blocks inimpl_v2
should asserttotal_supply
equals sum(balances) before and after migration — this is how the Move Prover can be used to check migration correctness.
Practical recommendations
- Keep storage layout stable where possible. If layout must change, migrate piecewise and leave a rollback or checkpoint.
- Use small, well-tested migration scripts and unit tests (#[test]) that run
init
→ prepopulate →migrate
→ assert invariants. - Limit upgrade privileges to a multisig/governance contract to reduce centralized risk.
Instead of a single proxy that delegates all logic, keep versioned storage containers and deploy new implementation modules side-by-side. Each implementation reads from the canonical storage container but only if a migration gate (a small VersionGate
resource) has advanced to permit that code-path. Migrations are performed by a controlled migrator that copies transforms from Vn
storage to Vn+1
storage while leaving Vn
intact until the migration completes and is audited. This gives:
- Safer rollbacks (you can keep old storage while testing new storage).
- Explicit migration checkpoints and auditability.
- Minimized logic in proxy; implementations operate on stable, versioned data shapes.
How it defends invariants
- Storage containers are typed per-version (
StoreV1
,StoreV2
) — code never reads a layout it wasn't compiled against. - Migration functions are idempotent and gated by a
MigrationMarker
that records migration progress and can’t be advanced unless the migrator holds a capability. - The Move Prover can reason about each storage version independently;
spec
lemmas can assert thatsum(balances_v1) == sum(balances_v2)
after migration.
Working-style Move sketch
module 0xA::versioned_token {
use std::signer;
use std::vector;
/// Admin who controls upgrades
struct Admin has key { addr: address }
/// Migration capability; linear resource only Admin has
struct MigratorCap has key {}
/// Version gate: which version is active. Stored under admin addr.
struct VersionGate has key { active: u64, in_migration: bool }
/// Storage for v1 layout
struct StoreV1 has key {
balances: vector<(address, u128)>,
total: u128
}
/// Storage for v2 layout (example: switch to per-account meta)
struct StoreV2 has key {
// different layout: per-account records hashed in vectors for demo
balances_v2: vector<(address, u128)>,
total_v2: u128
}
public fun init(admin: &signer) {
move_to(admin, Admin { addr: signer::address_of(admin) });
move_to(admin, MigratorCap {});
move_to(admin, VersionGate { active: 1u64, in_migration: false });
move_to(admin, StoreV1 { balances: vector::empty(), total: 0u128 });
// v2 storage not created until migration starts
}
////////////////////////////////////////////////////////////
// Implementations call these helpers; behavior chosen by version gate
////////////////////////////////////////////////////////////
public fun balance_of(admin_addr: address, owner: address): u128 acquires VersionGate, StoreV1, StoreV2 {
let gate = borrow_global<VersionGate>(admin_addr);
if (gate.active == 1u64) {
let s = borrow_global<StoreV1>(admin_addr);
// naive lookup for demo
let i = 0;
while (i < vector::length(&s.balances)) {
let pair = *vector::borrow(&s.balances, i);
if (pair.0 == owner) { return pair.1; }
i = i + 1;
}
0u128
} else {
let s2 = borrow_global<StoreV2>(admin_addr);
// different lookup logic
let i = 0;
while (i < vector::length(&s2.balances_v2)) {
let pair = *vector::borrow(&s2.balances_v2, i);
if (pair.0 == owner) { return pair.1; }
i = i + 1;
}
0u128
}
}
////////////////////////////////////////////////////////////
// Migration flow: admin triggers `start_migration`, migrator executes idempotent steps,
// then `finalize_migration` flips gate to new version
////////////////////////////////////////////////////////////
public fun start_migration(admin: &signer) acquires VersionGate, MigratorCap {
let g = borrow_global_mut<VersionGate>(signer::address_of(admin));
assert!(!g.in_migration, 1);
g.in_migration = true;
// create v2 store if not exists
if (!exists<StoreV2>(signer::address_of(admin))) {
move_to(admin, StoreV2 { balances_v2: vector::empty(), total_v2: 0u128 });
}
}
// Migrator runs this repeatedly to copy batches; function is idempotent per-account.
public fun migrate_account_batch(admin: &signer, start_idx: u64, batch_size: u64) acquires StoreV1, StoreV2, VersionGate {
let g = borrow_global<VersionGate>(signer::address_of(admin));
assert!(g.in_migration, 2);
let s1 = borrow_global_mut<StoreV1>(signer::address_of(admin));
let s2 = borrow_global_mut<StoreV2>(signer::address_of(admin));
let len = vector::length(&s1.balances) as u64;
let mut i = start_idx;
while (i < start_idx + batch_size && i < len) {
let pair = *vector::borrow(&s1.balances, (i as u64));
// Check if already migrated by searching s2; if present, skip (idempotent)
if (!exists_in_vec(&s2.balances_v2, pair.0)) {
vector::push_back(&mut s2.balances_v2, pair);
s2.total_v2 = s2.total_v2 + pair.1;
}
i = i + 1;
}
}
public fun finalize_migration(admin: &signer, new_version: u64) acquires VersionGate {
let g = borrow_global_mut<VersionGate>(signer::address_of(admin));
assert!(g.in_migration, 3);
g.active = new_version;
g.in_migration = false;
}
fun exists_in_vec(v: &vector<(address, u128)>, a: address): bool {
let i = 0;
while (i < vector::length(v)) {
if ((*vector::borrow(v, i)).0 == a) { return true; }
i = i + 1;
}
false
}
}
Trade-offs & tips
- Pros: safe, auditable migrations; easy rollback since old storage remains untouched until finalize.
- Cons: requires extra storage and migration work (batching), but acceptable for large systems.
- Use
spec
to prove migration preservestotal
across versions. - On Sui, replace account storage with objects; you can keep versioned object types and migrate object-by-object.
Sabes la respuesta?
Inicie sesión y compártalo.
Move is an executable bytecode language used to implement custom transactions and smart contracts.
Gana tu parte de 1000 Sui
Gana puntos de reputación y obtén recompensas por ayudar a crecer a la comunidad de Sui.

- ... SUIMatthardy+2095
- ... SUIacher+1666
- ... SUIjakodelarin+1092
- ... SUIChubbycheeks +1081
- ... SUITucker+1047
- ... SUIKurosakisui+1034
- ... SUIzerus+890