// SPDX-License-Identifier: Apache 2 /// This module implements two custom types relating to Token Bridge wrapped /// assets. These assets have been attested from foreign networks, whose /// metadata is stored in `ForeignInfo`. The Token Bridge contract is the /// only authority that can mint and burn these assets via `Supply`. /// /// See `create_wrapped` and 'token_registry' modules for more details. module token_bridge::wrapped_asset { use std::string::{String}; use sui::balance::{Self, Balance}; use sui::coin::{Self, TreasuryCap, CoinMetadata}; use sui::package::{Self, UpgradeCap}; use wormhole::external_address::{ExternalAddress}; use wormhole::state::{chain_id}; use token_bridge::string_utils; use token_bridge::asset_meta::{Self, AssetMeta}; use token_bridge::normalized_amount::{cap_decimals}; friend token_bridge::complete_transfer; friend token_bridge::create_wrapped; friend token_bridge::token_registry; friend token_bridge::transfer_tokens; /// Token chain ID matching Sui's are not allowed. const E_SUI_CHAIN: u64 = 0; /// Canonical token info does match `AssetMeta` payload. const E_ASSET_META_MISMATCH: u64 = 1; /// Coin decimals don't match the VAA. const E_DECIMALS_MISMATCH: u64 = 2; /// Container storing foreign asset info. struct ForeignInfo has store { token_chain: u16, token_address: ExternalAddress, native_decimals: u8, symbol: String } /// Container managing `ForeignInfo` and `TreasuryCap` for a wrapped asset /// coin type. struct WrappedAsset has store { info: ForeignInfo, treasury_cap: TreasuryCap, decimals: u8, upgrade_cap: UpgradeCap } /// Create new `WrappedAsset`. /// /// See `token_registry` module for more info. public(friend) fun new( token_meta: AssetMeta, coin_meta: &mut CoinMetadata, treasury_cap: TreasuryCap, upgrade_cap: UpgradeCap ): WrappedAsset { // Verify that the upgrade cap is from the same package as coin type. // This cap should not have been modified prior to creating this asset // (i.e. should have the default upgrade policy and build version == 1). wormhole::package_utils::assert_package_upgrade_cap( &upgrade_cap, package::compatible_policy(), 1 ); let ( token_address, token_chain, native_decimals, symbol, name ) = asset_meta::unpack(token_meta); // Protect against adding `AssetMeta` which has Sui's chain ID. assert!(token_chain != chain_id(), E_SUI_CHAIN); // Set metadata. coin::update_name(&mut treasury_cap, coin_meta, name); coin::update_symbol(&mut treasury_cap, coin_meta, string_utils::to_ascii(&symbol)); let decimals = cap_decimals(native_decimals); // Ensure that the `C` type has the right number of decimals. This is // the only field in the coinmeta that cannot be changed after the fact, // so we expect to receive one that already has the correct decimals // set. assert!(decimals == coin::get_decimals(coin_meta), E_DECIMALS_MISMATCH); let info = ForeignInfo { token_address, token_chain, native_decimals, symbol }; WrappedAsset { info, treasury_cap, decimals, upgrade_cap } } #[test_only] public fun new_test_only( token_meta: AssetMeta, coin_meta: &mut CoinMetadata, treasury_cap: TreasuryCap, upgrade_cap: UpgradeCap ): WrappedAsset { new(token_meta, coin_meta, treasury_cap, upgrade_cap) } /// Update existing `ForeignInfo` using new `AssetMeta`. /// /// See `token_registry` module for more info. public(friend) fun update_metadata( self: &mut WrappedAsset, coin_meta: &mut CoinMetadata, token_meta: AssetMeta ) { // NOTE: We ignore `native_decimals` because we do not enforce that // an asset's decimals on a foreign network needs to stay the same. let ( token_address, token_chain, _native_decimals, symbol, name ) = asset_meta::unpack(token_meta); // Verify canonical token info. Also check that the native decimals // have not changed (because changing this info is not desirable, as // this change means the supply changed on its native network). // // NOTE: This implicitly verifies that `token_chain` is not Sui's // because this was checked already when the asset was first added. let (expected_chain, expected_address) = canonical_info(self); assert!( ( token_chain == expected_chain && token_address == expected_address ), E_ASSET_META_MISMATCH ); // Finally only update the name and symbol. self.info.symbol = symbol; coin::update_name(&mut self.treasury_cap, coin_meta, name); coin::update_symbol(&mut self.treasury_cap, coin_meta, string_utils::to_ascii(&symbol)); } #[test_only] public fun update_metadata_test_only( self: &mut WrappedAsset, coin_meta: &mut CoinMetadata, token_meta: AssetMeta ) { update_metadata(self, coin_meta, token_meta) } /// Retrieve immutable reference to `ForeignInfo`. public fun info(self: &WrappedAsset): &ForeignInfo { &self.info } /// Retrieve canonical token chain ID from `ForeignInfo`. public fun token_chain(info: &ForeignInfo): u16 { info.token_chain } /// Retrieve canonical token address from `ForeignInfo`. public fun token_address(info: &ForeignInfo): ExternalAddress { info.token_address } /// Retrieve decimal amount from `ForeignInfo`. /// /// NOTE: This is for informational purposes. This decimal amount is not /// used for any calculations. public fun native_decimals(info: &ForeignInfo): u8 { info.native_decimals } /// Retrieve asset's symbol (UTF-8) from `ForeignMetadata`. /// /// NOTE: This value can be updated. public fun symbol(info: &ForeignInfo): String { info.symbol } /// Retrieve total minted supply. public fun total_supply(self: &WrappedAsset): u64 { coin::total_supply(&self.treasury_cap) } /// Retrieve decimals for this wrapped asset. For any asset whose native /// decimals is greater than the cap (8), this will be 8. /// /// See `normalized_amount` module for more info. public fun decimals(self: &WrappedAsset): u8 { self.decimals } /// Retrieve canonical token chain ID and token address. public fun canonical_info( self: &WrappedAsset ): (u16, ExternalAddress) { (self.info.token_chain, self.info.token_address) } /// Burn a given `Balance`. `Balance` originates from an outbound token /// transfer for a wrapped asset. /// /// See `transfer_tokens` module for more info. public(friend) fun burn( self: &mut WrappedAsset, burned: Balance ): u64 { balance::decrease_supply(coin::supply_mut(&mut self.treasury_cap), burned) } #[test_only] public fun burn_test_only( self: &mut WrappedAsset, burned: Balance ): u64 { burn(self, burned) } /// Mint a given amount. This amount is determined by an inbound token /// transfer payload for a wrapped asset. /// /// See `complete_transfer` module for more info. public(friend) fun mint( self: &mut WrappedAsset, amount: u64 ): Balance { coin::mint_balance(&mut self.treasury_cap, amount) } #[test_only] public fun mint_test_only( self: &mut WrappedAsset, amount: u64 ): Balance { mint(self, amount) } #[test_only] public fun destroy(asset: WrappedAsset) { let WrappedAsset { info, treasury_cap, decimals: _, upgrade_cap } = asset; sui::test_utils::destroy(treasury_cap); let ForeignInfo { token_chain: _, token_address: _, native_decimals: _, symbol: _ } = info; sui::package::make_immutable(upgrade_cap); } } #[test_only] module token_bridge::wrapped_asset_tests { use std::string::{Self}; use sui::balance::{Self}; use sui::coin::{Self, CoinMetadata}; use sui::object::{Self}; use sui::package::{Self}; use sui::test_scenario::{Self}; use wormhole::external_address::{Self}; use wormhole::state::{chain_id}; use token_bridge::asset_meta::{Self}; use token_bridge::string_utils; use token_bridge::coin_native_10::{COIN_NATIVE_10, Self}; use token_bridge::coin_wrapped_12::{COIN_WRAPPED_12, Self}; use token_bridge::coin_wrapped_7::{COIN_WRAPPED_7, Self}; use token_bridge::token_bridge_scenario::{person}; use token_bridge::wrapped_asset::{Self}; #[test] fun test_wrapped_asset_7() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; let parsed_meta = coin_wrapped_7::token_meta(); let expected_token_chain = asset_meta::token_chain(&parsed_meta); let expected_token_address = asset_meta::token_address(&parsed_meta); let expected_native_decimals = asset_meta::native_decimals(&parsed_meta); let expected_symbol = asset_meta::symbol(&parsed_meta); let expected_name = asset_meta::name(&parsed_meta); // Publish coin. let treasury_cap = coin_wrapped_7::init_and_take_treasury_cap( scenario, caller ); // Ignore effects. test_scenario::next_tx(scenario, caller); // Upgrade cap belonging to coin type. let upgrade_cap = package::test_publish( object::id_from_address(@token_bridge), test_scenario::ctx(scenario) ); let coin_meta: CoinMetadata = test_scenario::take_shared(scenario); // Make new. let asset = wrapped_asset::new_test_only( parsed_meta, &mut coin_meta, treasury_cap, upgrade_cap ); // Verify members. let info = wrapped_asset::info(&asset); assert!( wrapped_asset::token_chain(info) == expected_token_chain, 0 ); assert!( wrapped_asset::token_address(info) == expected_token_address, 0 ); assert!( wrapped_asset::native_decimals(info) == expected_native_decimals, 0 ); assert!(coin::get_symbol(&coin_meta) == string_utils::to_ascii(&expected_symbol), 0); assert!(coin::get_name(&coin_meta) == expected_name, 0); assert!(wrapped_asset::total_supply(&asset) == 0, 0); let (token_chain, token_address) = wrapped_asset::canonical_info(&asset); assert!(token_chain == expected_token_chain, 0); assert!(token_address == expected_token_address, 0); // Decimals are read from `CoinMetadata`, but in this case will agree // with the value encoded in the VAA. assert!(wrapped_asset::decimals(&asset) == expected_native_decimals, 0); assert!(coin::get_decimals(&coin_meta) == expected_native_decimals, 0); // Change name and symbol for update. let new_symbol = std::ascii::into_bytes(coin::get_symbol(&coin_meta)); std::vector::append(&mut new_symbol, b"??? and profit"); assert!(new_symbol != *string::bytes(&expected_symbol), 0); let new_name = coin::get_name(&coin_meta); string::append(&mut new_name, string::utf8(b"??? and profit")); assert!(new_name != expected_name, 0); let updated_meta = asset_meta::new( expected_token_address, expected_token_chain, expected_native_decimals, string::utf8(new_symbol), new_name ); // Update metadata now. wrapped_asset::update_metadata_test_only(&mut asset, &mut coin_meta, updated_meta); assert!(coin::get_symbol(&coin_meta) == std::ascii::string(new_symbol), 0); assert!(coin::get_name(&coin_meta) == new_name, 0); // Try to mint. let mint_amount = 420; let collected = balance::zero(); let (i, n) = (0, 8); while (i < n) { let minted = wrapped_asset::mint_test_only(&mut asset, mint_amount); assert!(balance::value(&minted) == mint_amount, 0); balance::join(&mut collected, minted); i = i + 1; }; assert!(balance::value(&collected) == n * mint_amount, 0); assert!( wrapped_asset::total_supply(&asset) == balance::value(&collected), 0 ); // Now try to burn. let burn_amount = 69; let i = 0; while (i < n) { let burned = balance::split(&mut collected, burn_amount); let check_amount = wrapped_asset::burn_test_only(&mut asset, burned); assert!(check_amount == burn_amount, 0); i = i + 1; }; let remaining = n * mint_amount - n * burn_amount; assert!(wrapped_asset::total_supply(&asset) == remaining, 0); assert!(balance::value(&collected) == remaining, 0); test_scenario::return_shared(coin_meta); // Clean up. balance::destroy_for_testing(collected); wrapped_asset::destroy(asset); // Done. test_scenario::end(my_scenario); } #[test] fun test_wrapped_asset_12() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; let parsed_meta = coin_wrapped_12::token_meta(); let expected_token_chain = asset_meta::token_chain(&parsed_meta); let expected_token_address = asset_meta::token_address(&parsed_meta); let expected_native_decimals = asset_meta::native_decimals(&parsed_meta); let expected_symbol = asset_meta::symbol(&parsed_meta); let expected_name = asset_meta::name(&parsed_meta); // Publish coin. let treasury_cap = coin_wrapped_12::init_and_take_treasury_cap( scenario, caller ); // Ignore effects. test_scenario::next_tx(scenario, caller); // Upgrade cap belonging to coin type. let upgrade_cap = package::test_publish( object::id_from_address(@token_bridge), test_scenario::ctx(scenario) ); let coin_meta: CoinMetadata = test_scenario::take_shared(scenario); // Make new. let asset = wrapped_asset::new_test_only( parsed_meta, &mut coin_meta, treasury_cap, upgrade_cap ); // Verify members. let info = wrapped_asset::info(&asset); assert!( wrapped_asset::token_chain(info) == expected_token_chain, 0 ); assert!( wrapped_asset::token_address(info) == expected_token_address, 0 ); assert!( wrapped_asset::native_decimals(info) == expected_native_decimals, 0 ); assert!(coin::get_symbol(&coin_meta) == string_utils::to_ascii(&expected_symbol), 0); assert!(coin::get_name(&coin_meta) == expected_name, 0); assert!(wrapped_asset::total_supply(&asset) == 0, 0); let (token_chain, token_address) = wrapped_asset::canonical_info(&asset); assert!(token_chain == expected_token_chain, 0); assert!(token_address == expected_token_address, 0); // Decimals are read from `CoinMetadata`, but in this case will not // agree with the value encoded in the VAA. assert!(wrapped_asset::decimals(&asset) == 8, 0); assert!( coin::get_decimals(&coin_meta) == wrapped_asset::decimals(&asset), 0 ); assert!(wrapped_asset::decimals(&asset) != expected_native_decimals, 0); // Change name and symbol for update. let new_symbol = std::ascii::into_bytes(coin::get_symbol(&coin_meta)); std::vector::append(&mut new_symbol, b"??? and profit"); assert!(new_symbol != *string::bytes(&expected_symbol), 0); let new_name = coin::get_name(&coin_meta); string::append(&mut new_name, string::utf8(b"??? and profit")); assert!(new_name != expected_name, 0); let updated_meta = asset_meta::new( expected_token_address, expected_token_chain, expected_native_decimals, string::utf8(new_symbol), new_name ); // Update metadata now. wrapped_asset::update_metadata_test_only(&mut asset, &mut coin_meta, updated_meta); assert!(coin::get_symbol(&coin_meta) == std::ascii::string(new_symbol), 0); assert!(coin::get_name(&coin_meta) == new_name, 0); // Try to mint. let mint_amount = 420; let collected = balance::zero(); let (i, n) = (0, 8); while (i < n) { let minted = wrapped_asset::mint_test_only(&mut asset, mint_amount); assert!(balance::value(&minted) == mint_amount, 0); balance::join(&mut collected, minted); i = i + 1; }; assert!(balance::value(&collected) == n * mint_amount, 0); assert!( wrapped_asset::total_supply(&asset) == balance::value(&collected), 0 ); // Now try to burn. let burn_amount = 69; let i = 0; while (i < n) { let burned = balance::split(&mut collected, burn_amount); let check_amount = wrapped_asset::burn_test_only(&mut asset, burned); assert!(check_amount == burn_amount, 0); i = i + 1; }; let remaining = n * mint_amount - n * burn_amount; assert!(wrapped_asset::total_supply(&asset) == remaining, 0); assert!(balance::value(&collected) == remaining, 0); // Clean up. balance::destroy_for_testing(collected); wrapped_asset::destroy(asset); test_scenario::return_shared(coin_meta); // Done. test_scenario::end(my_scenario); } #[test] #[expected_failure(abort_code = wrapped_asset::E_SUI_CHAIN)] // In this negative test case, we attempt to register a native coin as a // wrapped coin. fun test_cannot_new_sui_chain() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; // Initialize new coin type. coin_native_10::init_test_only(test_scenario::ctx(scenario)); // Ignore effects. test_scenario::next_tx(scenario, caller); // Sui's chain ID is not allowed. let invalid_meta = asset_meta::new( external_address::default(), chain_id(), 10, string::utf8(b""), string::utf8(b"") ); // Upgrade cap belonging to coin type. let upgrade_cap = package::test_publish( object::id_from_address(@token_bridge), test_scenario::ctx(scenario) ); let treasury_cap = test_scenario::take_shared>(scenario); let coin_meta = test_scenario::take_shared>(scenario); // You shall not pass! let asset = wrapped_asset::new_test_only( invalid_meta, &mut coin_meta, treasury_cap, upgrade_cap ); // Clean up. wrapped_asset::destroy(asset); abort 42 } #[test] #[expected_failure(abort_code = wrapped_asset::E_ASSET_META_MISMATCH)] /// In this negative test case, we attempt to update with a mismatching /// chain. fun test_cannot_update_metadata_asset_meta_mismatch_token_address() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; let parsed_meta = coin_wrapped_12::token_meta(); let expected_token_chain = asset_meta::token_chain(&parsed_meta); let expected_token_address = asset_meta::token_address(&parsed_meta); let expected_native_decimals = asset_meta::native_decimals(&parsed_meta); // Publish coin. let treasury_cap = coin_wrapped_12::init_and_take_treasury_cap( scenario, caller ); // Ignore effects. test_scenario::next_tx(scenario, caller); // Upgrade cap belonging to coin type. let upgrade_cap = package::test_publish( object::id_from_address(@token_bridge), test_scenario::ctx(scenario) ); let coin_meta = test_scenario::take_shared(scenario); // Make new. let asset = wrapped_asset::new_test_only( parsed_meta, &mut coin_meta, treasury_cap, upgrade_cap ); let invalid_meta = asset_meta::new( external_address::default(), expected_token_chain, expected_native_decimals, string::utf8(b""), string::utf8(b""), ); assert!( asset_meta::token_address(&invalid_meta) != expected_token_address, 0 ); assert!( asset_meta::token_chain(&invalid_meta) == expected_token_chain, 0 ); assert!( asset_meta::native_decimals(&invalid_meta) == expected_native_decimals, 0 ); // You shall not pass! wrapped_asset::update_metadata_test_only(&mut asset, &mut coin_meta, invalid_meta); // Clean up. wrapped_asset::destroy(asset); abort 42 } #[test] #[expected_failure(abort_code = wrapped_asset::E_ASSET_META_MISMATCH)] /// In this negative test case, we attempt to update with a mismatching /// chain. fun test_cannot_update_metadata_asset_meta_mismatch_token_chain() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; let parsed_meta = coin_wrapped_12::token_meta(); let expected_token_chain = asset_meta::token_chain(&parsed_meta); let expected_token_address = asset_meta::token_address(&parsed_meta); let expected_native_decimals = asset_meta::native_decimals(&parsed_meta); // Publish coin. let treasury_cap = coin_wrapped_12::init_and_take_treasury_cap( scenario, caller ); // Ignore effects. test_scenario::next_tx(scenario, caller); // Upgrade cap belonging to coin type. let upgrade_cap = package::test_publish( object::id_from_address(@token_bridge), test_scenario::ctx(scenario) ); let coin_meta = test_scenario::take_shared(scenario); // Make new. let asset = wrapped_asset::new_test_only( parsed_meta, &mut coin_meta, treasury_cap, upgrade_cap ); let invalid_meta = asset_meta::new( expected_token_address, chain_id(), expected_native_decimals, string::utf8(b""), string::utf8(b""), ); assert!( asset_meta::token_address(&invalid_meta) == expected_token_address, 0 ); assert!( asset_meta::token_chain(&invalid_meta) != expected_token_chain, 0 ); assert!( asset_meta::native_decimals(&invalid_meta) == expected_native_decimals, 0 ); // You shall not pass! wrapped_asset::update_metadata_test_only(&mut asset, &mut coin_meta, invalid_meta); // Clean up. wrapped_asset::destroy(asset); abort 42 } #[test] #[expected_failure( abort_code = wormhole::package_utils::E_INVALID_UPGRADE_CAP )] fun test_cannot_new_upgrade_cap_mismatch() { let caller = person(); let my_scenario = test_scenario::begin(caller); let scenario = &mut my_scenario; // Publish coin. let treasury_cap = coin_wrapped_12::init_and_take_treasury_cap( scenario, caller ); // Ignore effects. test_scenario::next_tx(scenario, caller); // Upgrade cap belonging to coin type. let upgrade_cap = package::test_publish( object::id_from_address(@0xbadc0de), test_scenario::ctx(scenario) ); let coin_meta = test_scenario::take_shared(scenario); // You shall not pass! let asset = wrapped_asset::new_test_only( coin_wrapped_12::token_meta(), &mut coin_meta, treasury_cap, upgrade_cap ); // Clean up. wrapped_asset::destroy(asset); abort 42 } }