From 3566aed797f936f6fd53ed84bb5e9e318b67be58 Mon Sep 17 00:00:00 2001 From: Chris Li <76067158+666lcz@users.noreply.github.com> Date: Thu, 10 Mar 2022 08:40:58 -0800 Subject: [PATCH] Implement the move module for cross-chain airdrop (#623) --- sui/src/keystore.rs | 6 + sui/src/unit_tests/cli_tests.rs | 179 ++++++++++++++++-- .../framework/sources/CrossChainAirdrop.move | 156 +++++++++++++++ .../tests/CrossChainAirdropTests.move | 82 ++++++++ 4 files changed, 410 insertions(+), 13 deletions(-) create mode 100644 sui_programmability/framework/sources/CrossChainAirdrop.move create mode 100644 sui_programmability/framework/tests/CrossChainAirdropTests.move diff --git a/sui/src/keystore.rs b/sui/src/keystore.rs index 89757140819b4..339ac3a296620 100644 --- a/sui/src/keystore.rs +++ b/sui/src/keystore.rs @@ -82,6 +82,12 @@ impl SuiKeystore { let store = serde_json::to_string_pretty(&self.keys.values().collect::>()).unwrap(); Ok(fs::write(&self.path, store)?) } + + pub fn add_key(&mut self, address: SuiAddress, keypair: KeyPair) -> Result<(), anyhow::Error> { + self.keys.insert(address, keypair); + self.save()?; + Ok(()) + } } pub struct SuiKeystoreSigner { diff --git a/sui/src/unit_tests/cli_tests.rs b/sui/src/unit_tests/cli_tests.rs index e2bff80027cff..99d738ec37b3c 100644 --- a/sui/src/unit_tests/cli_tests.rs +++ b/sui/src/unit_tests/cli_tests.rs @@ -2,28 +2,35 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; use move_core_types::identifier::Identifier; -use serde_json::json; +use serde_json::{json, Value}; use std::fs::read_dir; use std::ops::Add; use std::path::Path; +use std::str; use std::time::Duration; use sui::config::{ AccountConfig, AuthorityPrivateInfo, GenesisConfig, NetworkConfig, ObjectConfig, WalletConfig, AUTHORITIES_DB_NAME, }; use sui::gateway::GatewayType; +use sui::keystore::{KeystoreType, SuiKeystore}; use sui::sui_json::SuiJsonValue; use sui::wallet_commands::{WalletCommandResult, WalletCommands, WalletContext}; use sui_network::network::PortAllocator; use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress}; use sui_types::crypto::get_key_pair; use sui_types::messages::TransactionEffects; -use sui_types::object::{Data, MoveObject, Object, ObjectRead, GAS_VALUE_FOR_TESTING}; +use sui_types::object::{Object, ObjectRead, GAS_VALUE_FOR_TESTING}; +use tempfile::TempDir; use tokio::task; use tokio::task::JoinHandle; use tracing_test::traced_test; const TEST_DATA_DIR: &str = "src/unit_tests/data/"; +const AIRDROP_SOURCE_CONTRACT_ADDRESS: &str = "bc4ca0eda7647a8ab7c2061c2e118a18a936f13d"; +const AIRDROP_SOURCE_TOKEN_ID: u64 = 101u64; +const AIRDROP_TOKEN_NAME: &str = "BoredApeYachtClub"; +const AIRDROP_TOKEN_URI: &str = "ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/101"; macro_rules! retry_assert { ($test:expr, $timeout:expr) => {{ @@ -120,6 +127,152 @@ async fn test_addresses_command() -> Result<(), anyhow::Error> { Ok(()) } +#[traced_test] +#[tokio::test] +// TODO move this function to a standalone file +async fn test_cross_chain_airdrop() -> Result<(), anyhow::Error> { + let working_dir = tempfile::tempdir()?; + + let network = start_network(working_dir.path(), 10101, None).await?; + // Wait for authorities to come alive. + retry_assert!( + logs_contain("Listening to TCP traffic on 127.0.0.1"), + Duration::from_millis(5000) + ); + + // Create Wallet context with the oracle account + let (oracle_address, mut context) = airdrop_get_wallet_context_with_oracle(working_dir).await?; + let recipient_address = *context.config.accounts.first().unwrap(); + + // Assemble the move call to claim the airdrop + let oracle_obj_str = format!( + "0x{:02x}", + airdrop_get_oracle_object(oracle_address, &mut context).await? + ); + let args_json = json!([ + oracle_obj_str, + format!("0x{:02x}", recipient_address), + AIRDROP_SOURCE_CONTRACT_ADDRESS.to_string(), + json!(AIRDROP_SOURCE_TOKEN_ID), + AIRDROP_TOKEN_NAME.to_string(), + AIRDROP_TOKEN_URI.to_string() + ]); + let mut args = vec![]; + for a in args_json.as_array().unwrap() { + args.push(SuiJsonValue::new(a.clone()).unwrap()); + } + + // Claim the airdrop + let gas_object_id = transfer_gas(recipient_address, oracle_address, &mut context).await?; + let token = airdrop_call_move_and_get_created_object(args, gas_object_id, &mut context).await?; + + // Verify the airdrop token + assert_eq!(token["contents"]["type"], ("0x2::CrossChainAirdrop::NFT")); + let fields = &token["contents"]["fields"]; + assert_eq!( + fields["source_token_id"]["fields"]["id"], + AIRDROP_SOURCE_TOKEN_ID + ); + + // TODO: verify the other string fields once SuiJSON has better support for rendering + // string fields + + network.abort(); + Ok(()) +} + +async fn airdrop_get_wallet_context_with_oracle( + working_dir: TempDir, +) -> Result<(SuiAddress, WalletContext), anyhow::Error> { + use sui_types::crypto::get_key_pair_from_bytes; + + let (oracle_address, keypair) = get_key_pair_from_bytes(&[ + 143, 102, 49, 171, 56, 173, 188, 83, 154, 218, 98, 200, 173, 252, 53, 239, 131, 210, 147, + 14, 4, 24, 132, 151, 178, 0, 167, 89, 176, 90, 106, 176, 208, 47, 8, 58, 177, 56, 246, 192, + 244, 88, 202, 115, 9, 82, 3, 184, 18, 236, 128, 199, 22, 37, 255, 146, 103, 34, 0, 240, + 255, 163, 60, 174, + ]); + let mut wallet_conf = WalletConfig::read_or_create(&working_dir.path().join("wallet.conf"))?; + let path = match &wallet_conf.keystore { + KeystoreType::File(path) => path, + _ => panic!("Unexpected KeystoreType"), + }; + let mut store = SuiKeystore::load_or_create(path)?; + store.add_key(oracle_address, keypair)?; + Vec::push(&mut wallet_conf.accounts, oracle_address); + Ok((oracle_address, WalletContext::new(wallet_conf)?)) +} + +async fn airdrop_get_oracle_object( + address: SuiAddress, + context: &mut WalletContext, +) -> Result { + WalletCommands::SyncClientState { address } + .execute(context) + .await? + .print(true); + let object_refs = context.gateway.get_owned_objects(address); + assert_eq!(object_refs.len(), 1); + Ok(object_refs.first().unwrap().0) +} + +async fn airdrop_call_move_and_get_created_object( + args: Vec, + gas: ObjectID, + context: &mut WalletContext, +) -> Result { + let resp = WalletCommands::Call { + package: ObjectID::from_hex_literal("0x2").unwrap(), + module: Identifier::new("CrossChainAirdrop").unwrap(), + function: Identifier::new("claim").unwrap(), + type_args: vec![], + args: args.to_vec(), + gas, + gas_budget: 1000, + } + .execute(context) + .await?; + + let minted_token_id = match resp { + WalletCommandResult::Call( + _, + TransactionEffects { + created: new_objs, .. + }, + ) => { + assert_eq!(new_objs.len(), 1); + new_objs[0].0 .0 + } + _ => panic!("unexpected WalletCommandResult"), + }; + + get_move_object(context, minted_token_id).await +} + +async fn transfer_gas( + from: SuiAddress, + to: SuiAddress, + context: &mut WalletContext, +) -> Result { + let gas_objects_result = WalletCommands::Gas { address: from } + .execute(context) + .await?; + + let gas_objects = match gas_objects_result { + WalletCommandResult::Gas(objs) => objs, + _ => panic!("unexpected WalletCommandResult"), + }; + + WalletCommands::Transfer { + to, + object_id: *gas_objects[0].id(), + gas: *gas_objects[1].id(), + } + .execute(context) + .await?; + Ok(*gas_objects[0].id()) +} + #[traced_test] #[tokio::test] async fn test_objects_command() -> Result<(), anyhow::Error> { @@ -282,7 +435,9 @@ async fn test_custom_genesis_with_custom_move_package() -> Result<(), anyhow::Er // Make sure init() is executed correctly for custom_genesis_package_2::M1 let move_objects = get_move_objects(&mut context, address).await?; assert_eq!(move_objects.len(), 1); - assert_eq!(move_objects[0].type_.module.as_str(), "M1"); + assert!(move_objects[0]["contents"]["type"] + .to_string() + .contains("M1::Object")); network.abort(); Ok(()) @@ -460,7 +615,7 @@ fn extract_gas_info(s: &str) -> Option<(ObjectID, SequenceNumber, u64)> { async fn get_move_objects( context: &mut WalletContext, address: SuiAddress, -) -> Result, anyhow::Error> { +) -> Result, anyhow::Error> { // Sync client to retrieve objects from the network. WalletCommands::SyncClientState { address } .execute(context) @@ -488,18 +643,16 @@ async fn get_move_objects( async fn get_move_object( context: &mut WalletContext, id: ObjectID, -) -> Result { +) -> Result { let obj = WalletCommands::Object { id }.execute(context).await?; match obj { - WalletCommandResult::Object(ObjectRead::Exists( - _, - Object { - data: Data::Move(m), - .. - }, - .., - )) => Ok(m), + WalletCommandResult::Object(obj) => match obj { + ObjectRead::Exists(_, obj, layout) => { + Ok(obj.to_json(&layout).unwrap_or_else(|_| json!(""))) + } + _ => panic!("WalletCommands::Object returns wrong type"), + }, _ => panic!("WalletCommands::Object returns wrong type {}", obj), } } diff --git a/sui_programmability/framework/sources/CrossChainAirdrop.move b/sui_programmability/framework/sources/CrossChainAirdrop.move new file mode 100644 index 0000000000000..4dfb644c03af2 --- /dev/null +++ b/sui_programmability/framework/sources/CrossChainAirdrop.move @@ -0,0 +1,156 @@ +// Copyright (c) 2022, Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/// Allow a trusted oracle to mint a copy of NFT from a different chain. There can +/// only be one copy for each unique pair of contract_address and token_id. We only +/// support a single chain(Ethereum) right now, but this can be extended to other +/// chains by adding a chain_id field. +module Sui::CrossChainAirdrop { + use Std::Vector; + use Sui::ID::{VersionedID}; + use Sui::Transfer; + use Sui::TxContext::{Self, TxContext}; + + /// The oracle manages one `PerContractAirdropInfo` for each Ethereum contract + struct CrossChainAirdropOracle has key { + id: VersionedID, + // TODO: replace this with SparseSet for O(1) on-chain uniqueness check + managed_contracts: vector + } + + /// The token ID on the source contract + struct SourceTokenID has store, copy { + id: u64, + } + + /// The address of the source contract + struct SourceContractAddress has store, copy { + address: vector, + } + + /// Contains the Airdrop info for one contract address on Ethereum + struct PerContractAirdropInfo has store { + /// A single contract address on Ethereum + source_contract_address: SourceContractAddress, + + /// The Ethereum token ids whose Airdrop has been claimed. These + /// are stored to prevent the same NFT from being claimed twice + // TODO: replace u64 with u256 once the latter is supported + // + // TODO: replace this with SparseSet for O(1) on-chain uniqueness check + claimed_source_token_ids: vector + } + + /// The Sui representation of the original NFT + struct NFT has key { + id: VersionedID, + /// The address of the source contract, e.g, the Ethereum contract address + source_contract_address: SourceContractAddress, + // TODO: replace u64 with u256 once the latter is supported + // + /// The token id associated with the source contract e.g., the Ethereum token id + source_token_id: SourceTokenID, + /// A distinct Uniform Resource Identifier (URI) for a given asset. + /// This corresponds to the `tokenURI()` method in the ERC721Metadata + /// interface in EIP-721. + token_uri: vector, + /// A descriptive name for a collection of NFTs in this contract. + /// This corresponds to the `name()` method in the + /// ERC721Metadata interface in EIP-721. + name: vector, + } + + /// Address of the Oracle + // TODO: Change this to something else before testnet launch + const ORACLE_ADDRESS: address = @0xCEF1A51D2AA1226E54A1ACB85CFC58A051125A49; + + // Error codes + + /// Trying to claim a token that has already been claimed + const ETOKEN_ID_CLAIMED: u64 = 0; + + /// Create the `Orcacle` capability and hand it off to the oracle + fun init(ctx: &mut TxContext) { + let oracle = oracle_address(); + Transfer::transfer( + CrossChainAirdropOracle { + id: TxContext::new_id(ctx), + managed_contracts: Vector::empty(), + }, + oracle + ) + } + + /// Called by the oracle to mint the airdrop NFT and transfer to the recipient + public fun claim( + oracle: &mut CrossChainAirdropOracle, + recipient: address, + source_contract_address: vector, + source_token_id: u64, + name: vector, + token_uri: vector, + ctx: &mut TxContext, + ) { + let contract = get_or_create_contract(oracle, &source_contract_address); + // NOTE: this is where the globally uniqueness check happens + assert!(!is_token_claimed(contract, source_token_id), ETOKEN_ID_CLAIMED); + let token_id = SourceTokenID{ id: source_token_id }; + let coin = NFT { + id: TxContext::new_id(ctx), + source_contract_address: SourceContractAddress { address: source_contract_address }, + source_token_id: copy token_id, + name, + token_uri + }; + Vector::push_back(&mut contract.claimed_source_token_ids, token_id); + Transfer::transfer(coin, recipient); + } + + fun get_or_create_contract(oracle: &mut CrossChainAirdropOracle, source_contract_address: &vector): &mut PerContractAirdropInfo { + let index = 0; + // TODO: replace this with SparseSet so that the on-chain uniqueness check can be O(1) + while (index < Vector::length(&oracle.managed_contracts)) { + let info = Vector::borrow_mut(&mut oracle.managed_contracts, index); + if (&info.source_contract_address.address == source_contract_address) { + return info + }; + index = index + 1; + }; + + create_contract(oracle, source_contract_address) + } + + fun create_contract(oracle: &mut CrossChainAirdropOracle, source_contract_address: &vector): &mut PerContractAirdropInfo { + let info = PerContractAirdropInfo { + source_contract_address: SourceContractAddress { address: *source_contract_address }, + claimed_source_token_ids: Vector::empty() + }; + Vector::push_back(&mut oracle.managed_contracts, info); + let idx = Vector::length(&oracle.managed_contracts) - 1; + Vector::borrow_mut(&mut oracle.managed_contracts, idx) + } + + fun is_token_claimed(contract: &PerContractAirdropInfo, source_token_id: u64): bool { + // TODO: replace this with SparseSet so that the on-chain uniqueness check can be O(1) + let index = 0; + while (index < Vector::length(&contract.claimed_source_token_ids)) { + let claimed_id = Vector::borrow(&contract.claimed_source_token_ids, index); + if (&claimed_id.id == &source_token_id) { + return true + }; + index = index + 1; + }; + false + } + + public fun oracle_address(): address { + ORACLE_ADDRESS + } + + #[test_only] + /// Wrapper of module initializer for testing + public fun test_init(ctx: &mut TxContext) { + init(ctx) + } +} + diff --git a/sui_programmability/framework/tests/CrossChainAirdropTests.move b/sui_programmability/framework/tests/CrossChainAirdropTests.move new file mode 100644 index 0000000000000..13208fa83bc2f --- /dev/null +++ b/sui_programmability/framework/tests/CrossChainAirdropTests.move @@ -0,0 +1,82 @@ +// Copyright (c) 2022, Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[test_only] +module Sui::CrossChainAirdropTests { + use Sui::CrossChainAirdrop::{Self, CrossChainAirdropOracle, NFT}; + use Sui::ID::{VersionedID}; + use Sui::TestScenario::{Self, Scenario}; + + // Error codes + + /// Trying to claim a token that has already been claimed + const ETOKEN_ID_CLAIMED: u64 = 0; + const EOBJECT_NOT_FOUND: u64 = 1; + + const RECIPIENT_ADDRESS: address = @0x10; + const SOURCE_CONTRACT_ADDRESS: vector = x"BC4CA0EdA7647A8aB7C2061c2E118A18a936f13D"; + const SOURCE_TOKEN_ID: u64 = 101; + const NAME: vector = b"BoredApeYachtClub"; + const TOKEN_URI: vector = b"ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/101"; + + struct Object has key { + id: VersionedID, + } + + #[test] + fun test_claim_airdrop() { + let (scenario, oracle_address) = init(); + + // claim a token + claim_token(&mut scenario, &oracle_address, SOURCE_TOKEN_ID); + + // verify that the recipient has received the nft + assert!(owns_object(&mut scenario, &RECIPIENT_ADDRESS), EOBJECT_NOT_FOUND); + } + + #[test] + #[expected_failure(abort_code = 0)] + fun test_double_claim() { + let (scenario, oracle_address) = init(); + + // claim a token + claim_token(&mut scenario, &oracle_address, SOURCE_TOKEN_ID); + + // claim the same token again + claim_token(&mut scenario, &oracle_address, SOURCE_TOKEN_ID); + } + + fun init(): (Scenario, address) { + let oracle_address = CrossChainAirdrop::oracle_address(); + let scenario = TestScenario::begin(&oracle_address); + { + let ctx = TestScenario::ctx(&mut scenario); + CrossChainAirdrop::test_init(ctx); + }; + (scenario, oracle_address) + } + + fun claim_token(scenario: &mut Scenario, oracle_address: &address, token_id: u64) { + TestScenario::next_tx(scenario, oracle_address); + { + let oracle = TestScenario::remove_object(scenario); + let ctx = TestScenario::ctx(scenario); + CrossChainAirdrop::claim( + &mut oracle, + RECIPIENT_ADDRESS, + SOURCE_CONTRACT_ADDRESS, + token_id, + NAME, + TOKEN_URI, + ctx, + ); + TestScenario::return_object(scenario, oracle); + }; + } + + fun owns_object(scenario: &mut Scenario, owner: &address): bool{ + // Verify the token has been transfer to the recipient + TestScenario::next_tx(scenario, owner); + TestScenario::can_remove_object(scenario) + } +}