From 923eaf8b4f9af9c677654f46a5d6d5b555c45609 Mon Sep 17 00:00:00 2001 From: rohan-agarwal-coinbase Date: Wed, 25 Sep 2024 11:04:02 -0400 Subject: [PATCH] Add support for ERC1155 contract deployments (#269) --- CHANGELOG.md | 1 + src/client/api.ts | 18 +- src/coinbase/address/wallet_address.ts | 49 +++++ src/coinbase/smart_contract.ts | 24 ++- src/coinbase/types.ts | 20 +- src/coinbase/wallet.ts | 17 +- src/tests/smart_contract_test.ts | 22 +- src/tests/utils.ts | 22 ++ src/tests/wallet_address_test.ts | 278 +++++++++++++++++++++++++ 9 files changed, 444 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3239f4b..e7788de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Add `deployNFT` method to `WalletAddress` and `Wallet` to deploy an ERC721, updated `SmartContract` class to support deployment and fetching contract details +- Add `deployMultiToken` method to `WalletAddress` and `Wallet` to deploy an ERC1155, updated `SmartContract` class to support deployment and fetching contract details ## [0.6.1] - 2024-09-23 diff --git a/src/client/api.ts b/src/client/api.ts index 796be622..b879471d 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -1578,6 +1578,19 @@ export interface ModelError { */ 'correlation_id'?: string; } +/** + * Options for multi-token contract creation + * @export + * @interface MultiTokenContractOptions + */ +export interface MultiTokenContractOptions { + /** + * The URI for all token metadata + * @type {string} + * @memberof MultiTokenContractOptions + */ + 'uri': string; +} /** * Options for NFT contract creation * @export @@ -2151,7 +2164,7 @@ export interface SmartContractList { * Options for smart contract creation * @export */ -export type SmartContractOptions = NFTContractOptions | TokenContractOptions; +export type SmartContractOptions = MultiTokenContractOptions | NFTContractOptions | TokenContractOptions; /** * The type of the smart contract @@ -2161,7 +2174,8 @@ export type SmartContractOptions = NFTContractOptions | TokenContractOptions; export const SmartContractType = { Erc20: 'erc20', - Erc721: 'erc721' + Erc721: 'erc721', + Erc1155: 'erc1155' } as const; export type SmartContractType = typeof SmartContractType[keyof typeof SmartContractType]; diff --git a/src/coinbase/address/wallet_address.ts b/src/coinbase/address/wallet_address.ts index 4cbc5bfa..022f1c65 100644 --- a/src/coinbase/address/wallet_address.ts +++ b/src/coinbase/address/wallet_address.ts @@ -17,6 +17,7 @@ import { StakeOptionsMode, CreateERC20Options, CreateERC721Options, + CreateERC1155Options, } from "../types"; import { delay } from "../utils"; import { Wallet as WalletClass } from "../wallet"; @@ -379,6 +380,31 @@ export class WalletAddress extends Address { return smartContract; } + /** + * Deploys an ERC1155 multi-token contract. + * + * @param options - The options for creating the ERC1155 token. + * @param options.uri - The URI for all token metadata. + * @returns A Promise that resolves to the deployed SmartContract object. + * @throws {APIError} If the API request to create a smart contract fails. + */ + public async deployMultiToken(options: CreateERC1155Options): Promise { + if (!Coinbase.useServerSigner && !this.key) { + throw new Error("Cannot deploy ERC1155 without private key loaded"); + } + + const smartContract = await this.createERC1155(options); + + if (Coinbase.useServerSigner) { + return smartContract; + } + + await smartContract.sign(this.getSigner()); + await smartContract.broadcast(); + + return smartContract; + } + /** * Creates an ERC20 token contract. * @@ -432,6 +458,29 @@ export class WalletAddress extends Address { return SmartContract.fromModel(resp?.data); } + /** + * Creates an ERC1155 multi-token contract. + * + * @private + * @param {CreateERC1155Options} options - The options for creating the ERC1155 token. + * @param {string} options.uri - The URI for all token metadata. + * @returns {Promise} A Promise that resolves to the created SmartContract. + * @throws {APIError} If the API request to create a smart contract fails. + */ + private async createERC1155(options: CreateERC1155Options): Promise { + const resp = await Coinbase.apiClients.smartContract!.createSmartContract( + this.getWalletId(), + this.getId(), + { + type: SmartContractType.Erc1155, + options: { + uri: options.uri, + }, + }, + ); + 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 1f943fa0..fe124d9d 100644 --- a/src/coinbase/smart_contract.ts +++ b/src/coinbase/smart_contract.ts @@ -5,6 +5,7 @@ import { SmartContractType as SmartContractTypeModel, SmartContractOptions as SmartContractOptionsModel, TokenContractOptions as TokenContractOptionsModel, + NFTContractOptions as NFTContractOptionsModel, } from "../client/api"; import { Transaction } from "./transaction"; import { @@ -12,6 +13,7 @@ import { SmartContractType, NFTContractOptions, TokenContractOptions, + MultiTokenContractOptions, TransactionStatus, } from "./types"; import { Coinbase } from "./coinbase"; @@ -155,6 +157,8 @@ export class SmartContract { return SmartContractType.ERC20; case SmartContractTypeModel.Erc721: return SmartContractType.ERC721; + case SmartContractTypeModel.Erc1155: + return SmartContractType.ERC1155; default: throw new Error(`Unknown smart contract type: ${this.model.type}`); } @@ -172,12 +176,16 @@ export class SmartContract { symbol: this.model.options.symbol, totalSupply: this.model.options.total_supply, } as TokenContractOptions; - } else { + } else if (this.isERC721(this.getType(), this.model.options)) { return { name: this.model.options.name, symbol: this.model.options.symbol, baseURI: this.model.options.base_uri, } as NFTContractOptions; + } else { + return { + uri: this.model.options.uri, + } as MultiTokenContractOptions; } } @@ -304,4 +312,18 @@ export class SmartContract { ): options is TokenContractOptionsModel { return type === SmartContractType.ERC20; } + + /** + * Type guard for checking if the smart contract is an ERC721. + * + * @param type - The type of the smart contract. + * @param options - The options of the smart contract. + * @returns True if the smart contract is an ERC721, false otherwise. + */ + private isERC721( + type: SmartContractType, + options: SmartContractOptionsModel, + ): options is NFTContractOptionsModel { + return type === SmartContractType.ERC721; + } } diff --git a/src/coinbase/types.ts b/src/coinbase/types.ts index caf11b03..226f1021 100644 --- a/src/coinbase/types.ts +++ b/src/coinbase/types.ts @@ -912,6 +912,7 @@ export enum StakeOptionsMode { export enum SmartContractType { ERC20 = "erc20", ERC721 = "erc721", + ERC1155 = "erc1155", } /** @@ -932,10 +933,20 @@ export type TokenContractOptions = { totalSupply: string; }; +/** + * Multi-Token Contract Options + */ +export type MultiTokenContractOptions = { + uri: string; +}; + /** * Smart Contract Options */ -export type SmartContractOptions = NFTContractOptions | TokenContractOptions; +export type SmartContractOptions = + | NFTContractOptions + | TokenContractOptions + | MultiTokenContractOptions; /** * Options for creating a Transfer. @@ -986,6 +997,13 @@ export type CreateERC721Options = { baseURI: string; }; +/** + * Options for creating a ERC1155. + */ +export type CreateERC1155Options = { + uri: string; +}; + /** * Options for listing historical balances of an address. */ diff --git a/src/coinbase/wallet.ts b/src/coinbase/wallet.ts index c1ab5365..f9ab7f5e 100644 --- a/src/coinbase/wallet.ts +++ b/src/coinbase/wallet.ts @@ -30,6 +30,7 @@ import { WalletData, CreateERC20Options, CreateERC721Options, + CreateERC1155Options, } from "./types"; import { convertStringToHex, delay, formatDate, getWeekBackDate } from "./utils"; import { StakingOperation } from "./staking_operation"; @@ -792,12 +793,26 @@ export class Wallet { * @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. + * @throws {Error} If the private key is not loaded when not using server signer. */ public async deployNFT(options: CreateERC721Options): Promise { return (await this.getDefaultAddress()).deployNFT(options); } + /** + * Deploys an ERC1155 token contract. + * + * @param options - The options for creating the ERC1155 token. + * @param options.name - The name of the ERC1155 token. + * @param options.symbol - The symbol of the ERC1155 token. + * @param options.baseURI - The base URI of the ERC1155 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. + */ + public async deployMultiToken(options: CreateERC1155Options): Promise { + return (await this.getDefaultAddress()).deployMultiToken(options); + } + /** * Returns a String representation of the Wallet. * diff --git a/src/tests/smart_contract_test.ts b/src/tests/smart_contract_test.ts index 617ab61f..2df83a61 100644 --- a/src/tests/smart_contract_test.ts +++ b/src/tests/smart_contract_test.ts @@ -14,6 +14,8 @@ import { ERC721_NAME, ERC721_SYMBOL, ERC721_BASE_URI, + VALID_SMART_CONTRACT_ERC1155_MODEL, + ERC1155_URI, } from "./utils"; import { SmartContract } from "../coinbase/smart_contract"; import { ContractEvent } from "../coinbase/contract_event"; @@ -30,6 +32,8 @@ describe("SmartContract", () => { let erc721Model: SmartContractModel = VALID_SMART_CONTRACT_ERC721_MODEL; let erc20SmartContract: SmartContract = SmartContract.fromModel(erc20Model); let erc721SmartContract: SmartContract = SmartContract.fromModel(erc721Model); + let erc1155Model: SmartContractModel = VALID_SMART_CONTRACT_ERC1155_MODEL; + let erc1155SmartContract: SmartContract = SmartContract.fromModel(erc1155Model); afterEach(() => { jest.clearAllMocks(); }); @@ -79,13 +83,21 @@ describe("SmartContract", () => { }); describe("#getType", () => { - it("returns the smart contract type", () => { + it("returns the smart contract type for ERC20", () => { expect(erc20SmartContract.getType()).toEqual(VALID_SMART_CONTRACT_ERC20_MODEL.type); }); + + it("returns the smart contract type for ERC721", () => { + expect(erc721SmartContract.getType()).toEqual(VALID_SMART_CONTRACT_ERC721_MODEL.type); + }); + + it("returns the smart contract type for ERC1155", () => { + expect(erc1155SmartContract.getType()).toEqual(VALID_SMART_CONTRACT_ERC1155_MODEL.type); + }); }); describe("#getOptions", () => { - it("returns the smart contract options", () => { + it("returns the smart contract options for ERC20", () => { expect(erc20SmartContract.getOptions()).toEqual({ name: ERC20_NAME, symbol: ERC20_SYMBOL, @@ -100,6 +112,12 @@ describe("SmartContract", () => { baseURI: ERC721_BASE_URI, }); }); + + it("returns the smart contract options for ERC1155", () => { + expect(erc1155SmartContract.getOptions()).toEqual({ + uri: ERC1155_URI, + }); + }); }); describe("#getAbi", () => { diff --git a/src/tests/utils.ts b/src/tests/utils.ts index e7aae8ff..7b85001e 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -25,6 +25,7 @@ import { ValidatorStatus, NFTContractOptions as NFTContractOptionsModel, TokenContractOptions as TokenContractOptionsModel, + MultiTokenContractOptions as MultiTokenContractOptionsModel, } from "../client"; import { BASE_PATH } from "../client/base"; import { Coinbase } from "../coinbase/coinbase"; @@ -331,6 +332,27 @@ export const VALID_SMART_CONTRACT_ERC721_MODEL: SmartContractModel = { }, }; +export const ERC1155_URI = "https://example.com/{id}.json"; +export const VALID_SMART_CONTRACT_ERC1155_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.Erc1155, + options: { + uri: ERC1155_URI, + } as MultiTokenContractOptionsModel, + abi: JSON.stringify("some-abi"), + transaction: { + network_id: Coinbase.networks.BaseSepolia, + from_address_id: "0xdeadbeef", + unsigned_payload: + "7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e6365223a22307830222c22746f223a22307861383261623835303466646562326461646161336234663037356539363762626533353036356239222c22676173223a22307865623338222c226761735072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a2230786634323430222c226d6178466565506572476173223a2230786634333638222c2276616c7565223a22307830222c22696e707574223a223078366136323738343230303030303030303030303030303030303030303030303034373564343164653761383132393862613236333138343939363830306362636161643733633062222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a22307830222c2273223a22307830222c2279506172697479223a22307830222c2268617368223a22307865333131636632303063643237326639313566656433323165663065376431653965353362393761346166623737336638653935646431343630653665326163227d", + status: TransactionStatusEnum.Pending, + }, +}; + /** * mockStakingOperation returns a mock StakingOperation object with the provided status. * diff --git a/src/tests/wallet_address_test.ts b/src/tests/wallet_address_test.ts index 70899276..3aa97f04 100644 --- a/src/tests/wallet_address_test.ts +++ b/src/tests/wallet_address_test.ts @@ -57,6 +57,8 @@ import { ERC721_NAME, ERC721_SYMBOL, ERC721_BASE_URI, + VALID_SMART_CONTRACT_ERC1155_MODEL, + ERC1155_URI, } from "./utils"; import { Transfer } from "../coinbase/transfer"; import { TransactionStatus } from "../coinbase/types"; @@ -2096,6 +2098,282 @@ describe("WalletAddress", () => { }); }); + describe("#deployMultiToken", () => { + 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_ERC1155_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_ERC1155_MODEL, + deployer_address: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.deploySmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC1155_MODEL, + address_id: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + smartContract = await walletAddress.deployMultiToken({ + uri: ERC1155_URI, + }); + }); + + it("returns a smart contract", async () => { + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract.getId()).toBe(VALID_SMART_CONTRACT_ERC1155_MODEL.smart_contract_id); + }); + + it("creates the smart contract", async () => { + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + type: SmartContractType.Erc1155, + options: { + uri: ERC1155_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_ERC1155_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_ERC1155_MODEL, + deployer_address: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.deploySmartContract = mockReturnValue({ + ...VALID_SMART_CONTRACT_ERC1155_MODEL, + deployer_address: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + Coinbase.apiClients.smartContract!.getSmartContract = mockReturnValue( + VALID_SMART_CONTRACT_ERC1155_MODEL, + ); + + smartContract = await walletAddress.deployMultiToken({ + uri: ERC1155_URI, + }); + }); + + it("returns a smart contract", async () => { + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract.getId()).toBe(VALID_SMART_CONTRACT_ERC1155_MODEL.smart_contract_id); + }); + + it("creates the smart contract", async () => { + expect(Coinbase.apiClients.smartContract!.createSmartContract).toHaveBeenCalledWith( + walletAddress.getWalletId(), + walletAddress.getId(), + { + type: SmartContractType.Erc1155, + options: { + uri: ERC1155_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_ERC1155_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.deployMultiToken({ + uri: ERC1155_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.deployMultiToken({ + uri: ERC1155_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.deployMultiToken({ + uri: ERC1155_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_ERC1155_MODEL, + address_id: walletAddress.getId(), + wallet_id: walletAddress.getWalletId(), + }); + + smartContract = await walletAddress.deployMultiToken({ + uri: ERC1155_URI, + }); + }); + + it("returns a pending contract invocation", async () => { + expect(smartContract).toBeInstanceOf(SmartContract); + expect(smartContract.getId()).toBe(VALID_SMART_CONTRACT_ERC1155_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.Erc1155, + options: { + uri: ERC1155_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.deployMultiToken({ + uri: ERC1155_URI, + }), + ).rejects.toThrow(APIError); + }); + }); + }); + }); + describe("#createPayloadSignature", () => { let key = ethers.Wallet.createRandom(); let addressModel: AddressModel;