Skip to content

Commit

Permalink
Merge pull request #56 from coinbase/feat/transaction-model
Browse files Browse the repository at this point in the history
Implementing Transaction model
  • Loading branch information
erdimaden authored Jun 6, 2024
2 parents 2db120b + af0e0bc commit 10f3860
Show file tree
Hide file tree
Showing 7 changed files with 375 additions and 21 deletions.
8 changes: 3 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@

## [0.0.7] - 2024-06-10

### Added

- Added Base Mainnet network support

### Changed
Updated the usage of `Coinbase.networkList` to `Coinbase.networks`

- Updated the usage of `Coinbase.networkList` to `Coinbase.networks`
### Added
- Added Base Mainnet network support

## [0.0.6] - 2024-06-03

Expand Down
173 changes: 173 additions & 0 deletions src/coinbase/tests/transaction_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { ethers } from "ethers";
import { Transaction as TransactionModel } from "../../client/api";
import { Transaction } from "./../transaction";

describe("Transaction", () => {
let fromKey;
let fromAddressId;
let toAddressId;
let unsignedPayload;
let signedPayload;
let transactionHash;
let model;
let broadcastedModel;
let transaction;

beforeEach(() => {
fromKey = ethers.Wallet.createRandom();
fromAddressId = fromKey.address;
toAddressId = "0x4D9E4F3f4D1A8B5F4f7b1F5b5C7b8d6b2B3b1b0b";
unsignedPayload =
"7b2274797065223a22307832222c22636861696e4964223a2230783134613334222c226e6f6e63" +
"65223a22307830222c22746f223a22307834643965346633663464316138623566346637623166" +
"356235633762386436623262336231623062222c22676173223a22307835323038222c22676173" +
"5072696365223a6e756c6c2c226d61785072696f72697479466565506572476173223a223078" +
"3539363832663030222c226d6178466565506572476173223a2230783539363832663030222c22" +
"76616c7565223a2230783536626337356532643633313030303030222c22696e707574223a22" +
"3078222c226163636573734c697374223a5b5d2c2276223a22307830222c2272223a2230783022" +
"2c2273223a22307830222c2279506172697479223a22307830222c2268617368223a2230783664" +
"633334306534643663323633653363396561396135656438646561346332383966613861363966" +
"3031653635393462333732386230386138323335333433227d";
signedPayload =
"02f87683014a34808459682f008459682f00825208944d9e4f3f4d1a8b5f4f7b1f5b5c7b8d6b2b3b1b0b89056bc75e2d6310000080c001a07ae1f4655628ac1b226d60a6243aed786a2d36241ffc0f306159674755f4bd9ca050cd207fdfa6944e2b165775e2ca625b474d1eb40fda0f03f4ca9e286eae3cbe";

transactionHash = "0x6c087c1676e8269dd81e0777244584d0cbfd39b6997b3477242a008fa9349e11";

model = {
status: "pending",
from_address_id: fromAddressId,
unsigned_payload: unsignedPayload,
} as TransactionModel;

broadcastedModel = {
status: "broadcast",
from_address_id: fromAddressId,
unsigned_payload: unsignedPayload,
signed_payload: signedPayload,
transaction_hash: transactionHash,
} as TransactionModel;
transaction = new Transaction(model);
});

describe("constructor", () => {
it("initializes a new Transaction", () => {
expect(transaction).toBeInstanceOf(Transaction);
});

it("should raise an error when initialized with a model of a different type", () => {
expect(() => new Transaction(null!)).toThrow("Invalid model type");
});
});

describe("#unsignedPayload", () => {
it("returns the unsigned payload", () => {
expect(transaction.getUnsignedPayload()).toEqual(unsignedPayload);
});
});

describe("#signedPayload", () => {
it("should return undefined when the transaction has not been broadcast on chain", () => {
expect(transaction.getSignedPayload()).toBeUndefined();
});

it("should return the signed payload when the transaction has been broadcast on chain", () => {
const transaction = new Transaction(broadcastedModel);
expect(transaction.getSignedPayload()).toEqual(signedPayload);
});
});

describe("#transactionHash", () => {
it("should return undefined when the transaction has not been broadcast on chain", () => {
expect(transaction.getTransactionHash()).toBeUndefined();
});

it("should return the transaction hash when the transaction has been broadcast on chain", () => {
const transaction = new Transaction(broadcastedModel);
expect(transaction.getTransactionHash()).toEqual(transactionHash);
});
});

describe("#rawTransaction", () => {
let raw: ethers.Transaction, rawPayload;

beforeEach(() => {
raw = transaction.rawTransaction();
rawPayload = JSON.parse(Buffer.from(unsignedPayload, "hex").toString());
});
it("should return the raw transaction", () => {
expect(raw).toBeInstanceOf(ethers.Transaction);
});

it("should return the correct value", () => {
expect(raw.value).toEqual(BigInt(rawPayload.value));
});

it("should return the chain ID", () => {
expect(raw.chainId).toEqual(BigInt(rawPayload.chainId));
});

it("should return the correct destination address", () => {
expect(raw.to?.toUpperCase()).toEqual(toAddressId.toUpperCase());
});

it("should return the correct nonce", () => {
expect(raw.nonce).toEqual(0);
});

it("should return the correct gas limit", () => {
expect(raw.gasLimit).toEqual(BigInt(rawPayload.gas));
});

it("should return the correct max priority fee per gas", () => {
expect(raw?.maxPriorityFeePerGas).toEqual(BigInt(rawPayload.maxPriorityFeePerGas));
});

it("should return the correct max fee per gas", () => {
expect(raw.maxFeePerGas).toEqual(BigInt(rawPayload.maxFeePerGas));
});

it("should return the correct input", () => {
expect(raw.data).toEqual(rawPayload.input);
});
});

describe("#sign", () => {
let signature: string;

beforeEach(async () => {
signature = await transaction.sign(fromKey);
});

it("should return a string when the transaction is signed", async () => {
expect(typeof signature).toBe("string");
});

it("signs the raw transaction", async () => {
expect(signature).not.toBeNull();
});

it("returns a hex representation of the signed raw transaction", async () => {
expect(signature).not.toBeNull();
expect(signature.length).toBeGreaterThan(0);
});
});

describe("#toString", () => {
it("includes transaction details", () => {
const transaction = new Transaction(broadcastedModel);
expect(transaction.toString()).toContain(transaction.getStatus());
});

it("returns the same value as toString", () => {
const transaction = new Transaction(broadcastedModel);
expect(transaction.toString()).toEqual(
`Transaction { transactionHash: '${transaction.getTransactionHash()}', status: 'broadcast' }`,
);
});

it("should include the transaction hash when the transaction has been broadcast on chain", () => {
const transaction = new Transaction(broadcastedModel);
expect(transaction.toString()).toContain(transaction.getTransactionHash());
});
});
});
25 changes: 25 additions & 0 deletions src/coinbase/tests/wallet_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,31 @@ describe("Wallet Class", () => {
});
});

