From cba9820b033a954c2cf3ad2b96bc649c29fd8b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffee=20=E2=98=95=EF=B8=8F?= Date: Wed, 25 Mar 2026 17:59:04 -0400 Subject: [PATCH 1/3] feat: add eip 1271 support --- .changeset/five-wombats-grin.md | 9 ++ CLAUDE.md | 2 +- README.md | 2 +- examples/mpp-demo/src/lib/mpp-server.ts | 4 + packages/contracts/script/Deploy.s.sol | 1 - .../contracts/src/AbstractStreamChannel.sol | 43 ++++----- .../test/AbstractStreamChannel.t.sol | 92 ++++++++++++++++++- packages/mpp/src/constants.ts | 33 +++++-- packages/mpp/src/server/charge.ts | 74 ++++++++++----- packages/mpp/src/server/session.ts | 10 +- 10 files changed, 212 insertions(+), 58 deletions(-) create mode 100644 .changeset/five-wombats-grin.md diff --git a/.changeset/five-wombats-grin.md b/.changeset/five-wombats-grin.md new file mode 100644 index 0000000..80198ad --- /dev/null +++ b/.changeset/five-wombats-grin.md @@ -0,0 +1,9 @@ +--- +'@abstract-foundation/mpp': patch +--- + +Add smart-wallet compatibility for Abstract payments. + +- Support ERC-3009 `bytes` signatures for charge flows, including ERC-1271 wallets. +- Verify session vouchers with contract-wallet-compatible typed-data checks. +- Update the Abstract testnet and mainnet `AbstractStreamChannel` defaults to `0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c`. diff --git a/CLAUDE.md b/CLAUDE.md index cfbc3bc..b16591b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,5 +105,5 @@ Examples use **path mappings** in `tsconfig.json` to resolve these from local so | | Testnet (11124) | Mainnet (2741) | |---|---|---| | USDC.e | `0xbd28Bd5A3Ef540d1582828CE2A1a657353008C61` | `0x84A71ccD554Cc1b02749b35d22F684CC8ec987e1` | -| AbstractStreamChannel | `0x331C8Ec3Fefcd2276D9AEA06cD760dE7e5c15fE9` | `0x331C8Ec3Fefcd2276D9AEA06cD760dE7e5c15fE9` | +| AbstractStreamChannel | `0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c` | `0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c` | | RPC | `https://api.testnet.abs.xyz` | `https://api.mainnet.abs.xyz` | diff --git a/README.md b/README.md index 32ba5fa..fdb97fc 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ tsx src/agent.ts | Chain ID | 11124 | 2741 | | RPC | `https://api.testnet.abs.xyz` | `https://api.mainnet.abs.xyz` | | USDC.e | `0xbd28Bd5A3Ef540d1582828CE2A1a657353008C61` | `0x84A71ccD554Cc1b02749b35d22F684CC8ec987e1` | -| AbstractStreamChannel | `0x331C8Ec3Fefcd2276D9AEA06cD760dE7e5c15fE9` | `0x331C8Ec3Fefcd2276D9AEA06cD760dE7e5c15fE9` | +| AbstractStreamChannel | `0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c` | `0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c` | | Explorer | https://explorer.testnet.abs.xyz | https://explorer.abs.xyz | | VM | ZKsync (native AA, FCFS sequencer) | ZKsync | diff --git a/examples/mpp-demo/src/lib/mpp-server.ts b/examples/mpp-demo/src/lib/mpp-server.ts index 49554d1..77ea0bc 100644 --- a/examples/mpp-demo/src/lib/mpp-server.ts +++ b/examples/mpp-demo/src/lib/mpp-server.ts @@ -27,6 +27,9 @@ function getMppx() { const SERVER_PRIVATE_KEY = process.env.SERVER_PRIVATE_KEY as `0x${string}` | undefined const PAY_TO = process.env.NEXT_PUBLIC_PAY_TO as `0x${string}` | undefined const PAYMASTER_ADDRESS = process.env.PAYMASTER_ADDRESS as `0x${string}` | undefined + const ESCROW_CONTRACT = process.env.NEXT_PUBLIC_ESCROW_CONTRACT as + | `0x${string}` + | undefined if (!SECRET_KEY) throw new Error('MPP_SECRET_KEY required') if (!SERVER_PRIVATE_KEY) throw new Error('SERVER_PRIVATE_KEY required') if (!PAY_TO) throw new Error('NEXT_PUBLIC_PAY_TO required') @@ -52,6 +55,7 @@ function getMppx() { account: serverAccount, recipient: PAY_TO, currency: USDC_E_TESTNET, + ...(ESCROW_CONTRACT ? { escrowContract: ESCROW_CONTRACT } : {}), amount: SESSION_AMOUNT, suggestedDeposit: SESSION_DEPOSIT, unitType: 'request', diff --git a/packages/contracts/script/Deploy.s.sol b/packages/contracts/script/Deploy.s.sol index ab795d2..cb61899 100644 --- a/packages/contracts/script/Deploy.s.sol +++ b/packages/contracts/script/Deploy.s.sol @@ -25,7 +25,6 @@ import {AbstractStreamChannel} from "../src/AbstractStreamChannel.sol"; */ contract DeployAbstractStreamChannel is Script { function run() external { - console2.log("Deployer:", msg.sender); console2.log("Chain ID:", block.chainid); diff --git a/packages/contracts/src/AbstractStreamChannel.sol b/packages/contracts/src/AbstractStreamChannel.sol index 2e9bbe9..d38324b 100644 --- a/packages/contracts/src/AbstractStreamChannel.sol +++ b/packages/contracts/src/AbstractStreamChannel.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.20; import {IERC20} from "forge-std-1.15.0/src/interfaces/IERC20.sol"; import {IAbstractStreamChannel} from "./interfaces/IAbstractStreamChannel.sol"; -import {ECDSA} from "solady-0.1.26/src/utils/ECDSA.sol"; import {EIP712} from "solady-0.1.26/src/utils/EIP712.sol"; +import {SignatureCheckerLib} from "solady-0.1.26/src/utils/SignatureCheckerLib.sol"; /** * @title AbstractStreamChannel @@ -114,15 +114,7 @@ contract AbstractStreamChannel is IAbstractStreamChannel, EIP712 { revert AmountNotIncreasing(); } - bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); - bytes32 digest = _hashTypedData(structHash); - address signer = ECDSA.recoverCalldata(digest, signature); - - address expectedSigner = channel.authorizedSigner != address(0) ? channel.authorizedSigner : channel.payer; - - if (signer != expectedSigner) { - revert InvalidSignature(); - } + _assertValidVoucherSignature(channel, channelId, cumulativeAmount, signature); uint128 delta = cumulativeAmount - channel.settled; channel.settled = cumulativeAmount; @@ -230,15 +222,7 @@ contract AbstractStreamChannel is IAbstractStreamChannel, EIP712 { revert AmountExceedsDeposit(); } - bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); - bytes32 digest = _hashTypedData(structHash); - address signer = ECDSA.recoverCalldata(digest, signature); - - address expectedSigner = channel.authorizedSigner != address(0) ? channel.authorizedSigner : channel.payer; - - if (signer != expectedSigner) { - revert InvalidSignature(); - } + _assertValidVoucherSignature(channel, channelId, cumulativeAmount, signature); delta = cumulativeAmount - settledAmount; settledAmount = cumulativeAmount; @@ -340,8 +324,7 @@ contract AbstractStreamChannel is IAbstractStreamChannel, EIP712 { * @notice Compute the EIP-712 digest for a voucher (for off-chain signing). */ function getVoucherDigest(bytes32 channelId, uint128 cumulativeAmount) external view override returns (bytes32) { - bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); - return _hashTypedData(structHash); + return _getVoucherDigest(channelId, cumulativeAmount); } /** @@ -367,4 +350,22 @@ contract AbstractStreamChannel is IAbstractStreamChannel, EIP712 { delete channels[channelId]; channels[channelId].finalized = true; } + + function _assertValidVoucherSignature( + Channel storage channel, + bytes32 channelId, + uint128 cumulativeAmount, + bytes calldata signature + ) internal view { + address expectedSigner = channel.authorizedSigner != address(0) ? channel.authorizedSigner : channel.payer; + bytes32 digest = _getVoucherDigest(channelId, cumulativeAmount); + if (!SignatureCheckerLib.isValidSignatureNowCalldata(expectedSigner, digest, signature)) { + revert InvalidSignature(); + } + } + + function _getVoucherDigest(bytes32 channelId, uint128 cumulativeAmount) internal view returns (bytes32) { + bytes32 structHash = keccak256(abi.encode(VOUCHER_TYPEHASH, channelId, cumulativeAmount)); + return _hashTypedData(structHash); + } } diff --git a/packages/contracts/test/AbstractStreamChannel.t.sol b/packages/contracts/test/AbstractStreamChannel.t.sol index b6f2d85..c90ed03 100644 --- a/packages/contracts/test/AbstractStreamChannel.t.sol +++ b/packages/contracts/test/AbstractStreamChannel.t.sol @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {Test, console2} from "forge-std-1.15.0/src/Test.sol"; +import {Test} from "forge-std-1.15.0/src/Test.sol"; import {AbstractStreamChannel} from "../src/AbstractStreamChannel.sol"; import {IAbstractStreamChannel} from "../src/interfaces/IAbstractStreamChannel.sol"; import {IERC20} from "forge-std-1.15.0/src/interfaces/IERC20.sol"; import {ERC20} from "solady-0.1.26/src/tokens/ERC20.sol"; +import {SignatureCheckerLib} from "solady-0.1.26/src/utils/SignatureCheckerLib.sol"; /// @dev Simple ERC-20 mock for testing. contract MockERC20 is ERC20 { @@ -26,13 +27,49 @@ contract MockERC20 is ERC20 { } } +contract MockERC1271Wallet { + address public immutable owner; + + constructor(address owner_) { + owner = owner_; + } + + function approveToken(IERC20 token, address spender, uint256 amount) external { + token.approve(spender, amount); + } + + function openChannel( + AbstractStreamChannel escrow, + address payee, + address token, + uint128 deposit, + bytes32 salt, + address authorizedSigner + ) external returns (bytes32 channelId) { + return escrow.open(payee, token, deposit, salt, authorizedSigner); + } + + function requestClose(AbstractStreamChannel escrow, bytes32 channelId) external { + escrow.requestClose(channelId); + } + + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { + return SignatureCheckerLib.isValidSignatureNowCalldata(owner, hash, signature) + ? bytes4(0x1626ba7e) + : bytes4(0xffffffff); + } +} + contract AbstractStreamChannelTest is Test { AbstractStreamChannel public escrow; MockERC20 public token; + MockERC1271Wallet public smartWallet; address public payer = makeAddr("payer"); address public payee = makeAddr("payee"); uint256 public payerKey; + address public smartWalletOwner; + uint256 public smartWalletOwnerKey; bytes32 internal constant VOUCHER_TYPEHASH = keccak256("Voucher(bytes32 channelId,uint128 cumulativeAmount)"); @@ -40,14 +77,19 @@ contract AbstractStreamChannelTest is Test { // Give payer a deterministic key so we can sign vouchers payerKey = 0xA11CE; payer = vm.addr(payerKey); + smartWalletOwnerKey = 0xB0B; + smartWalletOwner = vm.addr(smartWalletOwnerKey); escrow = new AbstractStreamChannel(); token = new MockERC20(); + smartWallet = new MockERC1271Wallet(smartWalletOwner); // Fund payer token.mint(payer, 1_000e6); vm.prank(payer); token.approve(address(escrow), type(uint256).max); + token.mint(address(smartWallet), 1_000e6); + smartWallet.approveToken(IERC20(address(token)), address(escrow), type(uint256).max); } // ── Helpers ───────────────────────────────────────────────────────────── @@ -65,6 +107,20 @@ contract AbstractStreamChannelTest is Test { sig = abi.encodePacked(r, s, v); } + function _openSmartWalletChannel(uint128 deposit, bytes32 salt) internal returns (bytes32 channelId) { + channelId = smartWallet.openChannel(escrow, payee, address(token), deposit, salt, address(0)); + } + + function _signSmartWalletVoucher(bytes32 channelId, uint128 cumulativeAmount) + internal + view + returns (bytes memory sig) + { + bytes32 digest = escrow.getVoucherDigest(channelId, cumulativeAmount); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(smartWalletOwnerKey, digest); + sig = abi.encodePacked(r, s, v); + } + // ── Tests ──────────────────────────────────────────────────────────────── function test_DomainName() public view { @@ -198,6 +254,40 @@ contract AbstractStreamChannelTest is Test { assertTrue(escrow.getChannel(channelId).finalized); } + function test_Settle_ERC1271Payer() public { + bytes32 channelId = _openSmartWalletChannel(100e6, keccak256("scw-settle")); + uint128 cumulative = 35e6; + bytes memory sig = _signSmartWalletVoucher(channelId, cumulative); + + uint256 payeeBefore = token.balanceOf(payee); + + vm.prank(payee); + escrow.settle(channelId, cumulative, sig); + + assertEq(token.balanceOf(payee), payeeBefore + cumulative); + + IAbstractStreamChannel.Channel memory ch = escrow.getChannel(channelId); + assertEq(ch.payer, address(smartWallet)); + assertEq(ch.settled, cumulative); + assertFalse(ch.finalized); + } + + function test_Close_ERC1271Payer() public { + bytes32 channelId = _openSmartWalletChannel(100e6, keccak256("scw-close")); + uint128 cumulative = 45e6; + bytes memory sig = _signSmartWalletVoucher(channelId, cumulative); + + uint256 payeeBefore = token.balanceOf(payee); + uint256 payerBefore = token.balanceOf(address(smartWallet)); + + vm.prank(payee); + escrow.close(channelId, cumulative, sig); + + assertEq(token.balanceOf(payee), payeeBefore + cumulative); + assertEq(token.balanceOf(address(smartWallet)), payerBefore + (100e6 - cumulative)); + assertTrue(escrow.getChannel(channelId).finalized); + } + function test_Close_ZeroAmount() public { bytes32 channelId = _openChannel(100e6, keccak256("close-zero")); vm.prank(payee); diff --git a/packages/mpp/src/constants.ts b/packages/mpp/src/constants.ts index 72dbfb6..4a30e4f 100644 --- a/packages/mpp/src/constants.ts +++ b/packages/mpp/src/constants.ts @@ -13,11 +13,14 @@ export const USDC_E_MAINNET = /** USDC.e decimals */ export const USDC_E_DECIMALS = 6; -// ── AbstractStreamChannel (deployed on both testnet & mainnet) ──────────── +// ── AbstractStreamChannel deployments ────────────────────────────────────── -/** AbstractStreamChannel escrow contract — same address on testnet and mainnet. */ -export const ABSTRACT_STREAM_CHANNEL = - '0x331C8Ec3Fefcd2276D9AEA06cD760dE7e5c15fE9' as const; +/** AbstractStreamChannel escrow contract on Abstract Testnet. */ +export const ABSTRACT_STREAM_CHANNEL_TESTNET = + '0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c' as const; +/** AbstractStreamChannel escrow contract on Abstract Mainnet. */ +export const ABSTRACT_STREAM_CHANNEL_MAINNET = + '0x29635C384f451a72ED2e2a312BCeb8b0bDC0923c' as const; // ── ERC-3009 typehash ────────────────────────────────────────────────────── @@ -70,6 +73,24 @@ export const ERC3009_ABI = [ }, ] as const; +export const ERC3009_BYTES_SIGNATURE_ABI = [ + { + name: 'transferWithAuthorization', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [], + }, +] as const; + // ── AbstractStreamChannel ABI ────────────────────────────────────────────── export const ABSTRACT_STREAM_CHANNEL_ABI = [ @@ -283,6 +304,6 @@ export const DEFAULT_CURRENCY = { // ── Default escrow contract map by chainId ─────────────────────────────── export const DEFAULT_ESCROW = { - [abstractTestnet.id]: ABSTRACT_STREAM_CHANNEL, - [abstract.id]: ABSTRACT_STREAM_CHANNEL, + [abstractTestnet.id]: ABSTRACT_STREAM_CHANNEL_TESTNET, + [abstract.id]: ABSTRACT_STREAM_CHANNEL_MAINNET, }; diff --git a/packages/mpp/src/server/charge.ts b/packages/mpp/src/server/charge.ts index a79240f..25ddf1f 100644 --- a/packages/mpp/src/server/charge.ts +++ b/packages/mpp/src/server/charge.ts @@ -19,10 +19,10 @@ import { type Hex, http, type PublicClient, - recoverTypedDataAddress, type Transport, type WalletClient, } from 'viem'; +import { verifyTypedData } from 'viem/actions'; import { abstract, abstractTestnet } from 'viem/chains'; import { type ChainEIP712, @@ -33,6 +33,7 @@ import { abstractChargeMethods } from '../client/methods.js'; import { DEFAULT_CURRENCY, ERC3009_ABI, + ERC3009_BYTES_SIGNATURE_ABI, TRANSFER_WITH_AUTHORIZATION_TYPES, USDC_E_DECIMALS, } from '../constants.js'; @@ -77,6 +78,10 @@ export interface AbstractChargeServerOptions { /** Per-currency ERC-3009 domain cache to avoid redundant RPC calls. */ const domainCache = new Map(); +function isCompactSignature(signature: Hex): boolean { + return (signature.length - 2) / 2 === 65; +} + async function getErc3009Domain( publicClient: PublicClient, currency: Address, @@ -211,7 +216,8 @@ export function charge(params: AbstractChargeServerOptions) { chainId, ); - const recoveredAddress = await recoverTypedDataAddress({ + const verified = await verifyTypedData(publicClient, { + address: from, domain, types: TRANSFER_WITH_AUTHORIZATION_TYPES, primaryType: 'TransferWithAuthorization', @@ -226,10 +232,8 @@ export function charge(params: AbstractChargeServerOptions) { signature, }); - if (recoveredAddress.toLowerCase() !== from.toLowerCase()) { - throw new Error( - `ERC-3009 signature mismatch: recovered ${recoveredAddress}, expected ${from}`, - ); + if (!verified) { + throw new Error('ERC-3009 signature verification failed'); } // Check nonce not already used @@ -242,37 +246,59 @@ export function charge(params: AbstractChargeServerOptions) { if (used) throw new Error('ERC-3009 authorization nonce already used'); - // Split compact 65-byte signature into v/r/s - const sigHex = signature.startsWith('0x') - ? signature.slice(2) - : signature; - const r = `0x${sigHex.slice(0, 64)}` as Hex; - const s = `0x${sigHex.slice(64, 128)}` as Hex; - const v = parseInt(sigHex.slice(128, 130), 16); - - const txArgs = [ + const baseArgs = [ from, recipientAddr, BigInt(amountRaw), BigInt(validAfter), BigInt(validBefore), nonce, - v, - r, - s, ] as const; let txHash: Hex; - if (paymasterAddress) { + if (isCompactSignature(signature)) { + const sigHex = signature.startsWith('0x') + ? signature.slice(2) + : signature; + const r = `0x${sigHex.slice(0, 64)}` as Hex; + const s = `0x${sigHex.slice(64, 128)}` as Hex; + const v = parseInt(sigHex.slice(128, 130), 16); + const txArgs = [...baseArgs, v, r, s] as const; + + if (paymasterAddress) { + txHash = await walletClient.writeContract({ + account, + address: currencyAddr, + abi: ERC3009_ABI, + functionName: 'transferWithAuthorization', + args: txArgs, + chain: null, + ...{ + paymaster: paymasterAddress, + paymasterInput: getGeneralPaymasterInput({ + innerInput: paymasterInput ?? '0x', + }), + }, + }); + } else { + txHash = await walletClient.writeContract({ + account, + address: currencyAddr, + abi: ERC3009_ABI, + functionName: 'transferWithAuthorization', + args: txArgs, + chain: null, + }); + } + } else if (paymasterAddress) { txHash = await walletClient.writeContract({ account, address: currencyAddr, - abi: ERC3009_ABI, + abi: ERC3009_BYTES_SIGNATURE_ABI, functionName: 'transferWithAuthorization', - args: txArgs, + args: [...baseArgs, signature], chain: null, - // ZKsync-native gas sponsorship — no fee-payer service needed ...{ paymaster: paymasterAddress, paymasterInput: getGeneralPaymasterInput({ @@ -284,9 +310,9 @@ export function charge(params: AbstractChargeServerOptions) { txHash = await walletClient.writeContract({ account, address: currencyAddr, - abi: ERC3009_ABI, + abi: ERC3009_BYTES_SIGNATURE_ABI, functionName: 'transferWithAuthorization', - args: txArgs, + args: [...baseArgs, signature], chain: null, }); } diff --git a/packages/mpp/src/server/session.ts b/packages/mpp/src/server/session.ts index 5375dc4..b41cadd 100644 --- a/packages/mpp/src/server/session.ts +++ b/packages/mpp/src/server/session.ts @@ -15,11 +15,11 @@ import { isAddressEqual, type PublicClient, parseUnits, - recoverTypedDataAddress, type Transport, type WalletClient, zeroAddress, } from 'viem'; +import { verifyTypedData } from 'viem/actions'; import { abstract, abstractTestnet } from 'viem/chains'; import { type ChainEIP712, @@ -149,13 +149,15 @@ export interface AbstractSessionServerOptions { // ── Helpers ──────────────────────────────────────────────────────────────── async function verifyVoucherSig( + publicClient: PublicClient, escrowContract: Address, chainId: number, voucher: VoucherRecord, expectedSigner: Address, ): Promise { try { - const recovered = await recoverTypedDataAddress({ + return await verifyTypedData(publicClient, { + address: expectedSigner, domain: { name: VOUCHER_DOMAIN_NAME, version: VOUCHER_DOMAIN_VERSION, @@ -170,7 +172,6 @@ async function verifyVoucherSig( }, signature: voucher.signature, }); - return isAddressEqual(recovered, expectedSigner); } catch { return false; } @@ -371,6 +372,7 @@ export function session(params: AbstractSessionServerOptions) { signature, }; const valid = await verifyVoucherSig( + publicClient, escrowContract, chainId, voucher, @@ -501,6 +503,7 @@ export function session(params: AbstractSessionServerOptions) { signature, }; const valid = await verifyVoucherSig( + publicClient, escrowContract, chainId, voucher, @@ -573,6 +576,7 @@ export function session(params: AbstractSessionServerOptions) { signature, }; const valid = await verifyVoucherSig( + publicClient, escrowContract, chainId, voucher, From c31770b238e7367f4d583356793cfa51693d0c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffee=20=E2=98=95=EF=B8=8F?= Date: Wed, 25 Mar 2026 18:03:16 -0400 Subject: [PATCH 2/3] use viem parseSignature --- packages/mpp/src/server/charge.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/mpp/src/server/charge.ts b/packages/mpp/src/server/charge.ts index 25ddf1f..4646532 100644 --- a/packages/mpp/src/server/charge.ts +++ b/packages/mpp/src/server/charge.ts @@ -18,6 +18,7 @@ import { erc20Abi, type Hex, http, + parseSignature, type PublicClient, type Transport, type WalletClient, @@ -258,13 +259,11 @@ export function charge(params: AbstractChargeServerOptions) { let txHash: Hex; if (isCompactSignature(signature)) { - const sigHex = signature.startsWith('0x') - ? signature.slice(2) - : signature; - const r = `0x${sigHex.slice(0, 64)}` as Hex; - const s = `0x${sigHex.slice(64, 128)}` as Hex; - const v = parseInt(sigHex.slice(128, 130), 16); - const txArgs = [...baseArgs, v, r, s] as const; + const parsed = parseSignature(signature); + if (!('v' in parsed)) { + throw new Error('Expected a 65-byte ECDSA signature'); + } + const txArgs = [...baseArgs, Number(parsed.v), parsed.r, parsed.s] as const; if (paymasterAddress) { txHash = await walletClient.writeContract({ From bf5772b29339075f9b58cb05b53f7fa6bc55dd14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffee=20=E2=98=95=EF=B8=8F?= Date: Wed, 25 Mar 2026 18:23:19 -0400 Subject: [PATCH 3/3] fix: only decompose v/r/s for EOAs even if signature is 65 bytes --- packages/mpp/src/server/charge.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mpp/src/server/charge.ts b/packages/mpp/src/server/charge.ts index 4646532..ca125bc 100644 --- a/packages/mpp/src/server/charge.ts +++ b/packages/mpp/src/server/charge.ts @@ -255,10 +255,12 @@ export function charge(params: AbstractChargeServerOptions) { BigInt(validBefore), nonce, ] as const; + const signerCode = await publicClient.getCode({ address: from }); + const isContractAccount = !!signerCode && signerCode !== '0x'; let txHash: Hex; - if (isCompactSignature(signature)) { + if (!isContractAccount && isCompactSignature(signature)) { const parsed = parseSignature(signature); if (!('v' in parsed)) { throw new Error('Expected a 65-byte ECDSA signature');