Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sdk-core, sdk-coin-eth): add function to transfer nfts #4150

Merged
merged 1 commit into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -3758,6 +3758,9 @@ describe('V2 Wallet:', function () {
'5935d59cf660764331bafcade1855fd7',
],
multisigType: 'onchain',
coinSpecific: {
baseAddress: '0xdf07117705a9f8dc4c2a78de66b7f1797dba9d4e',
},
};
ethWallet = new Wallet(bitgo, bitgo.coin('gteth'), walletData);
});
Expand Down Expand Up @@ -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();
});
});
});
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 @@ -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;
Expand Down Expand Up @@ -707,6 +717,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
Loading