From aa56dc54986aab1e9d3320a8c5c2da37ede187e6 Mon Sep 17 00:00:00 2001 From: heyitaki Date: Sun, 22 Jan 2023 04:32:06 +0000 Subject: [PATCH] sdk/js: finish aptos tests --- sdk/js/src/aptos/types.ts | 26 +-- .../nft_bridge/__tests__/aptos-integration.ts | 168 +++++++++++------- sdk/js/src/nft_bridge/getForeignAsset.ts | 51 +++--- sdk/js/src/nft_bridge/getIsWrappedAsset.ts | 7 +- sdk/js/src/nft_bridge/getOriginalAsset.ts | 42 +++-- sdk/js/src/utils/aptos.ts | 88 +++++++-- 6 files changed, 240 insertions(+), 142 deletions(-) diff --git a/sdk/js/src/aptos/types.ts b/sdk/js/src/aptos/types.ts index c47fa5126..2c4a9b4bc 100644 --- a/sdk/js/src/aptos/types.ts +++ b/sdk/js/src/aptos/types.ts @@ -1,3 +1,5 @@ +import { TokenTypes } from "aptos"; + export type TokenBridgeState = { consumed_vaas: { elems: { @@ -74,11 +76,7 @@ export type CreateTokenDataEvent = { type: "0x3::token::CreateTokenDataEvent"; data: { description: string; - id: { - collection: string; - creator: string; - name: string; - }; + id: TokenTypes.TokenDataId; maximum: string; mutability_config: { description: boolean; @@ -108,22 +106,6 @@ export type DepositEvent = { type: "0x3::token::DepositEvent"; data: { amount: string; - id: RawTokenId; + id: TokenTypes.TokenId; }; }; - -export type RawTokenId = { - property_version: string; - token_data_id: { - collection: string; - creator: string; - name: string; - }; -}; - -export type TokenId = { - creatorAddress: string; - collectionName: string; - tokenName: string; - propertyVersion: number; -}; diff --git a/sdk/js/src/nft_bridge/__tests__/aptos-integration.ts b/sdk/js/src/nft_bridge/__tests__/aptos-integration.ts index dab3163b3..086b11d4f 100644 --- a/sdk/js/src/nft_bridge/__tests__/aptos-integration.ts +++ b/sdk/js/src/nft_bridge/__tests__/aptos-integration.ts @@ -6,24 +6,26 @@ import { jest, test, } from "@jest/globals"; +import { BN } from "@project-serum/anchor"; import { getAssociatedTokenAddress } from "@solana/spl-token"; import { Connection, Keypair, PublicKey } from "@solana/web3.js"; import { AptosAccount, AptosClient, FaucetClient, Types } from "aptos"; import { ethers } from "ethers"; import Web3 from "web3"; -import { DepositEvent, TokenId } from "../../aptos/types"; +import { DepositEvent } from "../../aptos/types"; import { CHAIN_ID_APTOS, CHAIN_ID_ETH, CHAIN_ID_SOLANA, CONTRACTS, + deriveCollectionHashFromTokenId, deriveTokenHashFromTokenId, generateSignAndSubmitEntryFunction, tryNativeToHexString, tryNativeToUint8Array, } from "../../utils"; import { parseNftTransferVaa } from "../../vaa"; -import { getForeignAssetAptos } from "../getForeignAsset"; +import { getForeignAssetAptos, getForeignAssetEth } from "../getForeignAsset"; import { getIsTransferCompletedAptos } from "../getIsTransferCompleted"; import { getIsWrappedAssetAptos } from "../getIsWrappedAsset"; import { getOriginalAssetAptos } from "../getOriginalAsset"; @@ -130,35 +132,31 @@ describe("Aptos NFT SDK tests", () => { )) as Types.UserTransaction; expect(aptosRedeemTxResult.success).toBe(true); expect( - await getIsTransferCompletedAptos( + getIsTransferCompletedAptos( aptosClient, APTOS_NFT_BRIDGE_ADDRESS, ethTransferVaa ) - ).toBe(true); + ).resolves.toBe(true); // get token data - const tokenData = await getForeignAssetAptos( + const tokenId = await getForeignAssetAptos( aptosClient, APTOS_NFT_BRIDGE_ADDRESS, CHAIN_ID_ETH, tryNativeToUint8Array(ethNft.address, CHAIN_ID_ETH) ); - assertIsNotNull(tokenData); + assertIsNotNull(tokenId); expect( - await getIsWrappedAssetAptos( + getIsWrappedAssetAptos( aptosClient, APTOS_NFT_BRIDGE_ADDRESS, - tokenData.creatorAddress + tokenId.token_data_id.creator ) - ).toBe(true); + ).resolves.toBe(true); expect( - await getOriginalAssetAptos( - aptosClient, - APTOS_NFT_BRIDGE_ADDRESS, - tokenData.creatorAddress - ) - ).toMatchObject({ + getOriginalAssetAptos(aptosClient, APTOS_NFT_BRIDGE_ADDRESS, tokenId) + ).resolves.toStrictEqual({ isWrapped: true, chainId: CHAIN_ID_ETH, assetAddress: Uint8Array.from(ethTransferVaaParsed.tokenAddress), @@ -167,9 +165,9 @@ describe("Aptos NFT SDK tests", () => { // transfer NFT from Aptos back to Ethereum const aptosTransferPayload = await transferFromAptos( APTOS_NFT_BRIDGE_ADDRESS, - tokenData.creatorAddress, - tokenData.collectionName, - tokenData.tokenName.padStart(64, "0"), + tokenId.token_data_id.creator, + tokenId.token_data_id.collection, + tokenId.token_data_id.name.padStart(64, "0"), 0, CHAIN_ID_ETH, tryNativeToUint8Array(ethSigner.address, CHAIN_ID_ETH) @@ -215,6 +213,13 @@ describe("Aptos NFT SDK tests", () => { APTOS_COLLECTION_NAME, "APE🦧" ); + expect( + await getIsWrappedAssetAptos( + aptosClient, + APTOS_NFT_BRIDGE_ADDRESS, + aptosAccount.address().toString() + ) + ).toBe(false); // get token data from user wallet const event = ( @@ -222,24 +227,18 @@ describe("Aptos NFT SDK tests", () => { aptosAccount.address(), "0x3::token::TokenStore", "deposit_events", - { limit: 1 } // most users will more than one deposit event + { limit: 1 } ) )[0] as DepositEvent; - const tokenId: TokenId = { - creatorAddress: event.data.id.token_data_id.creator, - collectionName: event.data.id.token_data_id.collection, - tokenName: event.data.id.token_data_id.name, - propertyVersion: Number(event.data.id.property_version), - }; - console.log(tokenId); + const depositTokenId = event.data.id; - // transfer NFT from Aptos to Solana + // transfer NFT from Aptos to Ethereum const aptosTransferPayload = await transferFromAptos( APTOS_NFT_BRIDGE_ADDRESS, - tokenId.creatorAddress, - tokenId.collectionName, - tokenId.tokenName, - tokenId.propertyVersion, + depositTokenId.token_data_id.creator, + depositTokenId.token_data_id.collection, + depositTokenId.token_data_id.name, + Number(depositTokenId.property_version), CHAIN_ID_ETH, tryNativeToUint8Array(ethSigner.address, CHAIN_ID_ETH) ); @@ -268,44 +267,85 @@ describe("Aptos NFT SDK tests", () => { ethSigner, aptosTransferVaa ); - console.log(JSON.stringify(ethRedeemTx, null, 2)); expect(ethRedeemTx.status).toBe(1); + // sanity check token hash and id + const tokenHash = await deriveTokenHashFromTokenId(depositTokenId); + expect(Buffer.from(tokenHash).toString("hex")).toBe( + new BN(aptosTransferVaaParsed.tokenId.toString()) + .toString("hex") + .padStart(64, "0") // conversion to BN strips leading zeros + ); + + const foreignAssetTokenId = await getForeignAssetAptos( + aptosClient, + APTOS_NFT_BRIDGE_ADDRESS, + CHAIN_ID_APTOS, + tokenHash + ); + assertIsNotNull(foreignAssetTokenId); + expect(foreignAssetTokenId).toStrictEqual(depositTokenId); + // get token address on Ethereum - const tokenHash = await deriveTokenHashFromTokenId(tokenId); + const tokenAddressAptos = await deriveCollectionHashFromTokenId( + foreignAssetTokenId + ); + const tokenAddressEth = await getForeignAssetEth( + ETH_NFT_BRIDGE_ADDRESS, + ethSigner, + CHAIN_ID_APTOS, + tokenAddressAptos + ); + assertIsNotNull(tokenAddressEth); + + // transfer NFT from Ethereum back to Aptos + const ethTransferTx = await transferFromEth( + ETH_NFT_BRIDGE_ADDRESS, + ethSigner, + tokenAddressEth, + tokenHash, + CHAIN_ID_APTOS, + tryNativeToUint8Array(aptosAccount.address().toString(), CHAIN_ID_APTOS) + ); + expect(ethTransferTx.status).toBe(1); + + // observe tx and get vaa + const ethTransferVaa = await getSignedVaaEthereum(ethTransferTx); + const ethTransferVaaParsed = parseNftTransferVaa(ethTransferVaa); + expect(ethTransferVaaParsed.name).toBe(APTOS_COLLECTION_NAME); + + // redeem NFT on Aptos + const aptosRedeemTxPayload = await redeemOnAptos( + APTOS_NFT_BRIDGE_ADDRESS, + ethTransferVaa + ); + const aptosRedeemTx = await generateSignAndSubmitEntryFunction( + aptosClient, + aptosAccount, + aptosRedeemTxPayload + ); + const aptosRedeemTxResult = (await aptosClient.waitForTransactionWithResult( + aptosRedeemTx.hash + )) as Types.UserTransaction; + expect(aptosRedeemTxResult.success).toBe(true); expect( - await getForeignAssetAptos( + getIsTransferCompletedAptos( aptosClient, APTOS_NFT_BRIDGE_ADDRESS, - CHAIN_ID_APTOS, - tokenHash + ethTransferVaa ) - ).toMatchObject(tokenId); - // const tokenAddress = await getForeignAssetEth( - // ETH_NFT_BRIDGE_ADDRESS, - // ethSigner, - // CHAIN_ID_APTOS, - // tokenHash - // ); - // console.log(tokenAddress); - // assertIsNotNull(tokenAddress); - - // // transfer NFT from Ethereum back to Aptos - // const ethTransferTx = await transferFromEth( - // ETH_NFT_BRIDGE_ADDRESS, - // ethSigner, - // tokenAddress, - // 0, - // CHAIN_ID_APTOS, - // tryNativeToUint8Array(aptosAccount.address().toString(), CHAIN_ID_APTOS) - // ); - // expect(ethTransferTx.status).toBe(1); - - // // observe tx and get vaa - - // // redeem NFT on Aptos - - // check NFT is the same + ).resolves.toBe(true); + expect( + getOriginalAssetAptos( + aptosClient, + APTOS_NFT_BRIDGE_ADDRESS, + foreignAssetTokenId + ) + ).resolves.toStrictEqual({ + isWrapped: false, + chainId: CHAIN_ID_APTOS, + assetAddress: tokenAddressAptos, + }); }); test("Transfer Solana SPL to Aptos", async () => { @@ -359,7 +399,9 @@ describe("Aptos NFT SDK tests", () => { new Uint8Array(solanaTransferVaaParsed.tokenAddress) ); assertIsNotNull(tokenData); - expect(tokenData.collectionName).toBe("Wormhole Bridged Solana-NFT"); // this will change if SPL cache is deprecated in favor of separate collections + expect(tokenData.token_data_id.collection).toBe( + "Wormhole Bridged Solana-NFT" // this will change if SPL cache is deprecated in favor of separate collections + ); // check if token is in user's account const events = (await aptosClient.getEventsByEventHandle( diff --git a/sdk/js/src/nft_bridge/getForeignAsset.ts b/sdk/js/src/nft_bridge/getForeignAsset.ts index a71e20011..5cf1e5f50 100644 --- a/sdk/js/src/nft_bridge/getForeignAsset.ts +++ b/sdk/js/src/nft_bridge/getForeignAsset.ts @@ -1,17 +1,12 @@ import { BN } from "@project-serum/anchor"; import { PublicKeyInitData } from "@solana/web3.js"; import { LCDClient } from "@terra-money/terra.js"; -import { AptosClient, HexString } from "aptos"; +import { AptosClient, HexString, TokenTypes } from "aptos"; import { ethers } from "ethers"; import { isBytes } from "ethers/lib/utils"; import { fromUint8Array } from "js-base64"; import { CHAIN_ID_SOLANA } from ".."; -import { - CreateTokenDataEvent, - NftBridgeState, - RawTokenId, - TokenId, -} from "../aptos/types"; +import { CreateTokenDataEvent, NftBridgeState } from "../aptos/types"; import { NFTBridge__factory } from "../ethers-contracts"; import { deriveWrappedMintKey } from "../solana/nftBridge"; import { @@ -116,14 +111,15 @@ export const getForeignAssetSol = getForeignAssetSolana; /** * Get the token id of a foreign asset on Aptos. Tokens on Aptos are identified * by the tuple (creatorAddress, collectionName, tokenName, propertyVersion), - * which this method returns. + * which this method returns as an object. * * This method also supports native assets, in which case it expects the token * hash (which can be obtained from `deriveTokenHashFromTokenId`). * @param client * @param nftBridgeAddress * @param originChain - * @param originAddress External address of token on origin chain, or token hash if origin chain is Aptos + * @param originAddress External address of token on origin chain, or token hash + * if origin chain is Aptos * @returns Unique token identifier on Aptos */ export async function getForeignAssetAptos( @@ -131,7 +127,7 @@ export async function getForeignAssetAptos( nftBridgeAddress: string, originChain: ChainId | ChainName, originAddress: Uint8Array -): Promise { +): Promise { const originChainId = coalesceChainId(originChain); if (originChainId === CHAIN_ID_APTOS) { const state = ( @@ -141,19 +137,17 @@ export async function getForeignAssetAptos( ) ).data as NftBridgeState; const handle = state.native_infos.handle; - const value = (await client.getTableItem(handle, { - key_type: `${nftBridgeAddress}::token_hash::TokenHash`, - value_type: `0x3::token::TokenId`, - key: { - hash: HexString.fromUint8Array(originAddress).hex(), - }, - })) as RawTokenId; - return { - creatorAddress: value.token_data_id.creator, - collectionName: value.token_data_id.collection, - tokenName: value.token_data_id.name, - propertyVersion: Number(value.property_version), - }; + const { token_data_id, property_version } = (await client.getTableItem( + handle, + { + key_type: `${nftBridgeAddress}::token_hash::TokenHash`, + value_type: `0x3::token::TokenId`, + key: { + hash: HexString.fromUint8Array(originAddress).hex(), + }, + } + )) as TokenTypes.TokenId & { __headers: unknown }; + return { token_data_id, property_version }; } const creatorAddress = await deriveResourceAccountAddress( @@ -165,19 +159,18 @@ export async function getForeignAssetAptos( throw new Error("Could not derive creator account address"); } + // Each creator account should contain a single collection and a single token + // creation event. The latter contains the token id that we're looking for. const event = ( await client.getEventsByEventHandle( creatorAddress, "0x3::token::Collections", "create_token_data_events", - { limit: 1 } // there should only ever be one event per resource account + { limit: 1 } ) )[0] as CreateTokenDataEvent; - const tokenData = event.data.id; return { - creatorAddress: tokenData.creator, - collectionName: tokenData.collection, - tokenName: tokenData.name, - propertyVersion: 0, + token_data_id: event.data.id, + property_version: "0", }; } diff --git a/sdk/js/src/nft_bridge/getIsWrappedAsset.ts b/sdk/js/src/nft_bridge/getIsWrappedAsset.ts index 428203b39..4c4848a95 100644 --- a/sdk/js/src/nft_bridge/getIsWrappedAsset.ts +++ b/sdk/js/src/nft_bridge/getIsWrappedAsset.ts @@ -56,8 +56,11 @@ export async function getIsWrappedAssetAptos( `${nftBridgeAddress}::state::OriginInfo` ); return true; - } catch (e) { - if (e instanceof Types.ApiError && e.status === 404) { + } catch (e: any) { + if ( + (e instanceof Types.ApiError || e.errorCode === "resource_not_found") && + e.status === 404 + ) { return false; } diff --git a/sdk/js/src/nft_bridge/getOriginalAsset.ts b/sdk/js/src/nft_bridge/getOriginalAsset.ts index a61686da3..2cd049abd 100644 --- a/sdk/js/src/nft_bridge/getOriginalAsset.ts +++ b/sdk/js/src/nft_bridge/getOriginalAsset.ts @@ -5,7 +5,7 @@ import { PublicKeyInitData, } from "@solana/web3.js"; import { LCDClient } from "@terra-money/terra.js"; -import { AptosClient, Types } from "aptos"; +import { AptosClient, TokenTypes, Types } from "aptos"; import { BigNumber, ethers } from "ethers"; import { arrayify, zeroPad } from "ethers/lib/utils"; import { WormholeWrappedInfo } from ".."; @@ -20,6 +20,7 @@ import { CHAIN_ID_APTOS, CHAIN_ID_SOLANA, coalesceChainId, + deriveCollectionHashFromTokenId, hex, } from "../utils"; import { getIsWrappedAssetEth } from "./getIsWrappedAsset"; @@ -182,16 +183,26 @@ export async function getOriginalAssetTerra( }; } -// TODO(aki): should this also return tokenId? +/** + * Given a token ID, returns the original asset chain and address. If this is a + * native asset, the asset address will be the collection hash. + * @param client + * @param nftBridgeAddress + * @param tokenId An object containing creator address, collection name, token + * name, and property version, which together uniquely identify a token on + * Aptos. For wrapped assets, property version will be 0. + * @returns Object containing origin chain and Wormhole compatible 32-byte asset + * address. + */ export async function getOriginalAssetAptos( client: AptosClient, nftBridgeAddress: string, - creatorAddress: string + tokenId: TokenTypes.TokenId ): Promise { try { const originInfo = ( await client.getAccountResource( - creatorAddress, + tokenId.token_data_id.creator, `${nftBridgeAddress}::state::OriginInfo` ) ).data as OriginInfo; @@ -204,15 +215,20 @@ export async function getOriginalAssetAptos( hex(originInfo.token_address.external_address) ), }; - } catch (e) { - if (e instanceof Types.ApiError && e.status === 404) { - return { - isWrapped: false, - chainId: CHAIN_ID_APTOS, - assetAddress: new Uint8Array(hex(creatorAddress)), - }; + } catch (e: any) { + if ( + !( + (e instanceof Types.ApiError || e.errorCode === "resource_not_found") && + e.status === 404 + ) + ) { + throw e; } - - throw e; } + + return { + isWrapped: false, + chainId: CHAIN_ID_APTOS, + assetAddress: await deriveCollectionHashFromTokenId(tokenId), + }; } diff --git a/sdk/js/src/utils/aptos.ts b/sdk/js/src/utils/aptos.ts index 57f137f43..096036839 100644 --- a/sdk/js/src/utils/aptos.ts +++ b/sdk/js/src/utils/aptos.ts @@ -1,7 +1,15 @@ -import { AptosAccount, AptosClient, BCS, TxnBuilderTypes, Types } from "aptos"; +import { + AptosAccount, + AptosClient, + BCS, + HexString, + TokenTypes, + TxnBuilderTypes, + Types, +} from "aptos"; import { hexZeroPad } from "ethers/lib/utils"; import { sha3_256 } from "js-sha3"; -import { TokenBridgeState, TokenId } from "../aptos/types"; +import { NftBridgeState, TokenBridgeState } from "../aptos/types"; import { ChainId, ChainName, @@ -248,7 +256,7 @@ export const coalesceModuleAddress = (str: string): string => { /** * The NFT bridge creates resource accounts, which in turn create a collection - * and mint a singl token for each transferred NFT. This method derives the + * and mint a single token for each transferred NFT. This method derives the * address of that resource account from the given origin chain and address. * @param nftBridgeAddress * @param originChain @@ -276,28 +284,82 @@ export const deriveResourceAccountAddress = async ( }; /** - * Native tokens in Aptos are represented by a single token hash that is - * derived from creator address, collection name, token name, and property - * version, ensuring that it is unique. This method derives that token hash. + * Get a hash that uniquely identifies a collection on Aptos. + * @param tokenId + * @returns Collection hash + */ +export const deriveCollectionHashFromTokenId = async ( + tokenId: TokenTypes.TokenId +): Promise => { + const inputs = Buffer.concat([ + BCS.bcsToBytes( + TxnBuilderTypes.AccountAddress.fromHex(tokenId.token_data_id.creator) + ), + Buffer.from(sha3_256(tokenId.token_data_id.collection), "hex"), + ]); + return new Uint8Array(Buffer.from(sha3_256(inputs), "hex")); +}; + +/** + * Get a hash that uniquely identifies a token on Aptos. + * + * Native tokens in Aptos are uniquely identified by a hash of creator address, + * collection name, token name, and property version. This hash is converted to + * a bigint in the `tokenId` field in NFT transfer VAAs. * @param tokenId * @returns Token hash identifying the token */ export const deriveTokenHashFromTokenId = async ( - tokenId: TokenId + tokenId: TokenTypes.TokenId ): Promise => { const propertyVersion = Buffer.alloc(8); - propertyVersion.writeBigUInt64BE(BigInt(tokenId.propertyVersion)); + propertyVersion.writeBigUInt64BE(BigInt(tokenId.property_version)); const inputs = Buffer.concat([ - BCS.bcsToBytes( - TxnBuilderTypes.AccountAddress.fromHex(tokenId.creatorAddress) - ), - Buffer.from(sha3_256(tokenId.collectionName), "hex"), - Buffer.from(sha3_256(tokenId.tokenName), "hex"), + Buffer.from(tokenId.token_data_id.creator, "hex"), + Buffer.from(sha3_256(tokenId.token_data_id.collection), "hex"), + Buffer.from(sha3_256(tokenId.token_data_id.name), "hex"), propertyVersion, ]); return new Uint8Array(Buffer.from(sha3_256(inputs), "hex")); }; +/** + * Get creator address, collection name, token name, and property version from + * a token hash. Note that this method is meant to be used for native tokens + * that have already been registered in the NFT bridge. + * + * The token hash is stored in the `tokenId` field of NFT transfer VAAs and + * is calculated by the operations in `deriveTokenHashFromTokenId`. + * @param client + * @param nftBridgeAddress + * @param tokenHash Token hash + * @returns Token ID + */ +export const getTokenIdFromTokenHash = async ( + client: AptosClient, + nftBridgeAddress: string, + tokenHash: Uint8Array +): Promise => { + const state = ( + await client.getAccountResource( + nftBridgeAddress, + `${nftBridgeAddress}::state::State` + ) + ).data as NftBridgeState; + const handle = state.native_infos.handle; + const { token_data_id, property_version } = (await client.getTableItem( + handle, + { + key_type: `${nftBridgeAddress}::token_hash::TokenHash`, + value_type: `0x3::token::TokenId`, + key: { + hash: HexString.fromUint8Array(tokenHash).hex(), + }, + } + )) as TokenTypes.TokenId & { __headers: unknown }; + return { token_data_id, property_version }; +}; + /** * Simulates given raw transaction and either returns the resulting transaction that was submitted * to the mempool, or throws if it fails.