From b77b386bf77408d4b1617ba3bc44e5899a65f2e0 Mon Sep 17 00:00:00 2001 From: Mohammad Al Faiyaz Date: Tue, 12 Dec 2023 23:57:44 -0500 Subject: [PATCH] feat(sdk-core): add function to transfer nfts - Add a new sendNftMethod to make transferring ERC-721/1155 tokens easier. - Expose function to construct call data for an ERC-721/1155 transfers TICKET: WP-1094 --- .../src/abstractEthLikeNewCoins.ts | 37 ++++++ .../baseNFTTransferBuilder.ts | 2 + .../transferBuilderERC1155.ts | 22 ++-- .../transferBuilders/transferBuilderERC721.ts | 14 ++- .../test/v2/fixtures/nfts/nftResponses.ts | 29 +++++ modules/bitgo/test/v2/unit/wallet.ts | 111 +++++++++++++++++- .../sdk-core/src/bitgo/baseCoin/baseCoin.ts | 5 + .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 25 ++++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 11 ++ modules/sdk-core/src/bitgo/wallet/wallet.ts | 88 ++++++++++++++ 10 files changed, 329 insertions(+), 15 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 1351029497..9f6e5f0f89 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -41,6 +41,7 @@ import { VerifyAddressOptions as BaseVerifyAddressOptions, VerifyTransactionOptions, Wallet, + BuildNftTransferDataOptions, } from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, @@ -58,6 +59,8 @@ import { SignTypedDataVersion, TypedDataUtils, TypedMessage } from '@metamask/et import { calculateForwarderV1Address, + ERC1155TransferBuilder, + ERC721TransferBuilder, getCommon, getProxyInitcode, getToken, @@ -2474,4 +2477,38 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { } return Buffer.concat(parts); } + + /** + * Build the data to transfer an ERC-721 or ERC-1155 token to another address + * @param params + */ + buildNftTransferData(params: BuildNftTransferDataOptions): string { + const { tokenContractAddress, recipientAddress, fromAddress } = params; + switch (params.type) { + case 'ERC721': { + const tokenId = params.tokenId; + const contractData = new ERC721TransferBuilder() + .tokenContractAddress(tokenContractAddress) + .to(recipientAddress) + .from(fromAddress) + .tokenId(tokenId) + .build(); + return contractData; + } + + case 'ERC1155': { + const entries = params.entries; + const transferBuilder = new ERC1155TransferBuilder() + .tokenContractAddress(tokenContractAddress) + .to(recipientAddress) + .from(fromAddress); + + for (const entry of entries) { + transferBuilder.entry(parseInt(entry.tokenId, 10), entry.amount); + } + + return transferBuilder.build(); + } + } + } } diff --git a/modules/abstract-eth/src/lib/transferBuilders/baseNFTTransferBuilder.ts b/modules/abstract-eth/src/lib/transferBuilders/baseNFTTransferBuilder.ts index 626d5fa0cb..25227aa54f 100644 --- a/modules/abstract-eth/src/lib/transferBuilders/baseNFTTransferBuilder.ts +++ b/modules/abstract-eth/src/lib/transferBuilders/baseNFTTransferBuilder.ts @@ -14,6 +14,8 @@ export abstract class BaseNFTTransferBuilder { protected _data: string; protected _tokenContractAddress: string; + public abstract build(): string; + protected constructor(serializedData?: string) { if (serializedData === undefined) { // initialize with default values for non mandatory fields diff --git a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC1155.ts b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC1155.ts index f5b3988b00..27de84c5ba 100644 --- a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC1155.ts +++ b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC1155.ts @@ -49,15 +49,7 @@ export class ERC1155TransferBuilder extends BaseNFTTransferBuilder { signAndBuild(): string { const hasMandatoryFields = this.hasMandatoryFields(); if (hasMandatoryFields) { - if (this._tokenIds.length === 1) { - const values = [this._fromAddress, this._toAddress, this._tokenIds[0], this._values[0], this._bytes]; - const contractCall = new ContractCall(ERC1155SafeTransferTypeMethodId, ERC1155SafeTransferTypes, values); - this._data = contractCall.serialize(); - } else { - const values = [this._fromAddress, this._toAddress, this._tokenIds, this._values, this._bytes]; - const contractCall = new ContractCall(ERC1155BatchTransferTypeMethodId, ERC1155BatchTransferTypes, values); - this._data = contractCall.serialize(); - } + this._data = this.build(); return sendMultiSigData( this._tokenContractAddress, @@ -100,4 +92,16 @@ export class ERC1155TransferBuilder extends BaseNFTTransferBuilder { this._data = transferData.data; } } + + build(): string { + if (this._tokenIds.length === 1) { + const values = [this._fromAddress, this._toAddress, this._tokenIds[0], this._values[0], this._bytes]; + const contractCall = new ContractCall(ERC1155SafeTransferTypeMethodId, ERC1155SafeTransferTypes, values); + return contractCall.serialize(); + } else { + const values = [this._fromAddress, this._toAddress, this._tokenIds, this._values, this._bytes]; + const contractCall = new ContractCall(ERC1155BatchTransferTypeMethodId, ERC1155BatchTransferTypes, values); + return contractCall.serialize(); + } + } } diff --git a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts index 5e8149ad63..59fa3c714d 100644 --- a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts +++ b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts @@ -4,7 +4,7 @@ import { hexlify, hexZeroPad } from 'ethers/lib/utils'; import { ContractCall } from '../contractCall'; import { decodeERC721TransferData, isValidEthAddress, sendMultiSigData } from '../utils'; import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder'; -import { ERC721SafeTransferTypeMethodId } from '../walletUtil'; +import { ERC721SafeTransferTypeMethodId, ERC721SafeTransferTypes } from '../walletUtil'; export class ERC721TransferBuilder extends BaseNFTTransferBuilder { private _tokenId: string; @@ -36,12 +36,16 @@ export class ERC721TransferBuilder extends BaseNFTTransferBuilder { return this; } + build(): string { + const types = ERC721SafeTransferTypes; + const values = [this._fromAddress, this._toAddress, this._tokenId, this._bytes]; + const contractCall = new ContractCall(ERC721SafeTransferTypeMethodId, types, values); + return contractCall.serialize(); + } + signAndBuild(): string { if (this.hasMandatoryFields()) { - const types = ['address', 'address', 'uint256', 'bytes']; - const values = [this._fromAddress, this._toAddress, this._tokenId, this._bytes]; - const contractCall = new ContractCall(ERC721SafeTransferTypeMethodId, types, values); - this._data = contractCall.serialize(); + this._data = this.build(); return sendMultiSigData( this._tokenContractAddress, // to diff --git a/modules/bitgo/test/v2/fixtures/nfts/nftResponses.ts b/modules/bitgo/test/v2/fixtures/nfts/nftResponses.ts index abf24c99dd..721e0dfe8d 100644 --- a/modules/bitgo/test/v2/fixtures/nfts/nftResponses.ts +++ b/modules/bitgo/test/v2/fixtures/nfts/nftResponses.ts @@ -59,3 +59,32 @@ export const nftResponse = { }, }, }; + +export const unsupportedNftResponse = { + unsupportedNfts: { + '0xd000f000aa1f8accbd5815056ea32a54777b2fc4': { + type: 'ERC721', + collections: { 4054: '1' }, + metadata: { + name: 'TestToadz', + tokenContractAddress: '0xd000f000aa1f8accbd5815056ea32a54777b2fc4', + }, + }, + '0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b': { + type: 'ERC721', + collections: { + 1186703: '1', + 1186705: '1', + 1294856: '1', + 1294857: '1', + 1294858: '1', + 1294859: '1', + 1294860: '1', + }, + metadata: { + name: 'MultiFaucet NFT', + tokenContractAddress: '0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b', + }, + }, + }, +}; diff --git a/modules/bitgo/test/v2/unit/wallet.ts b/modules/bitgo/test/v2/unit/wallet.ts index 39a42556c4..e41ff81c5d 100644 --- a/modules/bitgo/test/v2/unit/wallet.ts +++ b/modules/bitgo/test/v2/unit/wallet.ts @@ -39,7 +39,7 @@ import { getDefaultWalletKeys, toKeychainObjects } from './coins/utxo/util'; import { Tsol } from '@bitgo/sdk-coin-sol'; import { Teth } from '@bitgo/sdk-coin-eth'; -import { nftResponse } from '../fixtures/nfts/nftResponses'; +import { nftResponse, unsupportedNftResponse } from '../fixtures/nfts/nftResponses'; require('should-sinon'); @@ -3758,6 +3758,9 @@ describe('V2 Wallet:', function () { '5935d59cf660764331bafcade1855fd7', ], multisigType: 'onchain', + coinSpecific: { + baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e', + }, }; ethWallet = new Wallet(bitgo, bitgo.coin('gteth'), walletData); }); @@ -3790,5 +3793,111 @@ describe('V2 Wallet:', function () { transferCount: 0, }); }); + + it('Should throw when attempting to transfer a nft collection not in the wallet', async function () { + const getTokenBalanceNock = nock(bgUrl) + .get(`/api/v2/gteth/wallet/${ethWallet.id()}?allTokens=true`) + .reply(200, { + ...walletData, + ...nftResponse, + }); + + await ethWallet + .sendNft( + { + walletPassphrase: '123abc', + otp: '000000', + }, + { + tokenId: '123', + type: 'ERC721', + tokenContractAddress: '0x123badaddress', + recipientAddress: '0xc15acc27ee41f266877c8f0c61df5bcbc7997df6', + } + ) + .should.be.rejectedWith('Collection not found for token contract 0x123badaddress'); + getTokenBalanceNock.isDone().should.be.true(); + }); + + it('Should throw when attempting to transfer a ERC-721 nft not owned by the wallet', async function () { + const getTokenBalanceNock = nock(bgUrl) + .get(`/api/v2/gteth/wallet/${ethWallet.id()}?allTokens=true`) + .reply(200, { + ...walletData, + ...nftResponse, + ...unsupportedNftResponse, + }); + + await ethWallet + .sendNft( + { + walletPassphrase: '123abc', + otp: '000000', + }, + { + tokenId: '123', + type: 'ERC721', + tokenContractAddress: '0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b', + recipientAddress: '0xc15acc27ee41f266877c8f0c61df5bcbc7997df6', + } + ) + .should.be.rejectedWith( + 'Token 123 not found in collection 0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b or does not have a spendable balance' + ); + getTokenBalanceNock.isDone().should.be.true(); + }); + + it('Should throw when attempting to transfer ERC-1155 tokens when the amount transferred is more than the spendable balance', async function () { + const getTokenBalanceNock = nock(bgUrl) + .get(`/api/v2/gteth/wallet/${ethWallet.id()}?allTokens=true`) + .reply(200, { + ...walletData, + ...{ + unsupportedNfts: { + '0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b': { + type: 'ERC1155', + collections: { + 1186703: '9', + 1186705: '1', + 1294856: '1', + 1294857: '1', + 1294858: '1', + 1294859: '1', + 1294860: '1', + }, + metadata: { + name: 'MultiFaucet NFT', + tokenContractAddress: '0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b', + }, + }, + }, + }, + }); + + await ethWallet + .sendNft( + { + walletPassphrase: '123abc', + otp: '000000', + }, + { + entries: [ + { + amount: 10, + tokenId: '1186703', + }, + { + amount: 1, + tokenId: '1186705', + }, + ], + type: 'ERC1155', + tokenContractAddress: '0xf5de760f2e916647fd766b4ad9e85ff943ce3a2b', + recipientAddress: '0xc15acc27ee41f266877c8f0c61df5bcbc7997df6', + } + ) + .should.be.rejectedWith('Amount 10 exceeds spendable balance of 9 for token 1186703'); + getTokenBalanceNock.isDone().should.be.true(); + }); }); }); diff --git a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts index 466023d1df..7341fa4cd7 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts @@ -38,6 +38,7 @@ import { TransactionPrebuild, VerifyAddressOptions, VerifyTransactionOptions, + BuildNftTransferDataOptions, } from './iBaseCoin'; import { IInscriptionBuilder } from '../inscriptionBuilder'; import { Hash } from 'crypto'; @@ -493,4 +494,8 @@ export abstract class BaseCoin implements IBaseCoin { getHashFunction(): Hash { throw new NotImplementedError('getHashFunction is not supported for this coin'); } + + buildNftTransferData(params: BuildNftTransferDataOptions): string { + throw new NotImplementedError('buildNftTransferData is not supported for this coin'); + } } diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 3d50ffd98f..a20fc7a6c6 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -419,6 +419,24 @@ export interface MessagePrep { export type MPCAlgorithm = 'ecdsa' | 'eddsa'; +export type NFTTransferOptions = { + tokenContractAddress: string; + recipientAddress: string; +} & ( + | { + type: 'ERC721'; + tokenId: string; + } + | { + type: 'ERC1155'; + entries: { tokenId: string; amount: number }[]; + } +); + +export type BuildNftTransferDataOptions = NFTTransferOptions & { + fromAddress: string; +}; + export interface IBaseCoin { type: string; tokenConfig?: BaseTokenConfig; @@ -486,5 +504,12 @@ export interface IBaseCoin { // TODO - this only belongs in eth coins recoverToken(params: RecoverWalletTokenOptions): Promise; getInscriptionBuilder(wallet: Wallet): IInscriptionBuilder; + + /** + * Build the call data for transferring a NFT(s). + * @param params Options specifying the token contract, token recipient & the token(s) to be transferred + * @return the hex string for the contract call. + */ + buildNftTransferData(params: BuildNftTransferDataOptions): string; getHashFunction(): Hash; } diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index e182243619..149321f94f 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -7,6 +7,7 @@ import { TransactionPrebuild, VerificationOptions, TypedData, + NFTTransferOptions, } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { Keychain } from '../keychain'; @@ -642,6 +643,15 @@ export interface WalletEcdsaChallenges { createdBy: string; } +export type SendNFTOptions = Omit< + SendManyOptions, + 'recipients' | 'enableTokens' | 'tokenName' | 'txFormat' | 'receiveAddress' +>; + +export type SendNFTResult = { + pendingApproval: PendingApprovalData; +}; + export interface IWallet { bitgo: BitGoBase; baseCoin: IBaseCoin; @@ -707,6 +717,7 @@ export interface IWallet { submitTransaction(params?: SubmitTransactionOptions): Promise; send(params?: SendOptions): Promise; sendMany(params?: SendManyOptions): Promise; + sendNft(sendOptions: SendNFTOptions, sendNftOptions: Omit): Promise; recoverToken(params?: RecoverTokenOptions): Promise; getFirstPendingTransaction(params?: Record): Promise; changeFee(params?: ChangeFeeOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index 0d5ae4ca8d..5735f48c5a 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -7,6 +7,7 @@ import * as _ from 'lodash'; import * as common from '../../common'; import { IBaseCoin, + NFTTransferOptions, SignedMessage, SignedTransaction, SignedTransactionRequest, @@ -69,6 +70,8 @@ import { RemovePolicyRuleOptions, RemoveUserOptions, SendManyOptions, + SendNFTOptions, + SendNFTResult, SendOptions, ShareWalletOptions, SimulateWebhookOptions, @@ -2099,6 +2102,91 @@ export class Wallet implements IWallet { return this.sendMany(sendManyOptions); } + /** + * Send an ERC-721 NFT or ERC-1155 NFT(s). + * + * This function constructs the appropriate call data for an ERC-721/1155 token transfer, + * and calls the token contract with the data, and amount 0. This transaction will always produce + * a pending approval. + * + * @param sendOptions Options to specify how the transaction should be sent. + * @param sendNftOptions Options to specify the NFT(s) to be sent. + * + * @return A pending approval for the transaction. + */ + async sendNft(sendOptions: SendNFTOptions, sendNftOptions: NFTTransferOptions): Promise { + const nftCollections = await this.getNftBalances(); + const { tokenContractAddress, recipientAddress, type } = sendNftOptions; + + const nftBalance = nftCollections.find((c) => c.metadata.tokenContractAddress === tokenContractAddress); + if (!nftBalance) { + throw new Error(`Collection not found for token contract ${tokenContractAddress}`); + } + + if (!this.baseCoin.isValidAddress(recipientAddress)) { + throw new Error(`Invalid recipient address ${recipientAddress}`); + } + const baseAddress = this.coinSpecific()?.baseAddress; + if (!baseAddress) { + throw new Error('Missing base address for wallet'); + } + + if (nftBalance.type !== type) { + throw new Error(`Specified NFT type ${type} does not match collection type ${nftBalance.type}`); + } + + switch (sendNftOptions.type) { + case 'ERC721': { + if (!nftBalance.collections[sendNftOptions.tokenId]) { + throw new Error( + `Token ${sendNftOptions.tokenId} not found in collection ${tokenContractAddress} or does not have a spendable balance` + ); + } + + const data = this.baseCoin.buildNftTransferData({ ...sendNftOptions, fromAddress: baseAddress }); + return this.sendMany({ + ...sendOptions, + recipients: [ + { + address: sendNftOptions.tokenContractAddress, + amount: '0', + data: data, + }, + ], + }); + } + case 'ERC1155': { + const entries = sendNftOptions.entries; + for (const entry of entries) { + if (!nftBalance.collections[entry.tokenId]) { + throw new Error( + `Token ${entry.tokenId} not found in collection ${sendNftOptions.tokenContractAddress} or does not have a spendable balance` + ); + } + if (nftBalance.collections[entry.tokenId] < entry.amount) { + throw new Error( + `Amount ${entry.amount} exceeds spendable balance of ${nftBalance.collections[entry.tokenId]} for token ${ + entry.tokenId + }` + ); + } + } + + const data = this.baseCoin.buildNftTransferData({ ...sendNftOptions, fromAddress: baseAddress }); + return this.sendMany({ + ...sendOptions, + recipients: [ + { + address: sendNftOptions.tokenContractAddress, + amount: '0', + data: data, + }, + ], + }); + } + } + } + /** * Send money to multiple recipients * 1. Gets the user keychain by checking the wallet for a key which has an encrypted prv