Skip to content

Commit 1609995

Browse files
author
Neo
committed
[sec] OpenMatrixPaymaster: cap + allowlist agent value-calls (close drain hole)
sponsoredCallWithValue let any authorized agent send arbitrary ETH to any target — a compromised agent key could drain the paymaster. Add a per-agent daily value cap (agentDailyCap) + an optional target allowlist (targetAllowlistEnabled/allowedTargets), mirroring the server paymaster policy.allowed_actions. Secure by default: cap = 0 blocks ALL agent value-calls until the owner grants a daily allowance; the owner stays unrestricted. Only the guard changed — no other behavior touched. 6 new forge tests; suite 21 green.
1 parent ec1d64f commit 1609995

2 files changed

Lines changed: 124 additions & 0 deletions

File tree

contracts/OpenMatrixPaymaster.sol

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,26 @@ contract OpenMatrixPaymaster is ReentrancyGuard {
2222
uint256 public totalSponsored;
2323
uint256 public totalTransactions;
2424

25+
// ── Value-call policy (mirrors the server paymaster policy.allowed_actions) ──
26+
// A compromised/authorized AGENT key must not be able to drain funds or hit
27+
// an arbitrary target via sponsoredCallWithValue. The owner is trusted and
28+
// exempt; agents are constrained by a per-agent daily value cap and an
29+
// optional target allowlist. Both default to the tightest setting
30+
// (cap = 0 → agents cannot move value until the owner grants an allowance).
31+
uint256 public agentDailyCap;
32+
bool public targetAllowlistEnabled;
33+
mapping(address => bool) public allowedTargets;
34+
mapping(address => uint256) public agentSpentToday;
35+
mapping(address => uint256) public agentDayStart;
36+
2537
event GasSponsored(address indexed user, uint256 amount, string action);
2638
event AgentAuthorized(address indexed agent);
2739
event AgentRevoked(address indexed agent);
2840
event FundsDeposited(address indexed from, uint256 amount);
2941
event FundsWithdrawn(address indexed to, uint256 amount);
42+
event AgentDailyCapSet(uint256 cap);
43+
event TargetAllowed(address indexed target, bool allowed);
44+
event TargetAllowlistEnabledSet(bool enabled);
3045

3146
modifier onlyOwner() {
3247
require(msg.sender == owner, "Only owner");
@@ -69,15 +84,51 @@ contract OpenMatrixPaymaster is ReentrancyGuard {
6984
}
7085

7186
/// @notice Execute a sponsored call with ETH value
87+
/// @dev The owner is unrestricted. A non-owner AGENT is bound by the target
88+
/// allowlist (when enabled) and a per-agent daily value cap, so a
89+
/// compromised agent key cannot drain funds or reach an arbitrary
90+
/// target. The cap resets on a rolling 1-day bucket.
7291
function sponsoredCallWithValue(address target, bytes calldata data, uint256 value) external onlyAuthorized nonReentrant returns (bytes memory) {
7392
require(address(this).balance >= value, "Insufficient balance");
93+
if (msg.sender != owner && value > 0) {
94+
if (targetAllowlistEnabled) {
95+
require(allowedTargets[target], "Target not allowlisted");
96+
}
97+
uint256 dayStart = block.timestamp - (block.timestamp % 1 days);
98+
if (agentDayStart[msg.sender] != dayStart) {
99+
agentDayStart[msg.sender] = dayStart;
100+
agentSpentToday[msg.sender] = 0;
101+
}
102+
require(agentSpentToday[msg.sender] + value <= agentDailyCap, "Agent daily cap exceeded");
103+
agentSpentToday[msg.sender] += value;
104+
}
74105
totalTransactions++;
75106
totalSponsored += tx.gasprice * gasleft();
76107
(bool success, bytes memory result) = target.call{value: value}(data);
77108
require(success, "Sponsored call failed");
78109
return result;
79110
}
80111

112+
/// @notice Set the per-agent daily value cap (wei). Default 0 blocks all
113+
/// agent value-calls until the owner grants an allowance.
114+
function setAgentDailyCap(uint256 cap) external onlyOwner {
115+
agentDailyCap = cap;
116+
emit AgentDailyCapSet(cap);
117+
}
118+
119+
/// @notice Allow/deny a target for agent value-calls (used when the
120+
/// allowlist is enabled).
121+
function setTargetAllowed(address target, bool allowed) external onlyOwner {
122+
allowedTargets[target] = allowed;
123+
emit TargetAllowed(target, allowed);
124+
}
125+
126+
/// @notice Enable/disable the target allowlist for agent value-calls.
127+
function setTargetAllowlistEnabled(bool enabled) external onlyOwner {
128+
targetAllowlistEnabled = enabled;
129+
emit TargetAllowlistEnabledSet(enabled);
130+
}
131+
81132
/// @notice Authorize an agent address to sponsor gas
82133
function authorizeAgent(address agent) external onlyOwner {
83134
authorizedAgents[agent] = true;

contracts/test/OpenMatrixPaymaster.t.sol

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,79 @@ contract OpenMatrixPaymasterTest is Test {
146146
assertEq(txs, 0);
147147
assertEq(bal, 2 ether);
148148
}
149+
150+
// ── sponsoredCallWithValue agent policy (per-agent daily cap + allowlist) ──
151+
152+
function test_AgentValueCall_BlockedByDefault() public {
153+
// Default cap is 0 → a compromised/authorized agent cannot move value.
154+
vm.deal(address(paymaster), 1 ether);
155+
paymaster.authorizeAgent(agent);
156+
address target = makeAddr("target");
157+
vm.prank(agent);
158+
vm.expectRevert("Agent daily cap exceeded");
159+
paymaster.sponsoredCallWithValue(target, "", 0.1 ether);
160+
}
161+
162+
function test_AgentValueCall_WithinCapSucceeds_ThenCumulativeCapReverts() public {
163+
vm.deal(address(paymaster), 2 ether);
164+
paymaster.authorizeAgent(agent);
165+
paymaster.setAgentDailyCap(1 ether);
166+
address target = makeAddr("target");
167+
vm.prank(agent);
168+
paymaster.sponsoredCallWithValue(target, "", 0.6 ether); // ok
169+
assertEq(paymaster.agentSpentToday(agent), 0.6 ether);
170+
vm.prank(agent);
171+
vm.expectRevert("Agent daily cap exceeded"); // 0.6 + 0.6 > 1.0
172+
paymaster.sponsoredCallWithValue(target, "", 0.6 ether);
173+
}
174+
175+
function test_AgentValueCall_DailyCapResetsNextDay() public {
176+
vm.deal(address(paymaster), 3 ether);
177+
paymaster.authorizeAgent(agent);
178+
paymaster.setAgentDailyCap(1 ether);
179+
address target = makeAddr("target");
180+
vm.prank(agent);
181+
paymaster.sponsoredCallWithValue(target, "", 1 ether); // fills the cap
182+
vm.warp(block.timestamp + 1 days + 1);
183+
vm.prank(agent);
184+
paymaster.sponsoredCallWithValue(target, "", 1 ether); // new day, ok
185+
assertEq(paymaster.agentSpentToday(agent), 1 ether);
186+
}
187+
188+
function test_AgentValueCall_TargetAllowlistEnforced() public {
189+
vm.deal(address(paymaster), 2 ether);
190+
paymaster.authorizeAgent(agent);
191+
paymaster.setAgentDailyCap(1 ether);
192+
paymaster.setTargetAllowlistEnabled(true);
193+
address bad = makeAddr("bad");
194+
address good = makeAddr("good");
195+
vm.prank(agent);
196+
vm.expectRevert("Target not allowlisted");
197+
paymaster.sponsoredCallWithValue(bad, "", 0.1 ether);
198+
paymaster.setTargetAllowed(good, true);
199+
vm.prank(agent);
200+
paymaster.sponsoredCallWithValue(good, "", 0.1 ether); // ok
201+
}
202+
203+
function test_OwnerValueCall_IsUnrestricted() public {
204+
// The owner is trusted: no cap, no allowlist — even with defaults.
205+
vm.deal(address(paymaster), 1 ether);
206+
address target = makeAddr("target");
207+
paymaster.sponsoredCallWithValue(target, "", 0.5 ether); // owner, ok
208+
assertEq(target.balance, 0.5 ether);
209+
}
210+
211+
function test_PolicySetters_OnlyOwner() public {
212+
vm.prank(stranger);
213+
vm.expectRevert("Only owner");
214+
paymaster.setAgentDailyCap(1 ether);
215+
vm.prank(stranger);
216+
vm.expectRevert("Only owner");
217+
paymaster.setTargetAllowlistEnabled(true);
218+
vm.prank(stranger);
219+
vm.expectRevert("Only owner");
220+
paymaster.setTargetAllowed(makeAddr("t"), true);
221+
}
149222
}
150223

151224
/// Owner stand-in whose receive() costs more than the 2300-gas transfer

0 commit comments

Comments
 (0)