Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(common): kms correctly serializes transactions #2721

Merged
merged 17 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"devDependencies": {
"@types/debug": "^4.1.7",
"@types/node": "^18.15.11",
"@viem/anvil": "^0.0.7",
"tsup": "^6.7.0",
"vitest": "0.34.6"
},
Expand Down
48 changes: 47 additions & 1 deletion packages/common/src/account/kms/createKmsAccount.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { describe, it, expect, beforeAll } from "vitest";
import { KMSAccount, createKmsAccount } from "./createKmsAccount";
import { CreateKeyCommand, KMSClient } from "@aws-sdk/client-kms";
import { parseGwei, verifyMessage, verifyTypedData } from "viem";
import {
createPublicClient,
parseGwei,
createWalletClient,
http,
parseEther,
verifyMessage,
verifyTypedData,
} from "viem";
import { foundry } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { anvilRpcUrl } from "../test/common";
import { waitForTransaction } from "./test/waitForTransaction";

describe("createKmsAccount", () => {
let account: KMSAccount;
Expand Down Expand Up @@ -102,4 +114,38 @@ describe("createKmsAccount", () => {

expect(valid).toBeTruthy();
});

it("can execute transactions", async () => {
const publicClient = createPublicClient({
chain: foundry,
transport: http(anvilRpcUrl),
});

let balance = await publicClient.getBalance({ address: account.address });
expect(balance).toEqual(0n);

const kmsClient = createWalletClient({
chain: foundry,
transport: http(anvilRpcUrl),
account,
});

const adminClient = createWalletClient({
chain: foundry,
transport: http(anvilRpcUrl),
account: privateKeyToAccount("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"),
});

let tx = await adminClient.sendTransaction({ to: account.address, value: parseEther("1") });
await waitForTransaction(tx);

balance = await publicClient.getBalance({ address: account.address });
expect(balance).toEqual(1000000000000000000n);

tx = await kmsClient.sendTransaction({});
await waitForTransaction(tx);

balance = await publicClient.getBalance({ address: account.address });
expect(balance).toBeLessThan(1000000000000000000n);
});
});
32 changes: 20 additions & 12 deletions packages/common/src/account/kms/createKmsAccount.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KMSClient } from "@aws-sdk/client-kms";
import { LocalAccount, hashMessage, hashTypedData, keccak256, serializeTransaction } from "viem";
import { LocalAccount, hashMessage, hashTypedData, keccak256, serializeTransaction, signatureToHex } from "viem";
import { toAccount } from "viem/accounts";
import { signWithKms } from "./signWithKms";
import { getAddressFromKms } from "./getAddressFromKms";
Expand All @@ -25,38 +25,46 @@ export async function createKmsAccount({
const account = toAccount({
address,
async signMessage({ message }) {
const hash = hashMessage(message);
const signature = await signWithKms({
client,
keyId,
hash,
hash: hashMessage(message),
address,
});

return signature;
return signatureToHex(signature);
},
async signTransaction(transaction) {
const unsignedTx = serializeTransaction(transaction);
const hash = keccak256(unsignedTx);
async signTransaction(transaction, { serializer = serializeTransaction } = {}) {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const signableTransaction = (() => {
// For EIP-4844 Transactions, we want to sign the transaction payload body (tx_payload_body) without the sidecars (ie. without the network wrapper).
// See: https://github.com/ethereum/EIPs/blob/e00f4daa66bd56e2dbd5f1d36d09fd613811a48b/EIPS/eip-4844.md#networking
if (transaction.type === "eip4844")
return {
...transaction,
sidecars: false,
};
return transaction;
})();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooc why this and not

Suggested change
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const signableTransaction = (() => {
// For EIP-4844 Transactions, we want to sign the transaction payload body (tx_payload_body) without the sidecars (ie. without the network wrapper).
// See: https://github.com/ethereum/EIPs/blob/e00f4daa66bd56e2dbd5f1d36d09fd613811a48b/EIPS/eip-4844.md#networking
if (transaction.type === "eip4844")
return {
...transaction,
sidecars: false,
};
return transaction;
})();
// For EIP-4844 Transactions, we want to sign the transaction payload body (tx_payload_body) without the sidecars (ie. without the network wrapper).
// See: https://github.com/ethereum/EIPs/blob/e00f4daa66bd56e2dbd5f1d36d09fd613811a48b/EIPS/eip-4844.md#networking
const signableTransaction = transaction.type === "eip4844" ? { ...transaction, sidecars: false } : transaction;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied verbatim from the Viem function! It could be more encapsulated imo: https://github.com/wevm/viem/blob/main/src/accounts/utils/signTransaction.ts#L53-L62

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh got it!

maybe worth a note above about keeping this logic aligned with viem internals?


const signature = await signWithKms({
client,
keyId,
hash,
hash: keccak256(serializer(signableTransaction)),
address,
});

return signature;
return serializer(transaction, signature);
Copy link
Member

@holic holic Apr 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to confirm, we don't want to use signableTransaction here?

I see now we're following viem

Copy link
Contributor Author

@yonadaa yonadaa Apr 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, again this is from viem. I think it's because the data you sign should (or shouldn't, idk) include some fields that are in the full transaction data.

},
async signTypedData(typedData) {
const hash = hashTypedData(typedData);
const signature = await signWithKms({
client,
keyId,
hash,
hash: hashTypedData(typedData),
address,
});

return signature;
return signatureToHex(signature);
},
});

Expand Down
8 changes: 4 additions & 4 deletions packages/common/src/account/kms/signWithKms.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hex, isAddressEqual, signatureToHex, toHex } from "viem";
import { Hex, Signature, isAddressEqual, signatureToHex, toHex } from "viem";
import { recoverAddress } from "viem/utils";
import { KMSClient, SignCommandInput } from "@aws-sdk/client-kms";
import { sign } from "./sign";
Expand Down Expand Up @@ -65,7 +65,7 @@ type SignParameters = {
address: Hex;
};

type SignReturnType = Hex;
type SignReturnType = Signature;

/**
* @description Signs a hash with a given KMS key.
Expand All @@ -78,10 +78,10 @@ export async function signWithKms({ hash, address, keyId, client }: SignParamete
const { r, s } = await getRS({ keyId, hash, client });
const recovery = await getRecovery(hash, r, s, address);

return signatureToHex({
return {
r,
s,
v: recovery ? 28n : 27n,
yParity: recovery,
});
};
}
9 changes: 9 additions & 0 deletions packages/common/src/test/minePending.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { testClient } from "../../test/common";

export async function minePending(): Promise<void> {
const content = await testClient.getTxpoolContent();
if (!Object.keys(content.pending).length) return;

await testClient.mine({ blocks: 1 });
await minePending();
}
13 changes: 13 additions & 0 deletions packages/common/src/test/waitForTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Hex } from "viem";
import { getTransactionReceipt } from "viem/actions";
import { testClient } from "../../test/common";
import { minePending } from "./minePending";

export async function waitForTransaction(hash: Hex): Promise<void> {
await minePending();
const receipt = await getTransactionReceipt(testClient, { hash });
if (receipt.status === "reverted") {
// TODO: better error
throw new Error(`Transaction reverted (${hash})`);
}
}
18 changes: 18 additions & 0 deletions packages/common/test/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createTestClient, http, publicActions, walletActions } from "viem";

export const anvilHost = "127.0.0.1";
export const anvilPort = 8565;

// ID of the current test worker. Used by the `@viem/anvil` proxy server.
export const poolId = Number(process.env.VITEST_POOL_ID ?? 1);

export const anvilRpcUrl = `http://${anvilHost}:${anvilPort}/${poolId}`;

export const testClient = createTestClient({
mode: "anvil",
// TODO: if tests get slow, try switching to websockets?
transport: http(anvilRpcUrl),
pollingInterval: 10,
})
.extend(publicActions)
.extend(walletActions);
13 changes: 13 additions & 0 deletions packages/common/test/globalSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { startProxy as startAnvilProxy } from "@viem/anvil";
import { anvilHost, anvilPort } from "./common";

export default async function globalSetup(): Promise<() => Promise<void>> {
const shutdownAnvilProxy = await startAnvilProxy({
host: anvilHost,
port: anvilPort,
});

return async () => {
await shutdownAnvilProxy();
};
}
18 changes: 18 additions & 0 deletions packages/common/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { beforeAll, beforeEach } from "vitest";
import { testClient } from "./common";

// Some test suites deploy contracts in a `beforeAll` handler, so we restore chain state here.
beforeAll(async () => {
const state = await testClient.dumpState();
return async (): Promise<void> => {
await testClient.loadState({ state });
};
});

// Some tests execute transactions, so we restore chain state here.
beforeEach(async () => {
const state = await testClient.dumpState();
return async (): Promise<void> => {
await testClient.loadState({ state });
};
});
12 changes: 12 additions & 0 deletions packages/common/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
globalSetup: ["test/globalSetup.ts"],
setupFiles: ["test/setup.ts"],
// Temporarily set a low teardown timeout because anvil hangs otherwise
// Could move this timeout to anvil setup after https://github.com/wevm/anvil.js/pull/46
teardownTimeout: 500,
hookTimeout: 15000,
},
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading