Skip to content

Commit

Permalink
[PSDK-483] Payable Contract Invocations (#231)
Browse files Browse the repository at this point in the history
  • Loading branch information
John-peterson-coinbase authored Sep 11, 2024
1 parent fd31193 commit 678201a
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Add `Coinbase.configure` method to allow for configuration of the SDK and marked constructor as deprecated.
- Return correlation ID from APIError response
- Add optional fields to `CreateContractInvocationOptions` to set amount for payable contract method invocations

## [0.4.0] - 2024-09-06

Expand Down
58 changes: 44 additions & 14 deletions src/coinbase/address/wallet_address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,13 @@ export class WalletAddress extends Address {
* @param options.abi - The ABI of the contract.
* @param options.args - The arguments to pass to the contract method invocation.
* The keys should be the argument names and the values should be the argument values.
* @param options.amount - The amount of the asset to send to a payable contract method.
* @param options.assetId - The ID of the asset to send to a payable contract method.
* The asset must be a denomination of the native asset. (Ex. "wei", "gwei", or "eth").
* @returns The ContractInvocation object.
* @throws {APIError} if the API request to create a contract invocation fails.
* @throws {Error} if the address cannot sign.
* @throws {ArgumentError} if the address does not have sufficient balance.
*/
public async invokeContract(
options: CreateContractInvocationOptions,
Expand All @@ -285,7 +290,27 @@ export class WalletAddress extends Address {
throw new Error("Cannot invoke contract from address without private key loaded");
}

const contractInvocation = await this.createContractInvocation(options);
let atomicAmount: string | undefined;

if (options.assetId && options.amount) {
const asset = await Asset.fetch(this.getNetworkId(), options.assetId);
const normalizedAmount = new Decimal(options.amount.toString());
const currentBalance = await this.getBalance(options.assetId);
if (currentBalance.lessThan(normalizedAmount)) {
throw new ArgumentError(
`Insufficient funds: ${normalizedAmount} requested, but only ${currentBalance} available`,
);
}
atomicAmount = asset.toAtomicAmount(normalizedAmount).toString();
}

const contractInvocation = await this.createContractInvocation(
options.contractAddress,
options.method,
options.abi!,
options.args,
atomicAmount,
);

if (Coinbase.useServerSigner) {
return contractInvocation;
Expand All @@ -298,28 +323,33 @@ export class WalletAddress extends Address {
}

/**
* Creates a contract invocation model for the specified contract address, method, and arguments.
* The ABI object must be specified if the contract is not a known contract.
* Creates a contract invocation with the given data.
*
* @param amount - The amount of the Asset to send.
* @param fromAsset - The Asset to trade from.
* @param toAsset - The Asset to trade to.
* @returns A promise that resolves to a Trade object representing the new trade.
* @param contractAddress - The address of the contract the method will be invoked on.
* @param method - The method to invoke on the contract.
* @param abi - The ABI of the contract.
* @param args - The arguments to pass to the contract method invocation.
* The keys should be the argument names and the values should be the argument values.
* @param atomicAmount - The atomic amount of the native asset to send to a payable contract method.
* @returns The ContractInvocation object.
* @throws {APIError} if the API request to create a contract invocation fails.
*/
private async createContractInvocation({
abi,
args,
contractAddress,
method,
}: CreateContractInvocationOptions): Promise<ContractInvocation> {
private async createContractInvocation(
contractAddress: string,
method: string,
abi: object,
args: object,
atomicAmount?: string,
): Promise<ContractInvocation> {
const resp = await Coinbase.apiClients.contractInvocation!.createContractInvocation(
this.getWalletId(),
this.getId(),
{
method,
method: method,
abi: JSON.stringify(abi),
contract_address: contractAddress,
args: JSON.stringify(args),
amount: atomicAmount,
},
);

Expand Down
15 changes: 12 additions & 3 deletions src/coinbase/contract_invocation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Decimal } from "decimal.js";
import { TransactionStatus } from "./types";
import { Transaction } from "./transaction";
import { Coinbase } from "./coinbase";
Expand All @@ -7,9 +8,8 @@ import { delay } from "./utils";
import { TimeoutError } from "./errors";

/**
* A representation of a ContractInvocation, which moves an Amount of an Asset from
* a user-controlled Wallet to another Address. The fee is assumed to be paid
* in the native Asset of the Network.
* A representation of a ContractInvocation, which calls a smart contract method
* onchain. The fee is assumed to be paid in the native Asset of the Network.
*/
export class ContractInvocation {
private model: ContractInvocationModel;
Expand Down Expand Up @@ -113,6 +113,15 @@ export class ContractInvocation {
return JSON.parse(this.model.abi);
}

/**
* Returns the amount of the native asset sent to a payable contract method, if applicable.
*
* @returns The amount in atomic units of the native asset.
*/
public getAmount(): Decimal {
return new Decimal(this.model.amount);
}

/**
* Returns the Transaction Hash of the ContractInvocation.
*
Expand Down
2 changes: 2 additions & 0 deletions src/coinbase/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,8 @@ export type CreateContractInvocationOptions = {
abi?: object;
method: string;
args: object;
amount?: Amount;
assetId?: string;
};

/**
Expand Down
3 changes: 3 additions & 0 deletions src/coinbase/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,9 @@ export class Wallet {
* @param options.abi - The ABI of the contract.
* @param options.args - The arguments to pass to the contract method invocation.
* The keys should be the argument names and the values should be the argument values.
* @param options.amount - The amount of the asset to send to a payable contract method.
* @param options.assetId - The ID of the asset to send to a payable contract method.
* The asset must be a denomination of the native asset. (Ex. "wei", "gwei", or "eth").
* @returns The ContractInvocation object.
* @throws {APIError} if the API request to create a contract invocation fails.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/tests/contract_invocation_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ describe("Contract Invocation Class", () => {
});
});

describe("#getAmount", () => {
it("returns the amount", () => {
expect(contractInvocation.getAmount()).toEqual(new Decimal(0));
});
});

describe("#getTransactionHash", () => {
describe("when the transaction has a hash", () => {
let transactionHash = "0xtransactionHash";
Expand Down
2 changes: 1 addition & 1 deletion src/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,14 +254,14 @@ export const VALID_CONTRACT_INVOCATION_MODEL: ContractInvocationModel = {
method: "mint",
args: JSON.stringify(MINT_NFT_ARGS),
abi: JSON.stringify(MINT_NFT_ABI),
amount: "0",
transaction: {
network_id: Coinbase.networks.BaseSepolia,
from_address_id: "0xdeadbeef",
unsigned_payload:
"7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e6365223a22307830222c22746f223a22307861383261623835303466646562326461646161336234663037356539363762626533353036356239222c22676173223a22307865623338222c226761735072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a2230786634323430222c226d6178466565506572476173223a2230786634333638222c2276616c7565223a22307830222c22696e707574223a223078366136323738343230303030303030303030303030303030303030303030303034373564343164653761383132393862613236333138343939363830306362636161643733633062222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a22307830222c2273223a22307830222c2279506172697479223a22307830222c2268617368223a22307865333131636632303063643237326639313566656433323165663065376431653965353362393761346166623737336638653935646431343630653665326163227d",
status: TransactionStatusEnum.Pending,
},
amount: amount,
};

export const VALID_SIGNED_CONTRACT_INVOCATION_MODEL: ContractInvocationModel = {
Expand Down
88 changes: 88 additions & 0 deletions src/tests/wallet_address_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,94 @@ describe("WalletAddress", () => {
});
});

describe("when it is successful invoking a payable contract method", () => {
let contractInvocation;
let amount = new Decimal("1000");
let balanceResponse = { amount: "5000000", asset: { asset_id: "eth", decimals: 18 } };

beforeEach(async () => {
Coinbase.apiClients.contractInvocation!.createContractInvocation = mockReturnValue({
...VALID_CONTRACT_INVOCATION_MODEL,
address_id: walletAddress.getId(),
wallet_id: walletAddress.getWalletId(),
amount,
});

Coinbase.apiClients.contractInvocation!.broadcastContractInvocation = mockReturnValue({
...VALID_SIGNED_CONTRACT_INVOCATION_MODEL,
address_id: walletAddress.getId(),
wallet_id: walletAddress.getWalletId(),
amount,
});

Coinbase.apiClients.externalAddress!.getExternalAddressBalance =
mockReturnValue(balanceResponse);

contractInvocation = await walletAddress.invokeContract({
abi: MINT_NFT_ABI,
args: MINT_NFT_ARGS,
method: VALID_CONTRACT_INVOCATION_MODEL.method,
contractAddress: VALID_CONTRACT_INVOCATION_MODEL.contract_address,
amount,
assetId: Coinbase.assets.Wei,
});
});

it("returns a contract invocation", async () => {
expect(contractInvocation).toBeInstanceOf(ContractInvocation);
expect(contractInvocation.getId()).toBe(
VALID_CONTRACT_INVOCATION_MODEL.contract_invocation_id,
);
expect(contractInvocation.getAmount().toString()).toBe(amount.toString());
});

it("creates the contract invocation", async () => {
expect(
Coinbase.apiClients.contractInvocation!.createContractInvocation,
).toHaveBeenCalledWith(walletAddress.getWalletId(), walletAddress.getId(), {
abi: VALID_CONTRACT_INVOCATION_MODEL.abi,
args: VALID_CONTRACT_INVOCATION_MODEL.args,
method: VALID_CONTRACT_INVOCATION_MODEL.method,
contract_address: VALID_CONTRACT_INVOCATION_MODEL.contract_address,
amount: amount.toString(),
});
expect(
Coinbase.apiClients.contractInvocation!.createContractInvocation,
).toHaveBeenCalledTimes(1);
});

it("broadcasts the contract invocation", async () => {
expect(
Coinbase.apiClients.contractInvocation!.broadcastContractInvocation,
).toHaveBeenCalledWith(
walletAddress.getWalletId(),
walletAddress.getId(),
VALID_CONTRACT_INVOCATION_MODEL.contract_invocation_id,
{
signed_payload: expectedSignedPayload,
},
);

expect(
Coinbase.apiClients.contractInvocation!.broadcastContractInvocation,
).toHaveBeenCalledTimes(1);
});

it("checks for sufficient balance", async () => {
expect(
Coinbase.apiClients.externalAddress!.getExternalAddressBalance,
).toHaveBeenCalledWith(
walletAddress.getNetworkId(),
walletAddress.getId(),
Coinbase.assets.Eth,
);

expect(
Coinbase.apiClients.externalAddress!.getExternalAddressBalance,
).toHaveBeenCalledTimes(1);
});
});

describe("when no key is loaded", () => {
beforeEach(() => {
walletAddress = new WalletAddress(addressModel);
Expand Down

0 comments on commit 678201a

Please sign in to comment.