diff --git a/.gitignore b/.gitignore index 6916be933..1b0f12832 100644 --- a/.gitignore +++ b/.gitignore @@ -296,6 +296,8 @@ gv-team.md .windsurf/ .zencoder/ skills/ +!packages/sardis-openclaw/skills/ +!packages/sardis-openclaw/skills/** skills-lock.json AGENTS.md diff --git a/contracts/src/SardisVerifyingPaymaster.sol b/contracts/src/SardisVerifyingPaymaster.sol deleted file mode 100644 index 293655ffa..000000000 --- a/contracts/src/SardisVerifyingPaymaster.sol +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: MIT -// DEPRECATED: This contract has been superseded by Circle Paymaster (permissionless, no deployment needed). -// Circle Paymaster address (all chains): 0x0578cFB241215b77442a541325d6A4E6dFE700Ec -// Kept for reference only — do NOT deploy to mainnet. -pragma solidity ^0.8.20; - -import "@openzeppelin/contracts/access/Ownable.sol"; -import "@openzeppelin/contracts/utils/Pausable.sol"; -import "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; -import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol"; - -/** - * @title SardisVerifyingPaymaster - * @notice ERC-4337 paymaster with wallet allowlist and sponsor caps. - * @dev Validation supports optional off-chain verifier signatures in paymasterData. - */ -contract SardisVerifyingPaymaster is IPaymaster, Ownable, Pausable { - using ECDSA for bytes32; - - IEntryPoint public immutable entryPoint; - - /// @notice Optional verifier signer for sponsorship approvals. - address public verifier; - - /// @notice Max cost allowed per sponsored operation. - uint256 public maxSponsoredCostPerOp; - - /// @notice Daily sponsorship cap for this paymaster. - uint256 public dailySponsorCap; - - /// @notice Amount spent today in wei-equivalent units. - uint256 public spentToday; - - /// @notice Last day index for daily cap accounting. - uint256 public lastSpendDay; - - mapping(address => bool) public allowedWallets; - mapping(address => bool) public allowedTokens; - - event WalletAllowlistUpdated(address indexed wallet, bool allowed); - event TokenAllowlistUpdated(address indexed token, bool allowed); - event SponsorshipCapsUpdated(uint256 maxPerOp, uint256 dailyCap); - event VerifierUpdated(address indexed oldVerifier, address indexed newVerifier); - - error NotEntryPoint(); - error WalletNotAllowed(address wallet); - error CostLimitExceeded(uint256 maxCost, uint256 limit); - error DailyCapExceeded(uint256 maxCost, uint256 remaining); - error InvalidVerifierSignature(); - - constructor(address entryPoint_, address owner_, uint256 maxSponsoredCostPerOp_, uint256 dailySponsorCap_) - Ownable(owner_) - { - require(entryPoint_ != address(0), "entrypoint=0"); - entryPoint = IEntryPoint(entryPoint_); - maxSponsoredCostPerOp = maxSponsoredCostPerOp_; - dailySponsorCap = dailySponsorCap_; - lastSpendDay = block.timestamp / 1 days; - } - - modifier onlyEntryPoint() { - if (msg.sender != address(entryPoint)) revert NotEntryPoint(); - _; - } - - function setWalletAllowed(address wallet, bool allowed) external onlyOwner { - allowedWallets[wallet] = allowed; - emit WalletAllowlistUpdated(wallet, allowed); - } - - function setTokenAllowed(address token, bool allowed) external onlyOwner { - allowedTokens[token] = allowed; - emit TokenAllowlistUpdated(token, allowed); - } - - function setCaps(uint256 maxPerOp, uint256 dailyCap) external onlyOwner { - maxSponsoredCostPerOp = maxPerOp; - dailySponsorCap = dailyCap; - emit SponsorshipCapsUpdated(maxPerOp, dailyCap); - } - - function setVerifier(address newVerifier) external onlyOwner { - address old = verifier; - verifier = newVerifier; - emit VerifierUpdated(old, newVerifier); - } - - function withdrawTo(address payable to, uint256 amount) external onlyOwner { - entryPoint.withdrawTo(to, amount); - } - - function deposit() external payable { - entryPoint.depositTo{ value: msg.value }(address(this)); - } - - /** - * @inheritdoc IPaymaster - */ - function validatePaymasterUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost) - external - override - onlyEntryPoint - whenNotPaused - returns (bytes memory context, uint256 validationData) - { - if (!allowedWallets[userOp.sender]) revert WalletNotAllowed(userOp.sender); - if (maxCost > maxSponsoredCostPerOp) revert CostLimitExceeded(maxCost, maxSponsoredCostPerOp); - - _rollDayIfNeeded(); - uint256 remaining = dailySponsorCap > spentToday ? (dailySponsorCap - spentToday) : 0; - if (maxCost > remaining) revert DailyCapExceeded(maxCost, remaining); - - // Optional paymasterData format: abi.encode(address token, bytes verifierSignature) - if (userOp.paymasterAndData.length >= 52 + 32 + 32) { - bytes calldata paymasterData = ERC4337Utils.paymasterData(userOp); - (address token, bytes memory sig) = abi.decode(paymasterData, (address, bytes)); - if (token != address(0)) { - require(allowedTokens[token], "token_not_allowed"); - } - if (verifier != address(0)) { - bytes32 innerHash = keccak256( - abi.encodePacked(address(this), block.chainid, userOp.sender, token, maxCost, userOpHash) - ); - bytes32 digest = keccak256(abi.encodePacked("\\x19Ethereum Signed Message:\\n32", innerHash)); - if (digest.recover(sig) != verifier) revert InvalidVerifierSignature(); - } - } - - context = abi.encode(userOp.sender, maxCost); - validationData = ERC4337Utils.SIG_VALIDATION_SUCCESS; - } - - /** - * @inheritdoc IPaymaster - */ - function postOp(PostOpMode, bytes calldata context, uint256 actualGasCost, uint256) - external - override - onlyEntryPoint - { - (address sender, uint256 reservedMaxCost) = abi.decode(context, (address, uint256)); - sender; // silence unused var warning while preserving context shape - reservedMaxCost; - - _rollDayIfNeeded(); - spentToday += actualGasCost; - } - - function _rollDayIfNeeded() internal { - uint256 today = block.timestamp / 1 days; - if (today != lastSpendDay) { - lastSpendDay = today; - spentToday = 0; - } - } -} diff --git a/contracts/test/SardisVerifyingPaymaster.t.sol b/contracts/test/SardisVerifyingPaymaster.t.sol deleted file mode 100644 index f97baeba7..000000000 --- a/contracts/test/SardisVerifyingPaymaster.t.sol +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "../src/SardisVerifyingPaymaster.sol"; -import "@openzeppelin/contracts/interfaces/draft-IERC4337.sol"; - -contract SardisVerifyingPaymasterTest is Test { - SardisVerifyingPaymaster internal paymaster; - - address internal wallet = address(0xAAA1); - - function setUp() public { - // this test contract acts as mock EntryPoint. - paymaster = new SardisVerifyingPaymaster(address(this), address(this), 1 ether, 10 ether); - } - - function _buildUserOp(address sender) internal pure returns (PackedUserOperation memory op) { - op.sender = sender; - op.nonce = 0; - op.initCode = ""; - op.callData = ""; - op.accountGasLimits = bytes32(0); - op.preVerificationGas = 0; - op.gasFees = bytes32(0); - op.paymasterAndData = ""; - op.signature = ""; - } - - function testValidatePaymasterUserOpSuccess() public { - paymaster.setWalletAllowed(wallet, true); - - PackedUserOperation memory op = _buildUserOp(wallet); - (bytes memory context, uint256 validationData) = paymaster.validatePaymasterUserOp(op, bytes32("op"), 0.1 ether); - - assertGt(context.length, 0); - assertEq(validationData, 0); - } - - function testValidatePaymasterUserOpRevertsWhenWalletNotAllowed() public { - PackedUserOperation memory op = _buildUserOp(wallet); - vm.expectRevert(); - paymaster.validatePaymasterUserOp(op, bytes32("op"), 0.1 ether); - } - - function testValidatePaymasterUserOpRevertsOnCapExceeded() public { - paymaster.setWalletAllowed(wallet, true); - - PackedUserOperation memory op = _buildUserOp(wallet); - vm.expectRevert(); - paymaster.validatePaymasterUserOp(op, bytes32("op"), 2 ether); - } - - function testPostOpTracksSpend() public { - paymaster.setWalletAllowed(wallet, true); - bytes memory context = abi.encode(wallet, 0.1 ether); - - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, 0.05 ether, 0); - - assertEq(paymaster.spentToday(), 0.05 ether); - } - - // ============ Fuzz: Gas Calculation ============ - - function testFuzz_gasCalculation(uint256 maxCost) public { - // Bound maxCost to reasonable range - maxCost = bound(maxCost, 0, 100 ether); - - paymaster.setWalletAllowed(wallet, true); - - PackedUserOperation memory op = _buildUserOp(wallet); - - if (maxCost > 1 ether) { - // Exceeds maxSponsoredCostPerOp (set to 1 ether in setUp) - vm.expectRevert(); - paymaster.validatePaymasterUserOp(op, bytes32("op"), maxCost); - } else { - // Should succeed; cap check passes - (bytes memory context, uint256 validationData) = - paymaster.validatePaymasterUserOp(op, bytes32("op"), maxCost); - assertGt(context.length, 0); - assertEq(validationData, 0); - } - } - - function testFuzz_postOpAccumulatesSpend(uint256 gasCost1, uint256 gasCost2) public { - gasCost1 = bound(gasCost1, 0, 5 ether); - gasCost2 = bound(gasCost2, 0, 5 ether); - - paymaster.setWalletAllowed(wallet, true); - - bytes memory context = abi.encode(wallet, 1 ether); - - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, gasCost1, 0); - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, gasCost2, 0); - - assertEq(paymaster.spentToday(), gasCost1 + gasCost2); - } - - function testFuzz_dailyCapExhaustion(uint256 maxCost) public { - // Exhaust most of the daily cap, then try one more - maxCost = bound(maxCost, 0.01 ether, 1 ether); - - paymaster.setWalletAllowed(wallet, true); - - // Spend up to the cap via postOp - bytes memory context = abi.encode(wallet, 1 ether); - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, 9.5 ether, 0); - - PackedUserOperation memory op = _buildUserOp(wallet); - - uint256 remaining = 10 ether - 9.5 ether; // 0.5 ether - if (maxCost > remaining) { - vm.expectRevert(); - paymaster.validatePaymasterUserOp(op, bytes32("op"), maxCost); - } else { - (bytes memory ctx,) = paymaster.validatePaymasterUserOp(op, bytes32("op"), maxCost); - assertGt(ctx.length, 0); - } - } - - // ============ Reentrancy: postOp ============ - - function test_reentrancy_postOpDoubleCall() public { - // The paymaster's postOp only tracks spend, no external calls. - // Verify that calling postOp twice doesn't create inconsistency. - paymaster.setWalletAllowed(wallet, true); - - bytes memory context = abi.encode(wallet, 0.5 ether); - - // First call - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, 0.3 ether, 0); - assertEq(paymaster.spentToday(), 0.3 ether); - - // Second call (simulates what would happen in reentrancy) - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, 0.2 ether, 0); - assertEq(paymaster.spentToday(), 0.5 ether); - } - - function test_reentrancy_validateAndPostOpSequence() public { - // Ensure validate followed by postOp correctly tracks state - paymaster.setWalletAllowed(wallet, true); - - PackedUserOperation memory op = _buildUserOp(wallet); - (bytes memory context,) = paymaster.validatePaymasterUserOp(op, bytes32("op"), 0.5 ether); - - // postOp with actual cost less than maxCost - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, 0.3 ether, 0); - assertEq(paymaster.spentToday(), 0.3 ether); - - // Another validate should still have remaining cap - (bytes memory context2,) = paymaster.validatePaymasterUserOp(op, bytes32("op2"), 0.5 ether); - assertGt(context2.length, 0); - } - - // ============ Daily Reset ============ - - function testDailyCapResets() public { - paymaster.setWalletAllowed(wallet, true); - - bytes memory context = abi.encode(wallet, 1 ether); - paymaster.postOp(IPaymaster.PostOpMode.opSucceeded, context, 9 ether, 0); - assertEq(paymaster.spentToday(), 9 ether); - - // Warp to next day - vm.warp(block.timestamp + 1 days); - - // spentToday should reset on next interaction - PackedUserOperation memory op = _buildUserOp(wallet); - (bytes memory ctx,) = paymaster.validatePaymasterUserOp(op, bytes32("op"), 0.5 ether); - assertGt(ctx.length, 0); - } -} diff --git a/contracts/test/mocks/ReentrantToken.sol b/contracts/test/mocks/ReentrantToken.sol index f717e41be..89b41f66c 100644 --- a/contracts/test/mocks/ReentrantToken.sol +++ b/contracts/test/mocks/ReentrantToken.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.24; /// @title ReentrantToken /// @notice Mock ERC20 that calls back into the caller during transfer/transferFrom. -/// @dev Used to test reentrancy resistance in RefundProtocol and SardisVerifyingPaymaster. +/// @dev Used to test reentrancy resistance in RefundProtocol. /// The callback target and calldata are configurable so the same mock can attack /// any external function. contract ReentrantToken { diff --git a/packages/sardis-openclaw/skills/audit/SKILL.md b/packages/sardis-openclaw/skills/audit/SKILL.md new file mode 100644 index 000000000..716acad0f --- /dev/null +++ b/packages/sardis-openclaw/skills/audit/SKILL.md @@ -0,0 +1,26 @@ +--- +name: sardis-audit +description: Teach an OpenClaw agent to inspect Sardis transactions, preserve evidence, and verify authority before reporting payment state. +homepage: https://sardis.sh +user-invocable: false +--- + +# Sardis Audit + +Use this skill only when `SARDIS_API_KEY` is present. If it is missing, state that Sardis audit data is unavailable. + +## Audit Flow + +Use `sardis_list_transactions` to inspect the activity ledger before summarizing payment history, card activity, or budget usage. + +Use `sardis_check_balance` before claiming available spend capacity. + +Use `sardis_check_policy` before saying a future spend is allowed. + +## Reporting Rules + +Report Sardis transaction identifiers, status, merchant, amount, currency, and evidence references when available. + +Distinguish `pending`, `requires_approval`, `denied`, `settled`, and `revoked` states. + +Never claim a payment completed from intent text alone. The ledger status is the source of truth. diff --git a/packages/sardis-openclaw/skills/payments/SKILL.md b/packages/sardis-openclaw/skills/payments/SKILL.md new file mode 100644 index 000000000..ba62f9cc7 --- /dev/null +++ b/packages/sardis-openclaw/skills/payments/SKILL.md @@ -0,0 +1,32 @@ +--- +name: sardis-payments +description: Teach an OpenClaw agent to use Sardis payment verbs with policy checks, reversibility awareness, and fail-closed handling. +homepage: https://sardis.sh +user-invocable: false +--- + +# Sardis Payments + +Use this skill only when `SARDIS_API_KEY` is present. If it is missing, do not create wallets, issue cards, or attempt payments. + +## Payment Verbs + +Use `sardis_give_wallet` to provision the agent payment identity. + +Use `sardis_spend` only after a successful `sardis_check_policy` result. + +Use `sardis_pay_invoice` for invoice-shaped requests instead of manually constructing a payment when invoice metadata is available. + +Use `sardis_issue_card` only when the user has explicitly requested a card and the policy result permits it. + +Use `sardis_freeze_card` when a card is revoked, compromised, outside mandate, or no longer needed. + +## Fail-Closed Rules + +If Sardis returns `requires_approval`, surface the approval requirement and wait. + +If Sardis returns `deny`, stop the payment path. + +If Sardis is unavailable, treat the action as denied until the service is reachable again. + +Never expose raw card data, private keys, API keys, signing secrets, or provider tokens. diff --git a/packages/sardis-openclaw/skills/spending-policy/SKILL.md b/packages/sardis-openclaw/skills/spending-policy/SKILL.md new file mode 100644 index 000000000..399bfc7b1 --- /dev/null +++ b/packages/sardis-openclaw/skills/spending-policy/SKILL.md @@ -0,0 +1,26 @@ +--- +name: sardis-spending-policy +description: Teach an OpenClaw agent to set budgets, check policy before spend, and honor Sardis allow / requires_approval / deny outcomes. +homepage: https://sardis.sh +user-invocable: false +--- + +# Sardis Spending Policy + +Use this skill only when `SARDIS_API_KEY` is present. If it is missing, stop and ask the operator to configure Sardis before attempting any money movement. + +## Required Flow + +1. Create or update a budget with `sardis_set_budget` before the first spend. +2. Run `sardis_check_policy` before every `sardis_spend`. +3. Continue only when the policy result is `allow`. +4. Pause when the result is `requires_approval`. +5. Refuse when the result is `deny`. + +## Guardrails + +Never infer authority from user wording alone. The Sardis policy result is the authority boundary. + +Never split one payment into smaller payments to bypass a budget, approval threshold, merchant block, token block, or time window. + +Never retry a denied action with changed fields unless the operator explicitly changes the budget or mandate first.