describe("should change the network ID", () => {
let wallet;
beforeAll(async () => {
Coinbase.apiClients.wallet = walletsApiMock;
Coinbase.apiClients.address = addressesApiMock;
Coinbase.apiClients.wallet!.createWallet = mockReturnValue({
...VALID_WALLET_MODEL,
network_id: Coinbase.networks.BaseMainnet,
server_signer_status: ServerSignerStatus.PENDING,
});
Coinbase.apiClients.wallet!.getWallet = mockReturnValue({
...VALID_WALLET_MODEL,
network_id: Coinbase.networks.BaseMainnet,
server_signer_status: ServerSignerStatus.ACTIVE,
});
Coinbase.apiClients.address!.createAddress = mockReturnValue(newAddressModel(walletId));
wallet = await Wallet.create({
networkId: Coinbase.networks.BaseMainnet,
});
});
it("should return true when the wallet initialized", () => {
expect(wallet.getNetworkId()).toBe(Coinbase.networks.BaseMainnet);
});
});

describe(".saveSeed", () => {
const seed = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
let apiPrivateKey;
Expand Down
135 changes: 135 additions & 0 deletions src/coinbase/transaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { ethers } from "ethers";
import { Transaction as TransactionModel } from "../client/api";
import { TransactionStatus } from "./types";
import { parseUnsignedPayload } from "./utils";

/**
* A representation of an onchain Transaction.
*/
export class Transaction {
private model: TransactionModel;
private raw?: ethers.Transaction;

/**
* Transactions should be constructed via higher level abstractions like Trade or Transfer.
*
* @class
* @param model - The underlying Transaction object.
*/
constructor(model: TransactionModel) {
if (!model) {
throw new Error("Invalid model type");
}
this.model = model;
}

/**
* Returns the Unsigned Payload of the Transaction.
*
* @returns The Unsigned Payload
*/
getUnsignedPayload(): string {
return this.model.unsigned_payload;
}

/**
* Returns the Signed Payload of the Transaction.
*
* @returns The Signed Payload
*/
getSignedPayload(): string | undefined {
return this.model.signed_payload;
}

/**
* Returns the Transaction Hash of the Transaction.
*
* @returns The Transaction Hash
*/
getTransactionHash(): string | undefined {
return this.model.transaction_hash;
}

/**
* Returns the Status of the Transaction.
*
* @returns The Status
*/
getStatus(): string {
return this.model.status;
}

/**
* Returns the From Address ID for the Transaction.
*
* @returns The From Address ID
*/
fromAddressId(): string {
return this.model.from_address_id;
}

/**
* Returns whether the Transaction is in a terminal State.
*
* @returns Whether the Transaction is in a terminal State
*/
isTerminalState(): boolean {
return this.getStatus() in [TransactionStatus.COMPLETE, TransactionStatus.FAILED];
}

/**
* Returns the link to the Transaction on the blockchain explorer.
*
* @returns The link to the Transaction on the blockchain explorer
*/
getTransactionLink(): string {
// TODO: Parameterize this by Network.
return `https://sepolia.basescan.org/tx/${this.getTransactionHash()}`;
}

/**
* Returns the underlying raw transaction.
*
* @throws {InvalidUnsignedPayload} If the Unsigned Payload is invalid.
* @returns The ethers.js Transaction object
*/
rawTransaction(): ethers.Transaction {
if (this.raw) {
return this.raw;
}
const parsedPayload = parseUnsignedPayload(this.getUnsignedPayload());
const transaction = new ethers.Transaction();
transaction.chainId = BigInt(parsedPayload.chainId);
transaction.nonce = BigInt(parsedPayload.nonce);
transaction.maxPriorityFeePerGas = BigInt(parsedPayload.maxPriorityFeePerGas);
transaction.maxFeePerGas = BigInt(parsedPayload.maxFeePerGas);
// TODO: Handle multiple currencies.
transaction.gasLimit = BigInt(parsedPayload.gas);
transaction.to = parsedPayload.to;
transaction.value = BigInt(parsedPayload.value);
transaction.data = parsedPayload.input;

this.raw = transaction;
return this.raw;
}

/**
* Signs the Transaction with the provided key and returns the hex signing payload.
*
* @param key - The key to sign the transaction with
* @returns The hex-encoded signed payload
*/
async sign(key: ethers.Wallet) {
const signedPayload = await key!.signTransaction(this.rawTransaction());
return signedPayload?.slice(2);
}

/**
* Returns a string representation of the Transaction.
*
* @returns A string representation of the Transaction.
*/
toString(): string {
return `Transaction { transactionHash: '${this.getTransactionHash()}', status: '${this.getStatus()}' }`;
}
}
19 changes: 3 additions & 16 deletions src/coinbase/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { TransferStatus } from "./types";
import { Coinbase } from "./coinbase";
import { Transfer as TransferModel } from "../client/api";
import { ethers } from "ethers";
import { InternalError, InvalidUnsignedPayload } from "./errors";
import { InternalError } from "./errors";
import { WEI_PER_ETHER } from "./constants";
import { parseUnsignedPayload } from "./utils";

/**
* A representation of a Transfer, which moves an Amount of an Asset from
Expand Down Expand Up @@ -145,21 +146,7 @@ export class Transfer {

const transaction = new ethers.Transaction();

const rawPayload = this.getUnsignedPayload()
.match(/../g)
?.map(byte => parseInt(byte, 16));
if (!rawPayload) {
throw new InvalidUnsignedPayload("Unable to parse unsigned payload");
}

let parsedPayload;
try {
const rawPayloadBytes = new Uint8Array(rawPayload);
const decoder = new TextDecoder();
parsedPayload = JSON.parse(decoder.decode(rawPayloadBytes));
} catch (error) {
throw new InvalidUnsignedPayload("Unable to decode unsigned payload JSON");
}
const parsedPayload = parseUnsignedPayload(this.getUnsignedPayload());

transaction.chainId = BigInt(parsedPayload.chainId);
transaction.nonce = BigInt(parsedPayload.nonce);
Expand Down
Loading

0 comments on commit 10f3860

Please sign in to comment.