Skip to content

Commit

Permalink
[NFT Mirroring] validate ownership (MystenLabs#874)
Browse files Browse the repository at this point in the history
  • Loading branch information
666lcz authored Mar 17, 2022
1 parent 94a30f3 commit 8f9d0bd
Show file tree
Hide file tree
Showing 5 changed files with 192 additions and 92 deletions.
1 change: 0 additions & 1 deletion nft_mirror/oracle_server/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ ORACLE_CONTRACT_PACKAGE = "0x2"
ORACLE_CONTRACT_MODULE = "CrossChainAirdrop"
ORACLE_CONTRACT_ENTRY_FUNCTION = "claim"
ORACLE_CONTRACT_ADMIN_IDENTIFIER = "CrossChainAirdropOracle"
ORACLE_CONTRACT_NFT_IDENTIFIER = "NFT"
81 changes: 69 additions & 12 deletions nft_mirror/oracle_server/src/airdrop/airdropService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { NFTFetcher, NFTInfo } from '../common/nftFetcher';
import { ValidateError } from 'tsoa';
import { Connection } from '../sdk/gateway';

Expand Down Expand Up @@ -36,7 +37,7 @@ interface AirdropClaimInfo {
*
*
* @example {
* "wallet_message": "{\"domain\":{\"chainId\":1,\"name\":\"SuiDrop\",\"version\":\"1\"},\"message\":{\"source_chain\":\"ethereum\",\"source_contract_address\":\"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D\",\"source_token_id\":\"8937\",\"source_owner_address\":\"0x09dbc4a902199bbe7f7ec29b3714731786f2e878\",\"destination_sui_address\":\"0xa5e6dbcf33730ace6ec8b400ff4788c1f150ff7e\"},\"primaryType\":\"ClaimRequest\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"}],\"ClaimRequest\":[{\"name\":\"source_chain\",\"type\":\"string\"},{\"name\":\"source_contract_address\",\"type\":\"string\"},{\"name\":\"source_token_id\",\"type\":\"string\"},{\"name\":\"source_owner_address\",\"type\":\"string\"},{\"name\":\"destination_sui_address\",\"type\":\"string\"}]}}",
* "wallet_message": "{\"domain\":{\"chainId\":1,\"name\":\"SuiDrop\",\"version\":\"1\"},\"message\":{\"source_chain\":\"ethereum\",\"source_contract_address\":\"0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D\",\"source_token_id\":\"0x00000000000000000000000000000000000000000000000000000000000022e9\",\"source_owner_address\":\"0x09dbc4a902199bbe7f7ec29b3714731786f2e878\",\"destination_sui_address\":\"0xa5e6dbcf33730ace6ec8b400ff4788c1f150ff7e\"},\"primaryType\":\"ClaimRequest\",\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"}],\"ClaimRequest\":[{\"name\":\"source_chain\",\"type\":\"string\"},{\"name\":\"source_contract_address\",\"type\":\"string\"},{\"name\":\"source_token_id\",\"type\":\"string\"},{\"name\":\"source_owner_address\",\"type\":\"string\"},{\"name\":\"destination_sui_address\",\"type\":\"string\"}]}}",
* "signature": "abc"
* }
*/
Expand All @@ -58,7 +59,7 @@ export interface AirdropClaimRequest {
* @example {
* "source_chain": "ethereum",
* "source_contract_address": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D",
* "source_token_id": "101",
* "source_token_id": "0x00000000000000000000000000000000000000000000000000000000000022e9",
* "sui_explorer_link": "http:127.0.0.1:8000/BC4CA0EdA7647A8a"
* }
*/
Expand Down Expand Up @@ -89,11 +90,11 @@ export class AirdropService {
const { wallet_message } = claimMessage;
const data = JSON.parse(wallet_message);
const claimInfo = this.parseClaimInfo(data);
// TODO: validate signature and ownership
const nftInfo = await this.validateRequest(claimInfo);
const connection = new Connection(
process.env.SUI_GATEWAY_ENDPOINT as string
);
await this.executeMoveCall(connection, claimInfo);
await this.executeMoveCall(connection, claimInfo, nftInfo);
return {
source_chain: claimInfo.source_chain,
source_contract_address: claimInfo.source_contract_address,
Expand All @@ -114,9 +115,53 @@ export class AirdropService {
);
}

async validateRequest(claimInfo: AirdropClaimInfo): Promise<NFTInfo> {
const {
source_contract_address: contract,
source_token_id: tokenId,
source_owner_address: owner,
} = claimInfo;

const results = await this.getNFTInfo(owner, contract, tokenId);

if (results.length > 1) {
throw new Error(
`More than two tokens share the same contract ` +
`address and token id ${results}`
);
} else if (results.length === 0) {
throw new ValidateError(
{
messages: {
message: 'ownership not found',
value: claimInfo,
},
},
''
);
}
const nftInfo = results[0];

if (nftInfo.claim_status !== 'none') {
throw new ValidateError(
{
messages: {
message: 'The token has been claimed',
value: claimInfo,
},
},
''
);
}

// TODO: validate signature
return nftInfo;
}

async executeMoveCall(
connection: Connection,
claimInfo: AirdropClaimInfo
claimInfo: AirdropClaimInfo,
nftInfo: NFTInfo
): Promise<string> {
const oracleAddress = process.env.ORACLE_ADDRESS as string;
const [gasObjectId, oracleObjectId] = await this.getGasAndOracle(
Expand All @@ -126,20 +171,19 @@ export class AirdropService {
const {
destination_sui_address,
source_contract_address,
source_token_id,
source_token_id: tokenIdHex,
} = claimInfo;

// TODO: remove these hardcoded values in the next PR
const name = 'BoredApeYachtClub';
const tokenUri = 'ipfs://abc';

const tokenId = parseInt(tokenIdHex, 16);
console.log('token id', tokenId);
const { name, media_uri } = nftInfo.token;
const args = [
oracleObjectId,
destination_sui_address,
source_contract_address,
+source_token_id,
tokenId,
name,
tokenUri,
media_uri,
];

const [packageObjectId, module] = this.getPackageAndModule();
Expand All @@ -162,6 +206,19 @@ export class AirdropService {
return created[0].id;
}

async getNFTInfo(
owner: string,
contract: string,
tokenId: string
): Promise<NFTInfo[]> {
const fetcher = new NFTFetcher();
const results = await fetcher.getNFTInfo({
owner,
contractAddresses: [contract],
});
return results.filter((info) => info.token.token_id === tokenId);
}

async getGasAndOracle(
connection: Connection,
oracleAddress: string
Expand Down
1 change: 1 addition & 0 deletions nft_mirror/oracle_server/src/common/erc721.abi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"_name","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_tokenId","type":"uint256"}],"name":"getApproved","outputs":[{"name":"_approved","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_to","type":"address"},{"name":"_tokenId","type":"uint256"}],"name":"approve","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"implementsERC721","outputs":[{"name":"_implementsERC721","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"_totalSupply","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_from","type":"address"},{"name":"_to","type":"address"},{"name":"_tokenId","type":"uint256"}],"name":"transferFrom","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"},{"name":"_index","type":"uint256"}],"name":"tokenOfOwnerByIndex","outputs":[{"name":"_tokenId","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_tokenId","type":"uint256"}],"name":"ownerOf","outputs":[{"name":"_owner","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_tokenId","type":"uint256"}],"name":"tokenMetadata","outputs":[{"name":"_infoUrl","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"_balance","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_owner","type":"address"},{"name":"_tokenId","type":"uint256"},{"name":"_approvedAddress","type":"address"},{"name":"_metadata","type":"string"}],"name":"mint","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"_symbol","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_to","type":"address"},{"name":"_tokenId","type":"uint256"}],"name":"transfer","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"numTokensTotal","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"getOwnerTokens","outputs":[{"name":"_tokenIds","type":"uint256[]"}],"payable":false,"stateMutability":"view","type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"name":"_to","type":"address"},{"indexed":true,"name":"_tokenId","type":"uint256"}],"name":"Mint","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"_from","type":"address"},{"indexed":true,"name":"_to","type":"address"},{"indexed":false,"name":"_tokenId","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"_owner","type":"address"},{"indexed":true,"name":"_approved","type":"address"},{"indexed":false,"name":"_tokenId","type":"uint256"}],"name":"Approval","type":"event"}]
117 changes: 117 additions & 0 deletions nft_mirror/oracle_server/src/common/nftFetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import {
AlchemyWeb3,
createAlchemyWeb3,
GetNftsParams,
Nft as AlchemyNft,
} from '@alch/alchemy-web3';

export interface NFT {
/**
* A descriptive name for the NFT
*/
name: string;

/**
* The address of the collection contract
*/
contract_address: string;

/**
* The token id associated with the source contract address
*/
token_id: string;

/**
* Uri representing the location of the NFT media asset. The uri often
* links to an image. The uri is parsed from the metadata and can be
* standard URLs pointing to images on conventional servers, IPFS, or
* Arweave. The image format can be SVGs, PNGs, JPEGs, etc.
*/
media_uri?: string;
}

export interface NFTInfo {
token: NFT;
claim_status: 'none' | 'claimed';
destination_sui_address?: string;
sui_explorer_link?: string;
}

/**
* Utility class for fetching NFT info
*/
export class NFTFetcher {
/** @internal */ _alchemy: AlchemyWeb3;

constructor() {
this._alchemy = this.getAlchemyAPI();
}

public async getNFTInfoByAddress(address: string): Promise<NFTInfo[]> {
return await this.getNFTInfo({ owner: address });
}

public async getNFTInfo(params: GetNftsParams): Promise<NFTInfo[]> {
const nfts = await this.getNFTsByAddress(params);
return nfts.map((token) => ({
token,
// TODO: check db to see if airdrop has been claimed or not
claim_status: 'none',
}));
}

private async getNFTsByAddress(params: GetNftsParams): Promise<NFT[]> {
const nfts = await this._alchemy.alchemy.getNfts(params);
return await Promise.all(
nfts.ownedNfts.map((a) =>
this.extractFieldsFromAlchemyNFT(a as AlchemyNft)
)
);
}

private async extractFieldsFromAlchemyNFT(
alchemyNft: AlchemyNft
): Promise<NFT> {
// TODO: look into using gateway uri https://docs.alchemy.com/alchemy/guides/nft-api-faq#understanding-nft-metadata
const {
title,
metadata,
id: { tokenId: token_id },
contract: { address: contract_address },
} = alchemyNft;
const name = (await this.getTokenName(contract_address)) ?? title;
return {
contract_address,
name,
token_id,
media_uri: metadata?.image,
};
}

private getAlchemyAPI(): AlchemyWeb3 {
// TODO: implement pagination
const api_key = process.env.ALCHEMY_API_KEY || 'demo';
return createAlchemyWeb3(
`https://eth-mainnet.alchemyapi.io/v2/${api_key}`
);
}

private async getTokenName(tokenContract: string) {
try {
const contractABI = require('./erc721.abi.json');
const contractObject = new (this.getAlchemyAPI().eth.Contract)(
contractABI,
tokenContract
);
return await contractObject.methods.name().call();
} catch (err) {
console.error(
`Encountered error fetching name for contract ${tokenContract}: ${err}`
);
return '';
}
}
}
84 changes: 5 additions & 79 deletions nft_mirror/oracle_server/src/nfts/nftService.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,7 @@
// Copyright (c) 2022, Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import {
AlchemyMethods,
createAlchemyWeb3,
Nft as AlchemyNft,
} from '@alch/alchemy-web3';

