Skip to content
Open
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changelog

## 0.4.3

### New Features

- **Decode the token payment of a UserOperation you didn't build.** If you're a co-signer, you usually never see the `TokenQuote` that came back when the operation was created. `CandidePaymaster.decodeTokenQuote(smartAccount, userOperation)` (also on `Erc7677Paymaster`) reads it back from the operation itself, offline: which paymaster and token, how much the approval allows, the exchange rate the paymaster signed, how long it's valid, and `maxTokenCost`, the most you can actually be charged. It works with Candide's token paymasters on EntryPoint v0.6 to v0.9 and Pimlico's on v0.6 to v0.8. Sponsored operations, or ones with no paymaster, return `null`.

The token is read from the paymaster data for Pimlico and from the batch's `approve` call for Candide. It throws on anything it can't read safely: an unknown paymaster, a batch that delegatecalls something other than an official Safe MultiSend, or a batch that approves the paymaster for more than one token. (#240)

### API Changes

- Removed `CALIBUR_CANDIDE_V0_1_0_SINGLETON_ADDRESS`. It pointed at an unofficial, unaudited Calibur deployment, and `Calibur7702Account` never used it. (#242)
- Removed `BundlerErrorCode.INVALID_USEROPERATION_HASH`, deprecated since 0.4.1. Nothing has returned it since then. (#243)
- Removed the old `PaymasterMetadataV6/V7/V8` and `SupportedERC20TokensAndMetadata*` aliases. They were never exported, so this shouldn't affect anyone. (#243)

### Bug Fixes

- `getUserOperationEip712Data_V9` and `getUserOperationEip712Hash_V9` used an old module address by default, one that only exists on Sepolia. If you relied on the default, your signatures failed with `AA24`. They now use the same module as `SafeMultiChainSigAccountV1`. (#241)

### Internal

- `CandidePaymaster` and `Erc7677Paymaster` now share the token cost calculation. (#237)

## 0.4.2

### API Changes
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Only the latest stable version published to npm is supported with security updat

| Version | Supported |
| ------- | ------------------ |
| 0.4.2 | :white_check_mark: |
| 0.4.3 | :white_check_mark: |

## Reporting a Vulnerability

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "Candidelabs",
"url": "https://candide.dev"
},
"version": "0.4.2",
"version": "0.4.3",
"description": "Account Abstraction 4337 SDK by Candidelabs",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
Expand Down
8 changes: 7 additions & 1 deletion src/abstractionkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export { Simple7702AccountV09 } from "./account/simple/Simple7702AccountV09";
export { Bundler } from "./Bundler";
export {
BaseUserOperationDummyValues,
CALIBUR_CANDIDE_V0_1_0_SINGLETON_ADDRESS,
CALIBUR_UNISWAP_V1_0_0_SINGLETON_ADDRESS,
DEFAULT_SECP256R1_PRECOMPILE_ADDRESS,
EIP712_MULTI_CHAIN_OPERATIONS_PRIMARY_TYPE,
Expand All @@ -93,12 +92,18 @@ export type {
Erc7677StubDataResult,
} from "./paymaster/Erc7677Paymaster";
export { Erc7677Paymaster } from "./paymaster/Erc7677Paymaster";
export type {
DecodedTokenQuote,
KnownTokenPaymaster,
TokenPaymasterProvider,
} from "./paymaster/decodeTokenQuote";
export type {
AnyUserOperation,
CandidePaymasterContext,
Erc7677PaymasterConstructorOptions,
Erc7677Provider,
GasPaymasterUserOperationOverrides,
DecodeTokenPaymasterApprovalsAccount,
PrependTokenPaymasterApproveAccount,
SameUserOp,
} from "./paymaster/types";
Expand Down Expand Up @@ -148,6 +153,7 @@ export type {
SponsorInfo,
SponsorMetadata,
StateOverrideSet,
TokenPaymasterApproval,
TokenQuote,
UserOperationByHashResult,
UserOperationReceipt,
Expand Down
2 changes: 1 addition & 1 deletion src/account/Calibur/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export interface CaliburCreateUserOperationOverrides {
/**
* Paymaster init values for gas estimation. Set these to include
* paymaster data during gas estimation so preVerificationGas is accurate.
* Use {@link ExperimentalAllowAllPaymaster.getPaymasterFieldsInitValues} or similar
* Use {@link ExperimentalAllowAllParallelPaymaster.getPaymasterFieldsInitValues} or similar
* to obtain these values.
*/
paymasterFields?: ParallelPaymasterInitValues;
Expand Down
137 changes: 132 additions & 5 deletions src/account/Safe/SafeAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
Operation,
type StateOverrideSet,
type TenderlySimulationResult,
type TokenPaymasterApproval,
type UserOperationV6,
type UserOperationV7,
type UserOperationV9,
Expand All @@ -50,7 +51,14 @@ import {
} from "../../utilsTenderly";
import {SendUseroperationResponse} from "../SendUseroperationResponse";
import {SmartAccount} from "../SmartAccount";
import {decodeMultiSendCallData, encodeMultiSendCallData} from "./multisend";
import {
decodeMultiSendCallData,
decodeMultiSendTransactions,
encodeMultiSendCallData,
SAFE_MULTISEND_DEPLOYMENTS,
} from "./multisend";
import {getUserOperationPaymaster} from "../../paymaster/Paymaster";
import type {AnyUserOperation} from "../../paymaster/types";
import {
getSafeMessageEip712Data,
type SafeMessageTypedDataDomain,
Expand Down Expand Up @@ -976,7 +984,7 @@ export class SafeAccount extends SmartAccount {
* @param overrides.validUntil - timestamp the signature will be valid until
* @param overrides.entrypoint - target entrypoint
* defaults to ENTRYPOINT_V9
* @param overrides.safe4337ModuleAddress - defaults to "0xee8005d7e79f9a6829ea61A81Fc2A85055fB2a42"
* @param overrides.safe4337ModuleAddress - defaults to "0x22939E839e3c0F479B713eAF95e0df128554AEAd"
* @returns an object containing the typed data domain, type and typed data vales
* object needed for hashing and signing
*/
Expand All @@ -995,7 +1003,7 @@ export class SafeAccount extends SmartAccount {
messageValue: SafeUserOperationV9TypedMessageValue;
} {
const safe4337ModuleAddress =
overrides.safe4337ModuleAddress ?? "0xee8005d7e79f9a6829ea61A81Fc2A85055fB2a42";
overrides.safe4337ModuleAddress ?? "0x22939E839e3c0F479B713eAF95e0df128554AEAd";

return SafeAccount.baseGetUserOperationEip712DataV7V8V9(
useroperation,
Expand All @@ -1018,7 +1026,7 @@ export class SafeAccount extends SmartAccount {
* @param overrides.validUntil - timestamp the signature will be valid until
* @param overrides.entrypoint - target entrypoint
* defaults to ENTRYPOINT_V9
* @param overrides.safe4337ModuleAddress - defaults to "0xE0049883864b20728b76B5cf265765B45162516D"
* @param overrides.safe4337ModuleAddress - defaults to "0x22939E839e3c0F479B713eAF95e0df128554AEAd"
* @returns useroperation hash
*/
public static getUserOperationEip712Hash_V9(
Expand Down Expand Up @@ -1324,6 +1332,125 @@ export class SafeAccount extends SmartAccount {
return [safeAccountFactory.address, factoryGeneratorFunctionCallData];
}

/**
* Find the ERC-20 approvals a UserOperation grants to its own paymaster.
* Low-level account hook behind `Erc7677Paymaster.decodeTokenQuote` and
* `CandidePaymaster.decodeTokenQuote`, which most callers want instead.
*
* Token paymaster flows prepend `approve(paymaster, amount)` to the
* account's MultiSend batch. That amount is the most the paymaster can
* charge, so it is what the owners sign, even when the `TokenQuote` from
* building the operation is no longer at hand. Only `approve` calls whose
* spender is the paymaster set on this operation are returned, so a
* dapp's own `approve` in a sponsored operation is not mistaken for one.
*
* The whole batch is scanned, in execution order. `approve` sets rather
* than adds, so the paymaster's allowance for a token after execution is
* the last entry for that token. Token-flow operations for tokens that
* need an allowance reset (e.g. USDT) carry an `approve(0)` entry first.
*
* Only direct `approve` calls in the batch are recognized. Allowance
* granted any other way (`increaseAllowance`, `permit`, a delegatecall
* inside the batch) is not reported. Calls are matched by the
* `approve(address,uint256)` selector, which ERC-721 shares, so the target
* is not verified to be an ERC-20: compare `token` with the token you
* expect the paymaster to charge before treating `amount` as its cap.
*
* A delegatecall runs the target's code in the Safe's context, so the
* batch is only decoded when the Safe delegatecalls an official Safe
* MultiSend or MultiSendCallOnly deployment (v1.3.0, v1.4.1, v1.5.0), and
* the batch itself contains no delegatecall. Any other delegatecall throws
* rather than reporting approvals that code may never execute or may
* overwrite.
*
* @param userOperation - The UserOperation to inspect
* @param overrides - overrides for the default values
* @param overrides.multisendContractAddress - An additional MultiSend
* contract to accept, for custom deployments
* @returns Approvals to the paymaster, in execution order. Empty when the
* operation has no paymaster or grants it no approval.
* @throws AbstractionKitError with code "BAD_DATA" if `callData` is not a
* Safe module executor call, delegatecalls anything other than the
* expected MultiSend contract, or the MultiSend payload is malformed
*/
public decodeTokenPaymasterApprovals(
userOperation: AnyUserOperation,
overrides: {
multisendContractAddress?: string;
} = {},
): TokenPaymasterApproval[] {
const paymaster = getUserOperationPaymaster(userOperation);
if (paymaster == null) return [];

const [metaTransaction] = SafeAccount.decodeAccountCallData(userOperation.callData);
let transactions: MetaTransaction[] = [metaTransaction];
if (metaTransaction.operation === Operation.Delegate) {
const multisendSelector = "0x8d80ff0a";
const target = metaTransaction.to.toLowerCase();
const isKnownMultiSend =
SAFE_MULTISEND_DEPLOYMENTS.includes(target) ||
target === overrides.multisendContractAddress?.toLowerCase();
if (!isKnownMultiSend || !metaTransaction.data.startsWith(multisendSelector)) {
throw new AbstractionKitError(
"BAD_DATA",
`UserOperation delegatecalls ${metaTransaction.to}, which is not a known Safe ` +
"MultiSend contract; its token approvals cannot be determined. " +
"Pass overrides.multisendContractAddress for a custom MultiSend deployment.",
{ context: { to: metaTransaction.to } },
);
}
let packedTransactions: string;
try {
packedTransactions = decodeMultiSendCallData(metaTransaction.data);
} catch (err) {
throw new AbstractionKitError(
"BAD_DATA",
`malformed MultiSend calldata in delegatecall to ${metaTransaction.to}`,
{ cause: ensureError(err), context: { to: metaTransaction.to } },
);
}
transactions = decodeMultiSendTransactions(packedTransactions);
// An inner delegatecall runs arbitrary code in the Safe's context and
// could change the paymaster allowance after any approve we report.
const innerDelegate = transactions.find((tx) => tx.operation === Operation.Delegate);
if (innerDelegate != null) {
throw new AbstractionKitError(
"BAD_DATA",
`UserOperation batch delegatecalls ${innerDelegate.to}; its token approvals cannot be determined.`,
{ context: { to: innerDelegate.to } },
);
}
}

const approveSelector = getFunctionSelector("approve(address,uint256)");
const approvals: TokenPaymasterApproval[] = [];
for (const transaction of transactions) {
if (
(transaction.operation ?? Operation.Call) !== Operation.Call ||
!transaction.data.toLowerCase().startsWith(approveSelector)
) {
continue;
}
let spender: string;
let amount: bigint;
try {
[spender, amount] = decodeAbiParameters<[string, bigint]>(
["address", "uint256"],
`0x${transaction.data.slice(10)}`,
);
} catch (err) {
throw new AbstractionKitError(
"BAD_DATA",
`malformed approve calldata in call to ${transaction.to}`,
{ cause: ensureError(err), context: { to: transaction.to } },
);
}
if (spender.toLowerCase() !== paymaster.toLowerCase()) continue;
approvals.push({ token: getAddress(transaction.to), spender, amount: BigInt(amount) });
}
return approvals;
}

/**
* a non static wrapper function for prependTokenPaymasterApproveToCallDataStatic
* which adds a token approve call to the call data for a token paymaster
Expand Down Expand Up @@ -3540,7 +3667,7 @@ function generateOnChainIdentifier(
project: string,
platform: "Web" | "Mobile" | "Safe App" | "Widget" = "Web",
tool: string = "abstractionkit",
toolVersion: string = "0.4.2",
toolVersion: string = "0.4.3",
): string {
const identifierPrefix = "5afe"; // Safe identifier prefix
const identifierVersion = "00"; // First version
Expand Down
62 changes: 62 additions & 0 deletions src/account/Safe/multisend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import { decodeAbiParameters, getBytes, solidityPacked } from "../../ethereUtils";
import { AbstractionKitError } from "src/errors";
import { type MetaTransaction, Operation } from "src/types";

/**
* Official Safe MultiSend and MultiSendCallOnly deployments (v1.3.0 canonical,
* eip155 and zkSync, v1.4.1 canonical and zkSync, v1.5.0 canonical), from
* github.com/safe-global/safe-deployments. Lowercased for comparison.
*/
export const SAFE_MULTISEND_DEPLOYMENTS: readonly string[] = [
"0xa238cbeb142c10ef7ad8442c6d1f9e89e07e7761", // MultiSend v1.3.0
"0x998739bfdaadde7c933b942a68053933098f9eda", // MultiSend v1.3.0 eip155
"0x0dfcccb95225ffb03c6fbb2559b530c2b7c8a912", // MultiSend v1.3.0 zkSync
"0x40a2accbd92bca938b02010e17a5b8929b49130d", // MultiSendCallOnly v1.3.0
"0xa1dabef33b3b82c7814b6d82a79e50f4ac44102b", // MultiSendCallOnly v1.3.0 eip155
"0xf220d3b4dfb23c4ade8c88e526c1353abacbc38f", // MultiSendCallOnly v1.3.0 zkSync
"0x38869bf66a61cf6bdb996a6ae40d5853fd43b526", // MultiSend v1.4.1
"0x309d0b190fecca8e1d5d8309a16f7e3cb133e885", // MultiSend v1.4.1 zkSync
"0x9641d764fc13c8b624c04430c7356c1c7c8102e2", // MultiSendCallOnly v1.4.1
"0x0408ef011960d02349d50286d20531229bcef773", // MultiSendCallOnly v1.4.1 zkSync
"0x218543288004cd07832472d464648173c77d7eb7", // MultiSend v1.5.0
"0xa83c336b20401af773b6219ba5027174338d1836", // MultiSendCallOnly v1.5.0
];

/**
* Pack a single MetaTransaction into the MultiSend byte layout
* (operation, to, value, dataLength, data).
Expand Down Expand Up @@ -37,3 +58,44 @@ export function decodeMultiSendCallData(callData: string): string {
const decodedCalldata = decodeAbiParameters<[string]>(["bytes"], `0x${callData.slice(10)}`);
return decodedCalldata[0];
}

/**
* Split packed MultiSend transaction bytes (the output of
* {@link decodeMultiSendCallData}) back into MetaTransactions.
* Inverse of {@link encodeMultiSendCallData}.
* @param packed - Packed transactions as a 0x-prefixed hex string
* @returns The transactions in execution order
* @throws AbstractionKitError with code "BAD_DATA" if the bytes are truncated
*/
export function decodeMultiSendTransactions(packed: string): MetaTransaction[] {
const hex = packed.startsWith("0x") ? packed.slice(2) : packed;
// Byte offsets, doubled for hex characters: operation(1) to(20) value(32) dataLength(32)
const headerLength = (1 + 20 + 32 + 32) * 2;
const transactions: MetaTransaction[] = [];
let offset = 0;
while (offset < hex.length) {
if (offset + headerLength > hex.length) {
throw new AbstractionKitError("BAD_DATA", "truncated MultiSend transaction header", {
context: { packed },
});
}
const operation = Number.parseInt(hex.slice(offset, offset + 2), 16);
const to = `0x${hex.slice(offset + 2, offset + 42)}`;
const value = BigInt(`0x${hex.slice(offset + 42, offset + 106)}`);
const dataLength = Number(BigInt(`0x${hex.slice(offset + 106, offset + 170)}`)) * 2;
const dataStart = offset + headerLength;
if (dataStart + dataLength > hex.length) {
throw new AbstractionKitError("BAD_DATA", "truncated MultiSend transaction data", {
context: { packed },
});
}
transactions.push({
to,
value,
data: `0x${hex.slice(dataStart, dataStart + dataLength)}`,
operation,
});
offset = dataStart + dataLength;
}
return transactions;
}
4 changes: 0 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,3 @@ export const DEFAULT_SECP256R1_PRECOMPILE_ADDRESS = "0x0000000000000000000000000
/** Uniswap Calibur singleton v1.0.0 (EntryPoint v0.8) */
export const CALIBUR_UNISWAP_V1_0_0_SINGLETON_ADDRESS =
"0x000000009B1D0aF20D8C6d0A44e162d11F9b8f00";

/** Candide Calibur singleton v0.1.0 (EntryPoint v0.9, unaudited) */
export const CALIBUR_CANDIDE_V0_1_0_SINGLETON_ADDRESS =
"0x71032285A847c4311Eb7ec2E7A636aB94A9805Aa";
4 changes: 0 additions & 4 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ export type BundlerErrorCode =
| "UNSUPPORTED_SIGNATURE_AGGREGATOR"
| "INVALID_SIGNATURE"
| "PAYMASTER_DEPOSIT_TOO_LOW"
/** @deprecated no longer produced: -32601 was mismapped to this code; an
* invalid hash surfaces as INVALID_FIELDS (-32602) per the bundler spec
* tests and Voltaire, while -32601 is the standard METHOD_NOT_FOUND. */
| "INVALID_USEROPERATION_HASH"
| "EXECUTION_REVERTED";

/**
Expand Down
Loading
Loading