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

Improved Transaction Decoding #107

Merged
merged 4 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"dependencies": {
"@safe-global/safe-gateway-typescript-sdk": "^3.22.2",
"near-api-js": "^5.0.1",
"near-ca": "^0.7.0",
"near-ca": "^0.7.2",
"semver": "^7.6.3",
"viem": "^2.21.41"
},
Expand Down
58 changes: 29 additions & 29 deletions src/decode/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { EIP712TypedData } from "near-ca";
import { isRlpHex, isTransactionSerializable } from "near-ca";

import { isRlpHex, isTransactionSerializable } from "../lib/safe-message";
import { DecodedTxData, SafeEncodedSignRequest, UserOperation } from "../types";
import {
DecodedTxData,
parseEip712TypedData,
parseUserOperation,
SafeEncodedSignRequest,
} from "../types";
import {
decodeRlpHex,
decodeTransactionSerializable,
Expand All @@ -26,33 +30,29 @@ export function decodeTxData({
if (isTransactionSerializable(data)) {
return decodeTransactionSerializable(chainId, data);
}
if (typeof data !== "string") {
return decodeTypedData(chainId, data);
const parsedTypedData = parseEip712TypedData(data);
if (parsedTypedData) {
return decodeTypedData(chainId, parsedTypedData);
}
try {
// Stringified UserOperation.
const userOp: UserOperation = JSON.parse(data);
const userOp = parseUserOperation(data);
if (userOp) {
return decodeUserOperation(chainId, userOp);
} catch (error: unknown) {
if (error instanceof SyntaxError) {
// Raw message string.
return {
chainId,
costEstimate: "0",
transactions: [],
message: data,
};
} else {
// TODO: This shouldn't happen anymore and can probably be reverted.
// We keep it here now, because near-ca might not have adapted its router.
console.warn("Failed UserOp Parsing, try TypedData Parsing", error);
try {
const typedData: EIP712TypedData = JSON.parse(data);
return decodeTypedData(chainId, typedData);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`decodeTxData: Unexpected error - ${message}`);
}
}
}
// At this point we are certain that the data is a string.
// Typescript would disagree here because of the EIP712TypedData possibility that remains.
// However this is captured (indirectly) by parseEip712TypedData above.
// We check now if its a string and return a reasonable default (for the case of a raw message).
if (typeof data === "string") {
return {
chainId,
costEstimate: "0",
transactions: [],
message: data,
};
}
// Otherwise we have no idea what the data is and we throw.
console.warn("Unrecognized txData format,", chainId, data);
throw new Error(
`decodeTxData: Invalid or unsupported message format ${data}`
);
}
13 changes: 1 addition & 12 deletions src/decode/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {

import { SAFE_DEPLOYMENTS } from "../_gen/deployments";
import { decodeMulti, isMultisendTx } from "../lib/multisend";
import { DecodedTxData, UserOperation } from "../types";
import { DecodedTxData, MetaTransaction, UserOperation } from "../types";

export function decodeTransactionSerializable(
chainId: number,
Expand Down Expand Up @@ -89,14 +89,3 @@ export function decodeUserOperation(
transactions,
};
}

export declare enum OperationType {
Call = 0,
DelegateCall = 1,
}
export interface MetaTransaction {
readonly to: string;
readonly value: string;
readonly data: string;
readonly operation?: OperationType;
}
35 changes: 0 additions & 35 deletions src/lib/safe-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import {
Hash,
hashMessage,
hashTypedData,
Hex,
isHex,
parseTransaction,
serializeTransaction,
TransactionSerializable,
} from "viem";

export type DecodedSafeMessage = {
Expand Down Expand Up @@ -125,36 +121,5 @@ export function decodeSafeMessage(
};
}

// const isEIP712TypedData = (obj: any): obj is EIP712TypedData => {
// return (
// typeof obj === "object" &&
// obj != null &&
// "domain" in obj &&
// "types" in obj &&
// "message" in obj
// );
// };

// export const isBlindSigningPayload = (obj: EIP712TypedData | string): boolean =>
// !isEIP712TypedData(obj) && isHash(obj);

// Cheeky attempt to serialize. return true if successful!
export function isTransactionSerializable(
data: unknown
): data is TransactionSerializable {
try {
serializeTransaction(data as TransactionSerializable);
return true;
} catch (error) {
return false;
}
}

export function isRlpHex(data: unknown): data is Hex {
try {
parseTransaction(data as Hex);
return true;
} catch (error) {
return false;
}
}
108 changes: 108 additions & 0 deletions src/types/guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { EIP712TypedData, isEIP712TypedData } from "near-ca";
import { isAddress, isHex } from "viem";

import { UserOperation } from ".";

export const isUserOperation = (data: unknown): data is UserOperation => {
if (typeof data !== "object" || data === null) return false;

const candidate = data as Record<string, unknown>;

// Required fields
const hasRequiredFields =
"sender" in candidate &&
"nonce" in candidate &&
"callData" in candidate &&
"maxPriorityFeePerGas" in candidate &&
"maxFeePerGas" in candidate &&
"verificationGasLimit" in candidate &&
"callGasLimit" in candidate &&
"preVerificationGas" in candidate;

if (!hasRequiredFields) return false;

// Type checks for required fields
const hasValidRequiredTypes =
typeof candidate.sender === "string" &&
isAddress(candidate.sender) &&
typeof candidate.nonce === "string" &&
isHex(candidate.callData) &&
isHex(candidate.maxPriorityFeePerGas) &&
isHex(candidate.maxFeePerGas) &&
isHex(candidate.verificationGasLimit) &&
isHex(candidate.callGasLimit) &&
isHex(candidate.preVerificationGas);

if (!hasValidRequiredTypes) return false;

// Optional fields type checks
if ("factory" in candidate && candidate.factory !== undefined) {
if (typeof candidate.factory !== "string" || !isAddress(candidate.factory))
return false;
}

if ("factoryData" in candidate && candidate.factoryData !== undefined) {
if (!isHex(candidate.factoryData)) return false;
}

if ("signature" in candidate && candidate.signature !== undefined) {
if (!isHex(candidate.signature)) return false;
}

if ("paymaster" in candidate && candidate.paymaster !== undefined) {
if (
typeof candidate.paymaster !== "string" ||
!isAddress(candidate.paymaster)
)
return false;
}

if ("paymasterData" in candidate && candidate.paymasterData !== undefined) {
if (!isHex(candidate.paymasterData)) return false;
}

if (
"paymasterVerificationGasLimit" in candidate &&
candidate.paymasterVerificationGasLimit !== undefined
) {
if (!isHex(candidate.paymasterVerificationGasLimit)) return false;
}

if (
"paymasterPostOpGasLimit" in candidate &&
candidate.paymasterPostOpGasLimit !== undefined
) {
if (!isHex(candidate.paymasterPostOpGasLimit)) return false;
}

return true;
};

export const parseWithTypeGuard = <T>(
data: unknown,
typeGuard: (value: unknown) => value is T
): T | null => {
// Case 1: Already the correct type
if (typeGuard(data)) {
return data;
}

// Case 2: Stringified data
if (typeof data === "string") {
try {
const parsed = JSON.parse(data);
return typeGuard(parsed) ? parsed : null;
} catch (error) {
return null;
}
}

// Neither valid type nor valid stringified type
return null;
};

export const parseUserOperation = (data: unknown): UserOperation | null =>
parseWithTypeGuard(data, isUserOperation);

export const parseEip712TypedData = (data: unknown): EIP712TypedData | null =>
parseWithTypeGuard(data, isEIP712TypedData);
2 changes: 2 additions & 0 deletions src/types.ts → src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
} from "near-ca";
import { Address, Hex, ParseAbi } from "viem";

export * from "./guards";

/**
* Represents a collection of Safe contract deployments, each with its own address and ABI.
*/
Expand Down
7 changes: 2 additions & 5 deletions tests/e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,13 @@ describe("Near Safe Requests", () => {
],
})
).resolves.not.toThrow();

const typedDataString = `
{\"types\":{\"SafeTx\":[{\"name\":\"to\",\"type\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\"},{\"name\":\"data\",\"type\":\"bytes\"},{\"name\":\"operation\",\"type\":\"uint8\"},{\"name\":\"safeTxGas\",\"type\":\"uint256\"},{\"name\":\"baseGas\",\"type\":\"uint256\"},{\"name\":\"gasPrice\",\"type\":\"uint256\"},{\"name\":\"gasToken\",\"type\":\"address\"},{\"name\":\"refundReceiver\",\"type\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\"}],\"EIP712Domain\":[{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}]},\"domain\":{\"chainId\":\"0xaa36a7\",\"verifyingContract\":\"0x7fa8e8264985c7525fc50f98ac1a9b3765405489\"},\"primaryType\":\"SafeTx\",\"message\":{\"to\":\"0x102543f7e6b5786a444cc89ff73012825d13000d\",\"value\":\"100000000000000000\",\"data\":\"0x\",\"operation\":\"0\",\"safeTxGas\":\"0\",\"baseGas\":\"0\",\"gasPrice\":\"0\",\"gasToken\":\"0x0000000000000000000000000000000000000000\",\"refundReceiver\":\"0x0000000000000000000000000000000000000000\",\"nonce\":\"0\"}}
`;
// eslint-disable-next-line quotes
const typedDataString = `{\"types\":{\"SafeTx\":[{\"name\":\"to\",\"type\":\"address\"},{\"name\":\"value\",\"type\":\"uint256\"},{\"name\":\"data\",\"type\":\"bytes\"},{\"name\":\"operation\",\"type\":\"uint8\"},{\"name\":\"safeTxGas\",\"type\":\"uint256\"},{\"name\":\"baseGas\",\"type\":\"uint256\"},{\"name\":\"gasPrice\",\"type\":\"uint256\"},{\"name\":\"gasToken\",\"type\":\"address\"},{\"name\":\"refundReceiver\",\"type\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint256\"}],\"EIP712Domain\":[{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}]},\"domain\":{\"chainId\":\"0xaa36a7\",\"verifyingContract\":\"0x7fa8e8264985c7525fc50f98ac1a9b3765405489\"},\"primaryType\":\"SafeTx\",\"message\":{\"to\":\"0x102543f7e6b5786a444cc89ff73012825d13000d\",\"value\":\"100000000000000000\",\"data\":\"0x\",\"operation\":\"0\",\"safeTxGas\":\"0\",\"baseGas\":\"0\",\"gasPrice\":\"0\",\"gasToken\":\"0x0000000000000000000000000000000000000000\",\"refundReceiver\":\"0x0000000000000000000000000000000000000000\",\"nonce\":\"0\"}}`;
const { evmData } = await adapter.encodeSignRequest({
chainId: 11155111,
method: "eth_signTypedData_v4",
params: [adapter.mpcAddress, typedDataString],
});
console.log(evmData);
expect(() => decodeTxData({ ...evmData })).not.toThrow();

expect(evmData).toStrictEqual({
Expand Down
Loading