interface NFT {
/**
* A descriptive name for the NFT
*/
name: string;

/**
* The address of the collection contract
*/
contract_address: string;

/**
* The token id associated with the source contract address
*/
token_id: string;

/**
* Uri representing the location of the NFT media asset. The uri often
* links to an image. The uri is parsed from the metadata and can be
* standard URLs pointing to images on conventional servers, IPFS, or
* Arweave. The image format can be SVGs, PNGs, JPEGs, etc.
*/
media_uri?: string;
}

interface NFTInfo {
token: NFT;
claim_status: 'none' | 'claimed';
destination_sui_address?: string;
sui_explorer_link?: string;
}
import { NFTFetcher, NFTInfo } from '../common/nftFetcher';

/**
* NFTs owned by the address
Expand Down Expand Up @@ -78,51 +43,12 @@ export class NFTService {
public async get(
source_chain_owner_address: string
): Promise<NFTGetResponse> {
const nftInfo = await this.getNFTInfo(source_chain_owner_address);
return {
results: nftInfo,
};
}

private async getNFTInfo(address: string): Promise<NFTInfo[]> {
const nfts = await this.getNFTsByAddress(address);
return nfts.map((token) => ({
token,
// TODO: check db to see if airdrop has been claimed or not
claim_status: 'none',
}));
}

private async getNFTsByAddress(address: string): Promise<NFT[]> {
const alchemy = this.getAlchemyAPI();
const nfts = await alchemy.getNfts({ owner: address });
console.log(nfts.totalCount);
return nfts.ownedNfts.map((a) =>
this.extractFieldsFromAlchemyNFT(a as AlchemyNft)
const fetcher = new NFTFetcher();
const nftInfo = await fetcher.getNFTInfoByAddress(
source_chain_owner_address
);
}

private extractFieldsFromAlchemyNFT(alchemyNft: AlchemyNft): NFT {
// TODO: look into using gateway uri https://docs.alchemy.com/alchemy/guides/nft-api-faq#understanding-nft-metadata
const {
title: name,
metadata,
id: { tokenId: token_id },
contract: { address: contract_address },
} = alchemyNft;
return {
contract_address,
name,
token_id,
media_uri: metadata?.image,
results: nftInfo,
};
}

private getAlchemyAPI(): AlchemyMethods {
// TODO: implement pagination
const api_key = process.env.ALCHEMY_API_KEY || 'demo';
return createAlchemyWeb3(
`https://eth-mainnet.alchemyapi.io/v2/${api_key}`
).alchemy;
}
}

0 comments on commit 8f9d0bd

Please sign in to comment.