-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #56 from coinbase/feat/transaction-model
Implementing Transaction model
- Loading branch information
Showing
7 changed files
with
375 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()}' }`; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.