Skip to content
This repository was archived by the owner on Apr 19, 2026. It is now read-only.
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
9 changes: 9 additions & 0 deletions .changeset/five-wombats-grin.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
4 changes: 4 additions & 0 deletions examples/mpp-demo/src/lib/mpp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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',
Expand Down
1 change: 0 additions & 1 deletion packages/contracts/script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
43 changes: 22 additions & 21 deletions packages/contracts/src/AbstractStreamChannel.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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);
}
}
92 changes: 91 additions & 1 deletion packages/contracts/test/AbstractStreamChannel.t.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -26,28 +27,69 @@ 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)");

function setUp() public {
// 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 ─────────────────────────────────────────────────────────────
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 27 additions & 6 deletions packages/mpp/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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,
};
Loading
Loading