diff --git a/CHANGELOG.md b/CHANGELOG.md index e896d954..e3239f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Add `deployNFT` method to `WalletAddress` and `Wallet` to deploy an ERC721, updated `SmartContract` class to support deployment and fetching contract details + ## [0.6.1] - 2024-09-23 ### Added diff --git a/src/client/api.ts b/src/client/api.ts index c46570e4..796be622 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -1596,6 +1596,12 @@ export interface NFTContractOptions { * @memberof NFTContractOptions */ 'symbol': string; + /** + * The base URI for the NFT metadata + * @type {string} + * @memberof NFTContractOptions + */ + 'base_uri': string; } /** * diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index 791f663f..4cbc5bfa 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -16,6 +16,7 @@ import { Destination, StakeOptionsMode, CreateERC20Options, + CreateERC721Options, } from "../types"; import { delay } from "../utils"; import { Wallet as WalletClass } from "../wallet"; @@ -332,7 +333,7 @@ export class WalletAddress extends Address { * @param options.symbol - The symbol of the ERC20 token. * @param options.totalSupply - The total supply of the ERC20 token. * @returns A Promise that resolves to the deployed SmartContract object. - * @throws {Error} If the private key is not loaded when not using server signer. + * @throws {APIError} If the API request to create a smart contract fails. */ public async deployToken(options: CreateERC20Options): Promise { if (!Coinbase.useServerSigner && !this.key) { @@ -351,6 +352,33 @@ export class WalletAddress extends Address { return smartContract; } + /** + * Deploys an ERC721 token contract. + * + * @param options - The options for creating the ERC721 token. + * @param options.name - The name of the ERC721 token. + * @param options.symbol - The symbol of the ERC721 token. + * @param options.baseURI - The base URI of the ERC721 token. + * @returns A Promise that resolves to the deployed SmartContract object. + * @throws {APIError} If the API request to create a smart contract fails. + */ + public async deployNFT(options: CreateERC721Options): Promise { + if (!Coinbase.useServerSigner && !this.key) { + throw new Error("Cannot deploy ERC721 without private key loaded"); + } + + const smartContract = await this.createERC721(options); + + if (Coinbase.useServerSigner) { + return smartContract; + } + + await smartContract.sign(this.getSigner()); + await smartContract.broadcast(); + + return smartContract; + } + /** * Creates an ERC20 token contract. * @@ -378,6 +406,32 @@ export class WalletAddress extends Address { return SmartContract.fromModel(resp?.data); } + /** + * Creates an ERC721 token contract. + * + * @param options - The options for creating the ERC721 token. + * @param options.name - The name of the ERC721 token. + * @param options.symbol - The symbol of the ERC721 token. + * @param options.baseURI - The base URI of the ERC721 token. + * @returns A Promise that resolves to the deployed SmartContract object. + * @throws {APIError} If the private key is not loaded when not using server signer. + */ + private async createERC721(options: CreateERC721Options): Promise { + const resp = await Coinbase.apiClients.smartContract!.createSmartContract( + this.getWalletId(), + this.getId(), + { + type: SmartContractType.Erc721, + options: { + name: options.name, + symbol: options.symbol, + base_uri: options.baseURI, + }, + }, + ); + return SmartContract.fromModel(resp?.data); + } + /** * Creates a contract invocation with the given data. * diff --git a/src/coinbase/smart_contract.ts b/src/coinbase/smart_contract.ts index 68afab96..1f943fa0 100644 --- a/src/coinbase/smart_contract.ts +++ b/src/coinbase/smart_contract.ts @@ -3,6 +3,8 @@ import { DeploySmartContractRequest, SmartContract as SmartContractModel, SmartContractType as SmartContractTypeModel, + SmartContractOptions as SmartContractOptionsModel, + TokenContractOptions as TokenContractOptionsModel, } from "../client/api"; import { Transaction } from "./transaction"; import { @@ -164,10 +166,19 @@ export class SmartContract { * @returns The Smart Contract Options. */ public getOptions(): SmartContractOptions { - if (this.getType() === SmartContractType.ERC20) { - return this.model.options as TokenContractOptions; + if (this.isERC20(this.getType(), this.model.options)) { + return { + name: this.model.options.name, + symbol: this.model.options.symbol, + totalSupply: this.model.options.total_supply, + } as TokenContractOptions; + } else { + return { + name: this.model.options.name, + symbol: this.model.options.symbol, + baseURI: this.model.options.base_uri, + } as NFTContractOptions; } - return this.model.options as NFTContractOptions; } /** @@ -279,4 +290,18 @@ export class SmartContract { `type: '${this.getType()}'}` ); } + + /** + * Type guard for checking if the smart contract is an ERC20. + * + * @param type - The type of the smart contract. + * @param options - The options of the smart contract. + * @returns True if the smart contract is an ERC20, false otherwise. + */ + private isERC20( + type: SmartContractType, + options: SmartContractOptionsModel, + ): options is TokenContractOptionsModel { + return type === SmartContractType.ERC20; + } } diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index 826edc41..caf11b03 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -920,6 +920,7 @@ export enum SmartContractType { export type NFTContractOptions = { name: string; symbol: string; + baseURI: string; }; /** @@ -976,6 +977,15 @@ export type CreateERC20Options = { totalSupply: Amount; }; +/** + * Options for creating a ERC721. + */ +export type CreateERC721Options = { + name: string; + symbol: string; + baseURI: string; +}; + /** * Options for listing historical balances of an address. */ diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index 4d1a4b32..c1ab5365 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -29,6 +29,7 @@ import { WalletCreateOptions, WalletData, CreateERC20Options, + CreateERC721Options, } from "./types"; import { convertStringToHex, delay, formatDate, getWeekBackDate } from "./utils"; import { StakingOperation } from "./staking_operation"; @@ -783,6 +784,20 @@ export class Wallet { return (await this.getDefaultAddress()).deployToken(options); } + /** + * Deploys an ERC721 token contract. + * + * @param options - The options for creating the ERC721 token. + * @param options.name - The name of the ERC721 token. + * @param options.symbol - The symbol of the ERC721 token. + * @param options.baseURI - The base URI of the ERC721 token. + * @returns A Promise that resolves to the deployed SmartContract object. + * @throws {APIError} If the private key is not loaded when not using server signer. + */ + public async deployNFT(options: CreateERC721Options): Promise { + return (await this.getDefaultAddress()).deployNFT(options); + } + /** * Returns a String representation of the Wallet. * diff --git a/src/examples/solana_list_rewards.ts b/src/examples/solana_list_rewards.ts index 9161e64a..0459fa11 100644 --- a/src/examples/solana_list_rewards.ts +++ b/src/examples/solana_list_rewards.ts @@ -8,11 +8,24 @@ async function listSolanaStakingRewards() { const startTime = new Date(2024, 5).toISOString(); - const rewards = await StakingReward.list(NetworkIdentifier.SolanaMainnet, Coinbase.assets.Sol, ["beefKGBWeSpHzYBHZXwp5So7wdQGX6mu4ZHCsH3uTar"], startTime, new Date().toISOString()); + const rewards = await StakingReward.list( + NetworkIdentifier.SolanaMainnet, + Coinbase.assets.Sol, + ["beefKGBWeSpHzYBHZXwp5So7wdQGX6mu4ZHCsH3uTar"], + startTime, + new Date().toISOString(), + ); console.log(rewards); - const addr = new Address(NetworkIdentifier.SolanaMainnet, "beefKGBWeSpHzYBHZXwp5So7wdQGX6mu4ZHCsH3uTar"); - const balances = await addr.historicalStakingBalances(Coinbase.assets.Sol, startTime, new Date().toISOString()); + const addr = new Address( + NetworkIdentifier.SolanaMainnet, + "beefKGBWeSpHzYBHZXwp5So7wdQGX6mu4ZHCsH3uTar", + ); + const balances = await addr.historicalStakingBalances( + Coinbase.assets.Sol, + startTime, + new Date().toISOString(), + ); console.log(balances); } diff --git a/src/index.ts b/src/index.ts index 52995d59..e8ff2c43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,29 @@ export * from "./coinbase/address"; export * from "./coinbase/address/external_address"; export * from "./coinbase/address/wallet_address"; -export * from './coinbase/api_error'; -export * from './coinbase/asset'; -export * from './coinbase/authenticator'; -export * from './coinbase/balance'; -export * from './coinbase/balance_map'; -export * from './coinbase/coinbase'; -export * from './coinbase/constants'; -export * from './coinbase/contract_event'; -export * from './coinbase/contract_invocation'; -export * from './coinbase/errors'; -export * from './coinbase/faucet_transaction'; -export * from './coinbase/hash'; -export * from './coinbase/historical_balance'; -export * from './coinbase/payload_signature'; -export * from './coinbase/server_signer'; -export * from './coinbase/smart_contract'; -export * from './coinbase/staking_balance'; -export * from './coinbase/staking_operation'; -export * from './coinbase/staking_reward'; -export * from './coinbase/trade'; -export * from './coinbase/transaction'; -export * from './coinbase/transfer'; -export * from './coinbase/types'; -export * from './coinbase/validator'; -export * from './coinbase/wallet'; -export * from './coinbase/webhook'; \ No newline at end of file +export * from "./coinbase/api_error"; +export * from "./coinbase/asset"; +export * from "./coinbase/authenticator"; +export * from "./coinbase/balance"; +export * from "./coinbase/balance_map"; +export * from "./coinbase/coinbase"; +export * from "./coinbase/constants"; +export * from "./coinbase/contract_event"; +export * from "./coinbase/contract_invocation"; +export * from "./coinbase/errors"; +export * from "./coinbase/faucet_transaction"; +export * from "./coinbase/hash"; +export * from "./coinbase/historical_balance"; +export * from "./coinbase/payload_signature"; +export * from "./coinbase/server_signer"; +export * from "./coinbase/smart_contract"; +export * from "./coinbase/staking_balance"; +export * from "./coinbase/staking_operation"; +export * from "./coinbase/staking_reward"; +export * from "./coinbase/trade"; +export * from "./coinbase/transaction"; +export * from "./coinbase/transfer"; +export * from "./coinbase/types"; +export * from "./coinbase/validator"; +export * from "./coinbase/wallet"; +export * from "./coinbase/webhook"; diff --git a/src/tests/address_test.ts b/src/tests/address_test.ts index 3ea78b8a..88af24cd 100644 --- a/src/tests/address_test.ts +++ b/src/tests/address_test.ts @@ -70,7 +70,9 @@ describe("Address", () => { const result = await address.listTransactions({ limit: 2, page: "page" }); expect(result.transactions.length).toEqual(2); expect(result.transactions[0].blockHeight()).toEqual("12345"); - expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledTimes( + 1, + ); expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledWith( address.getNetworkId(), address.getId(), @@ -98,7 +100,9 @@ describe("Address", () => { const result = await address.listTransactions({}); expect(result.transactions.length).toEqual(1); expect(result.transactions[0].blockHeight()).toEqual("12348"); - expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledTimes( + 1, + ); expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledWith( address.getNetworkId(), address.getId(), @@ -116,7 +120,9 @@ describe("Address", () => { }); const result = await address.listTransactions({}); expect(result.transactions.length).toEqual(0); - expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledTimes(1); + expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledTimes( + 1, + ); expect(Coinbase.apiClients.transactionHistory!.listAddressTransactions).toHaveBeenCalledWith( address.getNetworkId(), address.getId(), diff --git a/src/tests/index_test.ts b/src/tests/index_test.ts index 29f73fac..a3902854 100644 --- a/src/tests/index_test.ts +++ b/src/tests/index_test.ts @@ -1,39 +1,38 @@ // test/index.test.ts import * as index from "../index"; -describe('Index file exports', () => { - it('should export all modules correctly', () => { - expect(index).toBeDefined(); - expect(index).toHaveProperty('Address'); - expect(index).toHaveProperty('APIError'); - expect(index).toHaveProperty('Asset'); - expect(index).toHaveProperty('Balance'); - expect(index).toHaveProperty('BalanceMap'); - expect(index).toHaveProperty('Coinbase'); - expect(index).toHaveProperty('ContractEvent'); - expect(index).toHaveProperty('ContractInvocation'); - expect(index).toHaveProperty('ExternalAddress'); - expect(index).toHaveProperty('FaucetTransaction'); - expect(index).toHaveProperty('GWEI_DECIMALS'); - expect(index).toHaveProperty('HistoricalBalance'); - expect(index).toHaveProperty('InvalidAPIKeyFormatError'); - expect(index).toHaveProperty('PayloadSignature'); - expect(index).toHaveProperty('ServerSigner'); - expect(index).toHaveProperty('SmartContract'); - expect(index).toHaveProperty('SponsoredSendStatus'); - expect(index).toHaveProperty('StakeOptionsMode'); - expect(index).toHaveProperty('StakingBalance'); - expect(index).toHaveProperty('StakingOperation'); - expect(index).toHaveProperty('StakingReward'); - expect(index).toHaveProperty('Trade'); - expect(index).toHaveProperty('Transaction'); - expect(index).toHaveProperty('TransactionStatus'); - expect(index).toHaveProperty('Transfer'); - expect(index).toHaveProperty('TransferStatus'); - expect(index).toHaveProperty('Validator'); - expect(index).toHaveProperty('Wallet'); - expect(index).toHaveProperty('WalletAddress'); - expect(index).toHaveProperty('Webhook'); - }); +describe("Index file exports", () => { + it("should export all modules correctly", () => { + expect(index).toBeDefined(); + expect(index).toHaveProperty("Address"); + expect(index).toHaveProperty("APIError"); + expect(index).toHaveProperty("Asset"); + expect(index).toHaveProperty("Balance"); + expect(index).toHaveProperty("BalanceMap"); + expect(index).toHaveProperty("Coinbase"); + expect(index).toHaveProperty("ContractEvent"); + expect(index).toHaveProperty("ContractInvocation"); + expect(index).toHaveProperty("ExternalAddress"); + expect(index).toHaveProperty("FaucetTransaction"); + expect(index).toHaveProperty("GWEI_DECIMALS"); + expect(index).toHaveProperty("HistoricalBalance"); + expect(index).toHaveProperty("InvalidAPIKeyFormatError"); + expect(index).toHaveProperty("PayloadSignature"); + expect(index).toHaveProperty("ServerSigner"); + expect(index).toHaveProperty("SmartContract"); + expect(index).toHaveProperty("SponsoredSendStatus"); + expect(index).toHaveProperty("StakeOptionsMode"); + expect(index).toHaveProperty("StakingBalance"); + expect(index).toHaveProperty("StakingOperation"); + expect(index).toHaveProperty("StakingReward"); + expect(index).toHaveProperty("Trade"); + expect(index).toHaveProperty("Transaction"); + expect(index).toHaveProperty("TransactionStatus"); + expect(index).toHaveProperty("Transfer"); + expect(index).toHaveProperty("TransferStatus"); + expect(index).toHaveProperty("Validator"); + expect(index).toHaveProperty("Wallet"); + expect(index).toHaveProperty("WalletAddress"); + expect(index).toHaveProperty("Webhook"); + }); }); - diff --git a/src/tests/smart_contract_test.ts b/src/tests/smart_contract_test.ts index af2eb33f..617ab61f 100644 --- a/src/tests/smart_contract_test.ts +++ b/src/tests/smart_contract_test.ts @@ -7,6 +7,13 @@ import { VALID_SMART_CONTRACT_ERC20_MODEL, mockReturnRejectedValue, contractEventApiMock, + ERC20_NAME, + ERC20_TOTAL_SUPPLY, + ERC20_SYMBOL, + VALID_SMART_CONTRACT_ERC721_MODEL, + ERC721_NAME, + ERC721_SYMBOL, + ERC721_BASE_URI, } from "./utils"; import { SmartContract } from "../coinbase/smart_contract"; import { ContractEvent } from "../coinbase/contract_event"; @@ -19,16 +26,17 @@ import { AxiosError } from "axios"; import { TimeoutError } from "../coinbase/errors"; describe("SmartContract", () => { - let smartContractModel: SmartContractModel = VALID_SMART_CONTRACT_ERC20_MODEL; - let smartContract: SmartContract = SmartContract.fromModel(smartContractModel); - + let erc20Model: SmartContractModel = VALID_SMART_CONTRACT_ERC20_MODEL; + let erc721Model: SmartContractModel = VALID_SMART_CONTRACT_ERC721_MODEL; + let erc20SmartContract: SmartContract = SmartContract.fromModel(erc20Model); + let erc721SmartContract: SmartContract = SmartContract.fromModel(erc721Model); afterEach(() => { jest.clearAllMocks(); }); describe("Constructor", () => { it("initializes a new SmartContract", () => { - expect(smartContract).toBeInstanceOf(SmartContract); + expect(erc20SmartContract).toBeInstanceOf(SmartContract); }); it("raises an error when the smartContract model is empty", () => { @@ -40,19 +48,23 @@ describe("SmartContract", () => { describe("#getId", () => { it("returns the smart contract ID", () => { - expect(smartContract.getId()).toEqual(VALID_SMART_CONTRACT_ERC20_MODEL.smart_contract_id); + expect(erc20SmartContract.getId()).toEqual( + VALID_SMART_CONTRACT_ERC20_MODEL.smart_contract_id, + ); }); }); describe("#getNetworkId", () => { it("returns the smart contract network ID", () => { - expect(smartContract.getNetworkId()).toEqual(VALID_SMART_CONTRACT_ERC20_MODEL.network_id); + expect(erc20SmartContract.getNetworkId()).toEqual( + VALID_SMART_CONTRACT_ERC20_MODEL.network_id, + ); }); }); describe("#getContractAddress", () => { it("returns the smart contract contract address", () => { - expect(smartContract.getContractAddress()).toEqual( + expect(erc20SmartContract.getContractAddress()).toEqual( VALID_SMART_CONTRACT_ERC20_MODEL.contract_address, ); }); @@ -60,7 +72,7 @@ describe("SmartContract", () => { describe("#getDeployerAddress", () => { it("returns the smart contract deployer address", () => { - expect(smartContract.getDeployerAddress()).toEqual( + expect(erc20SmartContract.getDeployerAddress()).toEqual( VALID_SMART_CONTRACT_ERC20_MODEL.deployer_address, ); }); @@ -68,25 +80,37 @@ describe("SmartContract", () => { describe("#getType", () => { it("returns the smart contract type", () => { - expect(smartContract.getType()).toEqual(VALID_SMART_CONTRACT_ERC20_MODEL.type); + expect(erc20SmartContract.getType()).toEqual(VALID_SMART_CONTRACT_ERC20_MODEL.type); }); }); describe("#getOptions", () => { it("returns the smart contract options", () => { - expect(smartContract.getOptions()).toEqual(VALID_SMART_CONTRACT_ERC20_MODEL.options); + expect(erc20SmartContract.getOptions()).toEqual({ + name: ERC20_NAME, + symbol: ERC20_SYMBOL, + totalSupply: ERC20_TOTAL_SUPPLY.toString(), + }); + }); + + it("returns the smart contract options for ERC721", () => { + expect(erc721SmartContract.getOptions()).toEqual({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }); }); }); describe("#getAbi", () => { it("returns the smart contract ABI", () => { - expect(smartContract.getAbi()).toEqual(JSON.parse(VALID_SMART_CONTRACT_ERC20_MODEL.abi)); + expect(erc20SmartContract.getAbi()).toEqual(JSON.parse(VALID_SMART_CONTRACT_ERC20_MODEL.abi)); }); }); describe("#getTransaction", () => { it("returns the smart contract transaction", () => { - expect(smartContract.getTransaction()).toEqual( + expect(erc20SmartContract.getTransaction()).toEqual( new Transaction(VALID_SMART_CONTRACT_ERC20_MODEL.transaction), ); }); @@ -117,7 +141,7 @@ describe("SmartContract", () => { Coinbase.apiClients.smartContract = smartContractApiMock; // Ensure signed payload is present. - smartContract = SmartContract.fromModel({ + erc20SmartContract = SmartContract.fromModel({ ...VALID_SMART_CONTRACT_ERC20_MODEL, transaction: { ...VALID_SMART_CONTRACT_ERC20_MODEL.transaction!, @@ -139,7 +163,7 @@ describe("SmartContract", () => { }, }); - broadcastedSmartContract = await smartContract.broadcast(); + broadcastedSmartContract = await erc20SmartContract.broadcast(); }); it("returns the broadcasted smart contract", async () => { @@ -151,9 +175,9 @@ describe("SmartContract", () => { it("broadcasts the smart contract", async () => { expect(Coinbase.apiClients.smartContract!.deploySmartContract).toHaveBeenCalledWith( - smartContract.getWalletId(), - smartContract.getDeployerAddress(), - smartContract.getId(), + erc20SmartContract.getWalletId(), + erc20SmartContract.getDeployerAddress(), + erc20SmartContract.getId(), { signed_payload: signedPayload.slice(2), }, @@ -165,11 +189,11 @@ describe("SmartContract", () => { describe("when the transaction is not signed", () => { beforeEach(() => { - smartContract = SmartContract.fromModel(VALID_SMART_CONTRACT_ERC20_MODEL); + erc20SmartContract = SmartContract.fromModel(VALID_SMART_CONTRACT_ERC20_MODEL); }); it("throws an error", async () => { - expect(smartContract.broadcast()).rejects.toThrow( + expect(erc20SmartContract.broadcast()).rejects.toThrow( "Cannot broadcast unsigned SmartContract deployment", ); }); @@ -191,7 +215,7 @@ describe("SmartContract", () => { }); it("throws an error", async () => { - expect(smartContract.broadcast()).rejects.toThrow(APIError); + expect(erc20SmartContract.broadcast()).rejects.toThrow(APIError); }); }); }); @@ -209,7 +233,7 @@ describe("SmartContract", () => { }); it("successfully waits and returns", async () => { - const completedSmartContract = await smartContract.wait(); + const completedSmartContract = await erc20SmartContract.wait(); expect(completedSmartContract).toBeInstanceOf(SmartContract); expect(completedSmartContract.getTransaction().getStatus()).toEqual( TransactionStatus.COMPLETE, @@ -229,7 +253,7 @@ describe("SmartContract", () => { }); it("successfully waits and returns a failed invocation", async () => { - const completedSmartContract = await smartContract.wait(); + const completedSmartContract = await erc20SmartContract.wait(); expect(completedSmartContract).toBeInstanceOf(SmartContract); expect(completedSmartContract.getTransaction().getStatus()).toEqual( TransactionStatus.FAILED, @@ -249,9 +273,9 @@ describe("SmartContract", () => { }); it("throws a timeout error", async () => { - expect(smartContract.wait({ timeoutSeconds: 0.05, intervalSeconds: 0.05 })).rejects.toThrow( - new TimeoutError("SmartContract deployment timed out"), - ); + expect( + erc20SmartContract.wait({ timeoutSeconds: 0.05, intervalSeconds: 0.05 }), + ).rejects.toThrow(new TimeoutError("SmartContract deployment timed out")); }); }); }); @@ -265,18 +289,18 @@ describe("SmartContract", () => { status: TransactionStatus.COMPLETE, }, }); - await smartContract.reload(); - expect(smartContract.getTransaction().getStatus()).toEqual(TransactionStatus.COMPLETE); + await erc20SmartContract.reload(); + expect(erc20SmartContract.getTransaction().getStatus()).toEqual(TransactionStatus.COMPLETE); expect(Coinbase.apiClients.smartContract!.getSmartContract).toHaveBeenCalledTimes(1); }); }); describe("#toString", () => { it("returns the same value as toString", () => { - expect(smartContract.toString()).toEqual( - `SmartContract{id: '${smartContract.getId()}', networkId: '${smartContract.getNetworkId()}', ` + - `contractAddress: '${smartContract.getContractAddress()}', deployerAddress: '${smartContract.getDeployerAddress()}', ` + - `type: '${smartContract.getType()}'}`, + expect(erc20SmartContract.toString()).toEqual( + `SmartContract{id: '${erc20SmartContract.getId()}', networkId: '${erc20SmartContract.getNetworkId()}', ` + + `contractAddress: '${erc20SmartContract.getContractAddress()}', deployerAddress: '${erc20SmartContract.getDeployerAddress()}', ` + + `type: '${erc20SmartContract.getType()}'}`, ); }); }); diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 4bf1b999..e7aae8ff 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -23,6 +23,8 @@ import { FeatureSet, TransactionStatusEnum, ValidatorStatus, + NFTContractOptions as NFTContractOptionsModel, + TokenContractOptions as TokenContractOptionsModel, } from "../client"; import { BASE_PATH } from "../client/base"; import { Coinbase } from "../coinbase/coinbase"; @@ -293,7 +295,32 @@ export const VALID_SMART_CONTRACT_ERC20_MODEL: SmartContractModel = { name: ERC20_NAME, symbol: ERC20_SYMBOL, total_supply: ERC20_TOTAL_SUPPLY.toString(), + } as TokenContractOptionsModel, + abi: JSON.stringify("some-abi"), + transaction: { + network_id: Coinbase.networks.BaseSepolia, + from_address_id: "0xdeadbeef", + unsigned_payload: + "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e6365223a22307830222c22746f223a22307861383261623835303466646562326461646161336234663037356539363762626533353036356239222c22676173223a22307865623338222c226761735072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a2230786634323430222c226d6178466565506572476173223a2230786634333638222c2276616c7565223a22307830222c22696e707574223a223078366136323738343230303030303030303030303030303030303030303030303034373564343164653761383132393862613236333138343939363830306362636161643733633062222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a22307830222c2273223a22307830222c2279506172697479223a22307830222c2268617368223a22307865333131636632303063643237326639313566656433323165663065376431653965353362393761346166623737336638653935646431343630653665326163227d", + status: TransactionStatusEnum.Pending, }, +}; + +export const ERC721_NAME = "Test NFT"; +export const ERC721_SYMBOL = "TEST"; +export const ERC721_BASE_URI = "https://example.com/metadata/"; +export const VALID_SMART_CONTRACT_ERC721_MODEL: SmartContractModel = { + smart_contract_id: "test-smart-contract-1", + network_id: Coinbase.networks.BaseSepolia, + wallet_id: walletId, + contract_address: "0xcontract-address", + deployer_address: "0xdeployer-address", + type: SmartContractType.Erc721, + options: { + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + base_uri: ERC721_BASE_URI, + } as NFTContractOptionsModel, abi: JSON.stringify("some-abi"), transaction: { network_id: Coinbase.networks.BaseSepolia, diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 49e7c6d4..70899276 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -53,6 +53,10 @@ import { ERC20_NAME, ERC20_SYMBOL, ERC20_TOTAL_SUPPLY, + VALID_SMART_CONTRACT_ERC721_MODEL, + ERC721_NAME, + ERC721_SYMBOL, + ERC721_BASE_URI, } from "./utils"; import { Transfer } from "../coinbase/transfer"; import { TransactionStatus } from "../coinbase/types"; @@ -1796,6 +1800,302 @@ describe("WalletAddress", () => { }); }); + describe("#deployNFT", () => { + let key = ethers.Wallet.createRandom(); + let addressModel: AddressModel; + let walletAddress: WalletAddress; + let expectedSignedPayload: string; + + beforeAll(() => { + Coinbase.apiClients.smartContract = smartContractApiMock; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + addressModel = newAddressModel(randomUUID(), randomUUID(), Coinbase.networks.BaseSepolia); + }); + + describe("when not using a server-signer", () => { + beforeEach(async () => { + Coinbase.useServerSigner = false; + + walletAddress = new WalletAddress(addressModel, key as unknown as ethers.Wallet); + + const tx = new Transaction(VALID_SMART_CONTRACT_ERC721_MODEL.transaction); + expectedSignedPayload = await tx.sign(key as unknown as ethers.Wallet); + }); + + describe("when it is successful", () => { + let smartContract; + + beforeEach(async () => { + Coinbase.apiClients.smartContract!.createSmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC721_MODEL, + deployer_address: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.deploySmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC721_MODEL, + address_id: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + smartContract = await walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }); + }); + + it("returns a smart contract", async () => { + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract.getId()).toBe(VALID_SMART_CONTRACT_ERC721_MODEL.smart_contract_id); + }); + + it("creates the smart contract", async () => { + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + type: SmartContractType.Erc721, + options: { + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + base_uri: ERC721_BASE_URI, + }, + }, + ); + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledTimes(1); + }); + + it("broadcasts the smart contract", async () => { + expect(Coinbase.apiClients.smartContract!.deploySmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + VALID_SMART_CONTRACT_ERC721_MODEL.smart_contract_id, + { + signed_payload: expectedSignedPayload, + }, + ); + + expect(Coinbase.apiClients.smartContract!.deploySmartContract).toHaveBeenCalledTimes(1); + }); + }); + + describe("when it is successful deploying a smart contract", () => { + let smartContract; + + beforeEach(async () => { + Coinbase.apiClients.smartContract!.createSmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC721_MODEL, + deployer_address: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.deploySmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC721_MODEL, + deployer_address: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.getSmartContract = mockReturnValue( + VALID_SMART_CONTRACT_ERC721_MODEL, + ); + + smartContract = await walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }); + }); + + it("returns a smart contract", async () => { + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract.getId()).toBe(VALID_SMART_CONTRACT_ERC721_MODEL.smart_contract_id); + }); + + it("creates the smart contract", async () => { + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + type: SmartContractType.Erc721, + options: { + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + base_uri: ERC721_BASE_URI, + }, + }, + ); + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledTimes(1); + }); + + it("broadcasts the smart contract", async () => { + expect(Coinbase.apiClients.smartContract!.deploySmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + VALID_SMART_CONTRACT_ERC721_MODEL.smart_contract_id, + { + signed_payload: expectedSignedPayload, + }, + ); + + expect(Coinbase.apiClients.smartContract!.deploySmartContract).toHaveBeenCalledTimes(1); + }); + }); + + describe("when no key is loaded", () => { + beforeEach(() => { + walletAddress = new WalletAddress(addressModel); + }); + + it("throws an error", async () => { + await expect( + walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }), + ).rejects.toThrow(Error); + }); + }); + + describe("when it fails to create a smart contract", () => { + beforeEach(() => { + Coinbase.apiClients.smartContract!.createSmartContract = mockReturnRejectedValue( + new APIError({ + response: { + status: 400, + data: { + code: "malformed_request", + message: "failed to create smart contract: invalid abi", + }, + }, + } as AxiosError), + ); + }); + + it("throws an error", async () => { + await expect( + walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }), + ).rejects.toThrow(APIError); + }); + }); + + describe("when it fails to broadcast a smart contract", () => { + beforeEach(() => { + Coinbase.apiClients.smartContract!.createSmartContract = mockReturnValue({ + ...VALID_CONTRACT_INVOCATION_MODEL, + address_id: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.deploySmartContract = mockReturnRejectedValue( + new APIError({ + response: { + status: 400, + data: { + code: "invalid_signed_payload", + message: "failed to broadcast smart contract: invalid signed payload", + }, + }, + } as AxiosError), + ); + }); + + it("throws an error", async () => { + await expect( + walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }), + ).rejects.toThrow(APIError); + }); + }); + }); + + describe("when using a server-signer", () => { + let smartContract; + + beforeEach(async () => { + Coinbase.useServerSigner = true; + + walletAddress = new WalletAddress(addressModel); + }); + + describe("when it is successful", () => { + beforeEach(async () => { + Coinbase.apiClients.smartContract!.createSmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC721_MODEL, + address_id: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + smartContract = await walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }); + }); + + it("returns a pending contract invocation", async () => { + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract.getId()).toBe(VALID_SMART_CONTRACT_ERC721_MODEL.smart_contract_id); + expect(smartContract.getTransaction().getStatus()).toBe(TransactionStatus.PENDING); + }); + + it("creates a contract invocation", async () => { + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + type: SmartContractType.Erc721, + options: { + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + base_uri: ERC721_BASE_URI, + }, + }, + ); + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledTimes(1); + }); + }); + + describe("when creating a contract invocation fails", () => { + beforeEach(() => { + Coinbase.apiClients.smartContract!.createSmartContract = mockReturnRejectedValue( + new APIError({ + response: { + status: 400, + data: { + code: "malformed_request", + message: "failed to create contract invocation: invalid abi", + }, + }, + } as AxiosError), + ); + }); + + it("throws an error", async () => { + await expect( + walletAddress.deployNFT({ + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }), + ).rejects.toThrow(APIError); + }); + }); + }); + }); + describe("#createPayloadSignature", () => { let key = ethers.Wallet.createRandom(); let addressModel: AddressModel; diff --git a/src/tests/wallet_test.ts b/src/tests/wallet_test.ts index 7454fb5c..eeadab5e 100644 --- a/src/tests/wallet_test.ts +++ b/src/tests/wallet_test.ts @@ -53,6 +53,10 @@ import { ERC20_NAME, ERC20_SYMBOL, ERC20_TOTAL_SUPPLY, + ERC721_NAME, + ERC721_SYMBOL, + ERC721_BASE_URI, + VALID_SMART_CONTRACT_ERC721_MODEL, } from "./utils"; import { Trade } from "../coinbase/trade"; import { WalletAddress } from "../coinbase/address/wallet_address"; @@ -615,6 +619,33 @@ describe("Wallet Class", () => { }); }); + describe("#deployNFT", () => { + let expectedSmartContract; + let options = { + name: ERC721_NAME, + symbol: ERC721_SYMBOL, + baseURI: ERC721_BASE_URI, + }; + + beforeEach(async () => { + expectedSmartContract = SmartContract.fromModel(VALID_SMART_CONTRACT_ERC721_MODEL); + + (await wallet.getDefaultAddress()).deployNFT = jest + .fn() + .mockResolvedValue(expectedSmartContract); + }); + + it("successfully deploys an ERC721 contract on the default address", async () => { + const smartContract = await wallet.deployNFT(options); + + expect((await wallet.getDefaultAddress()).deployNFT).toHaveBeenCalledTimes(1); + expect((await wallet.getDefaultAddress()).deployNFT).toHaveBeenCalledWith(options); + + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract).toEqual(expectedSmartContract); + }); + }); + describe("#createPayloadSignature", () => { let unsignedPayload = VALID_SIGNED_PAYLOAD_SIGNATURE_MODEL.unsigned_payload; let signature =