Skip to content

Commit

Permalink
Merge pull request #4150 from BitGo/WP-1094/NFT-support
Browse files Browse the repository at this point in the history
feat(sdk-core, sdk-coin-eth): add function to transfer nfts
  • Loading branch information
mohammadalfaiyazbitgo authored Dec 13, 2023
2 parents f9e2358 + b77b386 commit 2302dab
Show file tree
Hide file tree
Showing 10 changed files with 329 additions and 15 deletions.
37 changes: 37 additions & 0 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
VerifyAddressOptions as BaseVerifyAddressOptions,
VerifyTransactionOptions,
Wallet,
BuildNftTransferDataOptions,
} from '@bitgo/sdk-core';
import {
BaseCoin as StaticsBaseCoin,
Expand All @@ -58,6 +59,8 @@ import { SignTypedDataVersion, TypedDataUtils, TypedMessage } from '@metamask/et

import {
calculateForwarderV1Address,
ERC1155TransferBuilder,
ERC721TransferBuilder,
getCommon,
getProxyInitcode,
getToken,
Expand Down Expand Up @@ -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();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions modules/bitgo/test/v2/fixtures/nfts/nftResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},
};
111 changes: 110 additions & 1 deletion modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -3801,6 +3801,9 @@ describe('V2 Wallet:', function () {
'5935d59cf660764331bafcade1855fd7',
],
multisigType: 'onchain',
coinSpecific: {
baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e',
},
};
ethWallet = new Wallet(bitgo, bitgo.coin('gteth'), walletData);
});
Expand Down Expand Up @@ -3833,5 +3836,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();
});
});
});
5 changes: 5 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
TransactionPrebuild,
VerifyAddressOptions,
VerifyTransactionOptions,
BuildNftTransferDataOptions,
} from './iBaseCoin';
import { IInscriptionBuilder } from '../inscriptionBuilder';
import { Hash } from 'crypto';
Expand Down Expand Up @@ -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');
}
}
25 changes: 25 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -486,5 +504,12 @@ export interface IBaseCoin {
// TODO - this only belongs in eth coins
recoverToken(params: RecoverWalletTokenOptions): Promise<RecoverTokenTransaction>;
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;
}
11 changes: 11 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TransactionPrebuild,
VerificationOptions,
TypedData,
NFTTransferOptions,
} from '../baseCoin';
import { BitGoBase } from '../bitgoBase';
import { Keychain } from '../keychain';
Expand Down Expand Up @@ -650,6 +651,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;
Expand Down Expand Up @@ -715,6 +725,7 @@ export interface IWallet {
submitTransaction(params?: SubmitTransactionOptions): Promise<any>;
send(params?: SendOptions): Promise<any>;
sendMany(params?: SendManyOptions): Promise<any>;
sendNft(sendOptions: SendNFTOptions, sendNftOptions: Omit<NFTTransferOptions, 'fromAddress'>): Promise<SendNFTResult>;
recoverToken(params?: RecoverTokenOptions): Promise<any>;
getFirstPendingTransaction(params?: Record<string, never>): Promise<any>;
changeFee(params?: ChangeFeeOptions): Promise<any>;
Expand Down
Loading

0 comments on commit 2302dab

Please sign in to comment.