@biconomy/abstractjs
Version:
SDK for Biconomy integration with support for account abstraction, smart accounts, ERC-4337.
280 lines • 11.7 kB
JavaScript
import { OperationType } from "@safe-global/types-kit";
import { http, concatHex, createPublicClient, encodeAbiParameters, encodeFunctionData, erc20Abi, keccak256, zeroAddress } from "viem";
import { toAccount } from "viem/accounts";
import { ForwarderAbi } from "../../../constants/abi/ForwarderAbi.js";
// Safe SA mode prefix - 0x177eee04 for Safe Smart Account mode
export const SAFE_SA_PREFIX = "0x177eee04";
// Safe EIP-712 domain typehash
// keccak256("EIP712Domain(uint256 chainId,address verifyingContract)")
const SAFE_DOMAIN_TYPEHASH = "0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218";
/**
* Computes the Safe domain separator for a given Safe address and chain
*/
export const computeSafeDomainSeparator = (safeAddress, chainId) => {
return keccak256(encodeAbiParameters([
{ type: "bytes32" }, // DOMAIN_TYPEHASH
{ type: "uint256" }, // chainId
{ type: "address" } // verifyingContract (Safe address)
], [SAFE_DOMAIN_TYPEHASH, BigInt(chainId), safeAddress]));
};
/**
* Formats the signed Safe quote payload by encoding the Safe transaction data
* and signatures as required by the K1MeeValidator.
*
* @param quoteParams - The original Safe quote parameters
* @param safeTxData - The Safe transaction data
* @param signatures - The concatenated signatures from Safe owners
* @returns The signed Safe quote payload
*/
export const formatSignedSafeQuotePayload = (safeAccount, quoteParams, safeTxData, signatures) => {
const { quote } = quoteParams;
console.log("safeAccount:", safeAccount);
// Encode the SafeTxnData struct for the signature
const encodedSafeTxnData = encodeAbiParameters([
{ name: "safeAccount", type: "address" },
{
name: "safeTxnData",
type: "tuple",
components: [
{ name: "ogDomainSeparator", type: "bytes32" },
{ 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" },
{ name: "signatures", type: "bytes" }
]
}
], [
safeAccount,
{
ogDomainSeparator: safeTxData.ogDomainSeparator,
to: safeTxData.to,
value: safeTxData.value,
data: safeTxData.data,
operation: safeTxData.operation,
safeTxGas: safeTxData.safeTxGas,
baseGas: safeTxData.baseGas,
gasPrice: safeTxData.gasPrice,
gasToken: safeTxData.gasToken,
refundReceiver: safeTxData.refundReceiver,
nonce: safeTxData.nonce,
signatures
}
]);
return {
...quote,
signature: concatHex([SAFE_SA_PREFIX, encodedSafeTxnData])
};
};
/**
* Extracts unique chain IDs from the quote's userOps, excluding the payment
* chain if the quote is sponsored (since the payment userOp at index 0 is
* handled by the sponsor in that case).
*
* @param quote - The quote payload containing userOps and payment info
* @returns Array of unique chain IDs where the Safe must be deployed
*/
const getInvolvedChainIds = (quote) => {
const { userOps, paymentInfo } = quote;
const isSponsored = paymentInfo.sponsored === true;
// If sponsored, skip the first userOp (payment userOp) as it's handled by sponsor
const relevantUserOps = isSponsored ? userOps.slice(1) : userOps;
const chainIds = relevantUserOps.map((op) => Number(op.chainId));
return [...new Set(chainIds)];
};
/**
* Validates that the Safe is deployed on all chains involved in the supertransaction.
* Throws an error if the Safe is not deployed on any required chain.
*
* @param client - The Mee client instance
* @param quote - The quote payload containing userOps
* @param safeAddress - The Safe address to validate
* @throws Error if Safe is not deployed on any required chain
*/
export const validateSafeDeployment = async (client, quote, safeAddress) => {
const chainIds = getInvolvedChainIds(quote);
const deploymentChecks = await Promise.all(chainIds.map(async (chainId) => {
const deployment = client.account.deploymentOn(chainId, false);
if (!deployment) {
return { chainId, deployed: false, reason: "no deployment config" };
}
const publicClient = createPublicClient({
chain: deployment.client.chain,
transport: http()
});
const code = await publicClient.getCode({ address: safeAddress });
const isDeployed = code !== undefined && code !== "0x";
return { chainId, deployed: isDeployed };
}));
const undeployedChains = deploymentChecks.filter((check) => !check.deployed);
if (undeployedChains.length > 0) {
const chainList = undeployedChains.map((c) => c.chainId).join(", ");
throw new Error(`Safe at ${safeAddress} is not deployed on chains: ${chainList}. Safe must be deployed on all chains involved in the supertransaction.`);
}
};
/**
* Signs a Safe quote for the Safe Smart Account fusion mode.
* This enables using a Gnosis Safe multisig as the master account for supertransactions.
*
* The flow works by:
* 1. Creating a Safe transaction that approves tokens for the orchestrator
* 2. Appending the supertxn hash to the Safe transaction calldata
* 3. Collecting signatures from Safe owners
* 4. Encoding the Safe transaction + signatures as the userOp signature
*
* @param client - The Mee client instance
* @param parameters - Parameters for signing the Safe quote
* @param parameters.fusionQuote - The Safe quote to sign
* @param parameters.safeWalletClient - Wallet client for signing the Safe transaction
* @param parameters.safeAccount - The Safe address
* @param parameters.executeTrigger - Whether to execute trigger during validation (default: true)
*
* @returns Promise resolving to the quote payload with Safe signature
*
* @example
* ```typescript
* const signedSafeQuote = await signSafeQuote(meeClient, {
* fusionQuote: {
* quote: quotePayload,
* trigger: {
* tokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
* chainId: 1,
* amount: 1000000n // 1 USDC
* }
* },
* safeWalletClient: safeClient, // Configured Safe wallet client
* safeAccount: "0x..." // Safe address
* });
* ```
*/
export const signSafeQuote = async (client, parameters) => {
const { fusionQuote, signedSafeTxn } = parameters;
// Validate Safe is deployed on all chains involved in the supertransaction
await validateSafeDeployment(client, fusionQuote.quote, parameters.safeAccount);
const txnData = signedSafeTxn.data;
const signatures = signedSafeTxn.encodedSignatures();
const ogDomainSeparator = computeSafeDomainSeparator(parameters.safeAccount, fusionQuote.trigger.chainId);
const safeTxData = {
ogDomainSeparator,
to: txnData.to,
value: BigInt(txnData.value),
data: txnData.data,
operation: txnData.operation,
safeTxGas: BigInt(txnData.safeTxGas),
baseGas: BigInt(txnData.baseGas),
gasPrice: BigInt(txnData.gasPrice),
gasToken: txnData.gasToken,
refundReceiver: txnData.refundReceiver,
nonce: BigInt(txnData.nonce),
signatures: signatures
};
return formatSignedSafeQuotePayload(parameters.safeAccount, parameters.fusionQuote, safeTxData, signatures);
};
export function getDataToPrepareSafeTransaction(client, quoteParams, companionAccount) {
const { quote, trigger } = quoteParams;
if (trigger.call) {
throw new Error("Custom triggers are not supported for Safe fusion transactions");
}
if (!trigger.amount) {
throw new Error("Amount is required to sign a Safe quote");
}
const account = companionAccount ?? client.account;
const spender = account.addressOn(trigger.chainId, true);
const recipient = trigger.recipientAddress || spender;
const { version } = account.deploymentOn(trigger.chainId, true);
const ethForwarderAddress = version.ethForwarderAddress;
const amount = trigger.approvalAmount ?? trigger.amount;
let to;
let value;
let calldata;
if (trigger.tokenAddress === zeroAddress) {
// Native token case: call the ETH forwarder contract
const forwardCalldata = encodeFunctionData({
abi: ForwarderAbi,
functionName: "forward",
args: [recipient]
});
to = ethForwarderAddress;
value = amount;
calldata = forwardCalldata;
}
else {
// ERC20 token case: approve the spender
const approveCalldata = encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [spender, amount]
});
to = trigger.tokenAddress;
value = 0n;
calldata = approveCalldata;
}
// Append the supertxn hash to the calldata
const dataWithHash = concatHex([calldata, quote.hash]);
return {
to,
value: value.toString(),
data: dataWithHash,
operation: OperationType.Call
};
}
/**
* Creates a mock signer that uses the Safe address as the signer address.
*
* This signer is intended for use with `toMultichainNexusAccount` when the
* orchestrator should be owned by a Safe multisig. The actual signing happens
* through the Safe's multisig flow (using protocol-kit), not through this signer.
*
* The returned signer:
* - Has `address` set to the Safe's address
* - Will throw an error if any signing method is called
* - Can only be used with Safe-mode operations (getSafeQuote, signSafeQuote, etc.)
*
* @param safeAddress - The address of the Safe multisig
* @returns A LocalAccount-compatible mock signer with the Safe address
*
* @example
* ```typescript
* import { getMockSafeSigner } from "@biconomy/abstractjs"
*
* const safeAddress = "0x..." // Your Safe multisig address
* const safeSigner = getMockSafeSigner(safeAddress)
*
* // Create a multichain Nexus account owned by the Safe
* const mcNexus = await toMultichainNexusAccount({
* signer: safeSigner,
* chainConfigurations: [
* { chain: baseSepolia, transport: http(), version: getMEEVersion(MEEVersion.V2_3_0) },
* { chain: optimismSepolia, transport: http(), version: getMEEVersion(MEEVersion.V2_3_0) }
* ]
* })
*
* // The Nexus orchestrator is now owned by the Safe
* // Use getSafeQuote and signSafeQuote for transactions
* ```
*/
export const getMockSafeSigner = (safeAddress) => {
return toAccount({
address: safeAddress,
async signMessage(_) {
throw new Error("signMessage is not supported for Safe-owned signer. " +
"Use protocol-kit to sign Safe transactions instead.");
},
async signTransaction(_) {
throw new Error("signTransaction is not supported for Safe-owned signer. " +
"Use protocol-kit to sign Safe transactions instead.");
},
async signTypedData(_) {
throw new Error("signTypedData is not supported for Safe-owned signer. " +
"Use protocol-kit to sign Safe transactions instead.");
}
});
};
export default signSafeQuote;
//# sourceMappingURL=signSafeQuote.js.map