@trustvc/eip7702 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # TrustVC EIP-7702 Smart Account
2
+
3
+ Gasless trade document operations on TrustVC via **EIP-7702** (smart account delegation) and **ERC-4337** (account abstraction). Deployed on Sepolia testnet.
4
+
5
+ ## Overview
6
+
7
+ This package provides three contracts:
8
+
9
+ | Contract | Role |
10
+ | --- | --- |
11
+ | `EIP7702Implementation` | Shared smart account logic that EOAs delegate to via EIP-7702 |
12
+ | `PlatformPaymaster` | Per-platform ERC-4337 paymaster — sponsors gas for registry and title escrow operations |
13
+ | `PlatformAccountFactory` | Deploys `PlatformPaymaster` clones cheaply via EIP-1167 (`Clones.cloneDeterministic`) |
14
+
15
+ ## How it works
16
+
17
+ 1. **Delegate** — An EOA signs an EIP-7702 authorization pointing to `EIP7702Implementation`. The EOA's code becomes `0xef0100 || impl_address`, giving it full smart-account capabilities while keeping its original private key.
18
+ 2. **Deploy paymaster** — A platform calls `PlatformAccountFactory.deployPlatformPaymaster()`. A minimal-proxy clone (~55k gas vs ~1.5M for a full deploy) is initialized with the platform's owner address, daily ETH limit, and TDoc deployer.
19
+ 3. **Gasless ops** — Users submit `UserOperation`s through a bundler (Pimlico). The paymaster validates and sponsors:
20
+ - **Path A** — Calls to authorized registries or title escrows (beneficiary/holder/owner only, daily limit enforced)
21
+ - **Path B** — `deployRegistry` (credit-gated) and `mintDocument` (MINTER\_ROLE gated) on the paymaster itself
22
+
23
+ ## Contracts
24
+
25
+ ### EIP7702Implementation
26
+
27
+ EIP-7702 smart account using OpenZeppelin's `Account`, `SignerEIP7702`, and `ERC7821`. Deployed once; EOAs point to it via authorization.
28
+
29
+ - `entryPoint` — immutable, baked into bytecode at deploy time
30
+ - Execution via `ERC7821` batch execute
31
+ - Signature validation: signer must equal `address(this)` (the delegating EOA)
32
+
33
+ ### PlatformPaymaster
34
+
35
+ Per-platform ERC-4337 paymaster cloned from a shared implementation.
36
+
37
+ #### State
38
+
39
+ | Variable | Description |
40
+ | --- | --- |
41
+ | `tdocDeployer` | TDocDeployer contract that mints registry clones |
42
+ | `authorizedRegistries` | Registries whose calls this paymaster will sponsor |
43
+ | `authorizedTitleEscrows` | Title escrows whose calls this paymaster will sponsor |
44
+ | `authorizedCallers` | Addresses (beneficiary/holder) allowed on Path A |
45
+ | `userWhitelist` | Deployment credits per user (max 3) |
46
+ | `documentsMinted` | Count of documents minted per caller |
47
+ | `dailyLimit` | Max ETH sponsored per user per day |
48
+
49
+ #### Key functions
50
+
51
+ - `initialize(owner, dailyLimit, tdocDeployer)` — called once by the factory after clone; guarded by `owner() == address(0)`
52
+ - `deployRegistry(impl, name, symbol)` — deploys a TradeTrust registry, consumes one credit, auto-authorizes it
53
+ - `mintDocument(registry, beneficiary, holder, tokenId, remark)` — mints a document, auto-authorizes beneficiary + holder + title escrow
54
+ - `setUserWhitelist / removeUserFromWhitelist` — manage deployment credits (`onlyOwner`)
55
+ - `addRegistry / removeRegistry` — manage authorized registries (`onlyOwner`)
56
+ - `addTitleEscrow / removeTitleEscrow` — manage authorized title escrows (`onlyOwner`)
57
+ - `addAuthorizedCaller / removeAuthorizedCaller` — manage Path A callers (`onlyOwner`)
58
+ - `setDailyLimit` — update daily spend cap (`onlyOwner`)
59
+ - `getUserDailySpend(user)` — returns `(spent, limit, resetsAt)`
60
+
61
+ ### PlatformAccountFactory
62
+
63
+ Deploys `PlatformPaymaster` clones deterministically.
64
+
65
+ - `deployPlatformPaymaster(platformAddress, dailyLimit, salt)` — clones the implementation and calls `initialize`
66
+ - `computePaymasterAddress(salt)` — predict the clone address before deployment
67
+ - `updateTdocDeployer(addr)` — update TDoc deployer (`onlyOwner`)
68
+ - `updatePaymasterImplementation(addr)` — upgrade the implementation for future clones (`onlyOwner`)
69
+
70
+ ## Deployed addresses (Sepolia)
71
+
72
+ | Contract | Address |
73
+ | --- | --- |
74
+ | EIP7702Implementation | `0xa46EC3920Ac5fc54F4bA33185A91ae250aDF59B8` |
75
+ | PlatformPaymaster (implementation) | `0xa24695178ea881ab7d4d105106e4906a8da4752b` |
76
+ | PlatformAccountFactory | `0x7e9ef6363180baa744eb32ceab367a44f52adc9f` |
77
+ | TDocDeployer | `0x64bc665056DC8bE4092e569ED13a7F273Be28cD2` |
78
+ | EntryPoint v0.8 | `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108` |
79
+
80
+ ## Installation
81
+
82
+ ```bash
83
+ npm install @trustvc/eip7702
84
+ ```
85
+
86
+ ## Development
87
+
88
+ ### Prerequisites
89
+
90
+ - Node.js 18+
91
+ - A Sepolia RPC URL (e.g. Infura)
92
+ - A Pimlico API key for bundler access
93
+
94
+ ### Setup
95
+
96
+ ```bash
97
+ npm install
98
+ cp .env.example .env # fill in PRIVATE_KEY, SEPOLIA_RPC_URL, PIMLICO_API_KEY
99
+ ```
100
+
101
+ ### Build
102
+
103
+ ```bash
104
+ npm run build # compile contracts + generate ABIs + bundle JS
105
+ npm run build:sol # compile contracts only
106
+ npm run build:abis # generate ABI TypeScript files only
107
+ ```
108
+
109
+ ### Test
110
+
111
+ ```bash
112
+ npm test # runs all 45 hardhat tests
113
+ ```
114
+
115
+ ### Deploy (Sepolia)
116
+
117
+ Deploy in order:
118
+
119
+ ```bash
120
+ # 1. Deploy the shared PlatformPaymaster implementation
121
+ npx hardhat run scripts/deployImplementation.ts --network sepolia
122
+
123
+ # 2. Deploy the factory (add PAYMASTER_IMPLEMENTATION to .env first)
124
+ npx hardhat run scripts/deployFactory.ts --network sepolia
125
+
126
+ # 3. Clone a paymaster for your platform (add FACTORY_ADDRESS to .env first)
127
+ npx hardhat run scripts/deployPlatformPaymaster.ts --network sepolia
128
+
129
+ # 4. Stake the paymaster on the EntryPoint
130
+ npx hardhat run scripts/stakePlatformPaymaster.ts --network sepolia
131
+ ```
132
+
133
+ ### Mint a document gaslessly
134
+
135
+ ```bash
136
+ npx hardhat run scripts/mintDocumentGasless.ts --network sepolia
137
+ ```
138
+
139
+ ## Environment variables
140
+
141
+ | Variable | Description |
142
+ | --- | --- |
143
+ | `PRIVATE_KEY` | Deployer wallet private key |
144
+ | `SEPOLIA_RPC_URL` | Sepolia RPC endpoint |
145
+ | `PIMLICO_API_KEY` | Pimlico bundler API key |
146
+ | `TDOC_DEPLOYER_ADDRESS` | Deployed TDocDeployer address |
147
+ | `PAYMASTER_IMPLEMENTATION` | PlatformPaymaster implementation address |
148
+ | `FACTORY_ADDRESS` | PlatformAccountFactory address |
149
+ | `PAYMASTER_ADDRESS` | Deployed paymaster clone address |
150
+ | `EIP7702_IMPL_ADDRESS` | EIP7702Implementation address |
151
+
152
+ ## Tech stack
153
+
154
+ - Solidity `^0.8.28` · OpenZeppelin Contracts v5 · `@account-abstraction/contracts` v0.8
155
+ - Hardhat · hardhat-toolbox-viem · Viem · Pimlico bundler
156
+ - EIP-7702 · ERC-4337 · EIP-1167 minimal proxy · Sepolia testnet
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {Account} from "@openzeppelin/contracts/account/Account.sol";
5
+ import {ERC7821} from "@openzeppelin/contracts/account/extensions/draft-ERC7821.sol";
6
+ import {SignerEIP7702} from "@openzeppelin/contracts/utils/cryptography/signers/SignerEIP7702.sol";
7
+ import {EIP7702Utils} from "@openzeppelin/contracts/account/utils/EIP7702Utils.sol";
8
+ import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
9
+ import {IEntryPoint} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
10
+
11
+ /**
12
+ * EIP-7702 smart account implementation using OpenZeppelin base contracts.
13
+ *
14
+ * Inheritance stack:
15
+ * Account — ERC-4337 validateUserOp, onlyEntryPoint guards, pre-fund payment
16
+ * SignerEIP7702 — _rawSignatureValidation: recovers signer and checks == address(this)
17
+ * ERC7821 — standardized batch execute(bytes32 mode, bytes executionData)
18
+ * ERC165 — supportsInterface
19
+ *
20
+ * EIP-7702 flow: deploy this contract once, then have EOAs sign an authorization
21
+ * pointing to this address. The EOA's code becomes `0xef0100 || address(this)`,
22
+ * giving it full smart-account capabilities while keeping the original private key.
23
+ */
24
+ contract EIP7702Implementation is Account, SignerEIP7702, ERC7821, ERC165 {
25
+ // Baked into bytecode at deploy time so EIP-7702 delegating EOAs inherit
26
+ // the correct EntryPoint without any storage reads.
27
+ IEntryPoint private immutable _entryPoint;
28
+
29
+ // Takes address so callers don't need OZ's IEntryPoint type in scope.
30
+ constructor(address entryPoint_) {
31
+ _entryPoint = IEntryPoint(entryPoint_);
32
+ }
33
+
34
+ function entryPoint() public view virtual override returns (IEntryPoint) {
35
+ return _entryPoint;
36
+ }
37
+
38
+ // -------------------------------------------------------------------------
39
+ // Execution
40
+ // -------------------------------------------------------------------------
41
+
42
+ /**
43
+ * @dev Allow the EntryPoint (in addition to address(this)) to call the
44
+ * ERC-7821 batch execute function. Without this override only the account
45
+ * itself can trigger execution.
46
+ */
47
+ function _erc7821AuthorizedExecutor(
48
+ address caller,
49
+ bytes32 mode,
50
+ bytes calldata executionData
51
+ ) internal view virtual override returns (bool) {
52
+ return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData);
53
+ }
54
+
55
+ /**
56
+ * @dev Convenience single-call path. Only callable by the EntryPoint or the
57
+ * account itself — prevents arbitrary callers from draining funds.
58
+ *
59
+ * For batch calls use the ERC-7821 `execute(bytes32 mode, bytes executionData)`
60
+ * inherited from ERC7821 with mode = 0x0100000000000000000000000000000000000000000000000000000000000000
61
+ * and executionData = abi.encode(calls) where calls is (address,uint256,bytes)[].
62
+ */
63
+ function execute(
64
+ address to,
65
+ uint256 value,
66
+ bytes calldata data
67
+ ) external onlyEntryPointOrSelf returns (bytes memory) {
68
+ (bool ok, bytes memory result) = to.call{value: value}(data);
69
+ require(ok, "execution failed");
70
+ return result;
71
+ }
72
+
73
+ // -------------------------------------------------------------------------
74
+ // ERC-165
75
+ // -------------------------------------------------------------------------
76
+
77
+ /**
78
+ * @dev Advertise ERC-7821 (batch executor) and ERC-165 support.
79
+ * The ERC-7821 interface ID 0x4e49c5c7 is the XOR of:
80
+ * execute(bytes32,bytes).selector ^ supportsExecutionMode(bytes32).selector
81
+ */
82
+ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
83
+ return interfaceId == 0x4e49c5c7 // ERC-7821 batch executor
84
+ || super.supportsInterface(interfaceId); // covers 0x01ffc9a7 (ERC-165)
85
+ }
86
+
87
+ // -------------------------------------------------------------------------
88
+ // Utilities
89
+ // -------------------------------------------------------------------------
90
+
91
+ /**
92
+ * @dev Returns the implementation address this EOA has delegated to via
93
+ * EIP-7702, or address(0) if no delegation is active.
94
+ */
95
+ function getDelegate() external view returns (address) {
96
+ return EIP7702Utils.fetchDelegate(address(this));
97
+ }
98
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import {PlatformPaymaster} from "./PlatformPaymaster.sol";
5
+ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
6
+ import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
7
+
8
+ contract PlatformAccountFactory is Ownable {
9
+ address public tdocDeployer;
10
+ address public paymasterImplementation;
11
+
12
+ event PlatformOnboarded(address indexed platformAddress, address indexed paymaster);
13
+ event TdocDeployerUpdated(address indexed newDeployer);
14
+ event ImplementationUpdated(address indexed newImplementation);
15
+
16
+ constructor(
17
+ address _tdocDeployer,
18
+ address _paymasterImplementation
19
+ ) Ownable(msg.sender) {
20
+ tdocDeployer = _tdocDeployer;
21
+ paymasterImplementation = _paymasterImplementation;
22
+ }
23
+
24
+ function updateTdocDeployer(address _tdocDeployer) external onlyOwner {
25
+ require(_tdocDeployer != address(0), "Zero address");
26
+ tdocDeployer = _tdocDeployer;
27
+ emit TdocDeployerUpdated(_tdocDeployer);
28
+ }
29
+
30
+ function updatePaymasterImplementation(address _impl) external onlyOwner {
31
+ require(_impl != address(0), "Zero address");
32
+ paymasterImplementation = _impl;
33
+ emit ImplementationUpdated(_impl);
34
+ }
35
+
36
+ function deployPlatformPaymaster(
37
+ address platformAddress,
38
+ uint256 dailyLimit,
39
+ bytes32 salt
40
+ ) external returns (address paymaster) {
41
+ paymaster = Clones.cloneDeterministic(paymasterImplementation, salt);
42
+ PlatformPaymaster(payable(paymaster)).initialize(platformAddress, dailyLimit, tdocDeployer);
43
+ emit PlatformOnboarded(platformAddress, paymaster);
44
+ }
45
+
46
+ // Address is determined solely by implementation + salt (not by constructor args).
47
+ function computePaymasterAddress(bytes32 salt) external view returns (address) {
48
+ return Clones.predictDeterministicAddress(paymasterImplementation, salt, address(this));
49
+ }
50
+ }
@@ -0,0 +1,347 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.28;
3
+
4
+ import "@account-abstraction/contracts/core/BasePaymaster.sol";
5
+ import "@account-abstraction/contracts/core/Helpers.sol";
6
+
7
+ interface ITDocDeployer {
8
+ function deploy(
9
+ address implementation,
10
+ bytes memory params
11
+ ) external returns (address);
12
+ }
13
+
14
+ interface ITradeTrustToken {
15
+ function mint(
16
+ address beneficiary,
17
+ address holder,
18
+ uint256 tokenId,
19
+ bytes calldata remark
20
+ ) external returns (address);
21
+ }
22
+
23
+ interface IAccessControl {
24
+ function grantRole(bytes32 role, address account) external;
25
+ function renounceRole(bytes32 role, address callerConfirmation) external;
26
+ }
27
+
28
+ /**
29
+ * PlatformPaymaster — validates by inspecting the target contract in callData.
30
+ *
31
+ * Validation strategy (no third-party storage reads → bundler compliant):
32
+ * 1. Decode userOp.callData → must be execute(address to, uint256, bytes)
33
+ * 2. Check `to` is in authorizedRegistries (paymaster's own storage)
34
+ * 3. Check daily spend (paymaster's own storage — always allowed)
35
+ *
36
+ * Access control is enforced by the Storage contract itself:
37
+ * require(msg.sender == beneficiary || msg.sender == holder)
38
+ * so non-beneficiary/holder callers are rejected at execution time.
39
+ * If the call reverts, _postOp skips the spend update.
40
+ */
41
+ contract PlatformPaymaster is BasePaymaster {
42
+ bytes4 private constant EXECUTE_SEL =
43
+ bytes4(keccak256("execute(address,uint256,bytes)"));
44
+ bytes4 private constant DEPLOY_REGISTRY_SEL =
45
+ bytes4(keccak256("deployRegistry(address,string,string)"));
46
+ bytes4 private constant MINT_DOCUMENT_SEL =
47
+ bytes4(
48
+ keccak256("mintDocument(address,address,address,uint256,bytes)")
49
+ );
50
+
51
+ bytes32 private constant DEFAULT_ADMIN_ROLE = bytes32(0);
52
+
53
+ ITDocDeployer public tdocDeployer;
54
+
55
+ // Paymaster's own storage — always allowed under ERC-7562
56
+ mapping(address => bool) public authorizedRegistries;
57
+ mapping(address => bool) public authorizedTitleEscrows;
58
+ // remaining deployment credits per user; max 3 per user
59
+ mapping(address => uint256) public userWhitelist;
60
+ // Addresses permitted to call title escrow functions via this paymaster
61
+ mapping(address => bool) public authorizedCallers;
62
+ // Count of documents minted per user
63
+ mapping(address => uint256) public documentsMinted;
64
+
65
+ // Daily spend tracking — paymaster's own storage, always allowed
66
+ mapping(address => uint256) public dailySpend;
67
+ mapping(address => uint256) public lastReset;
68
+ uint256 public dailyLimit;
69
+
70
+ event RegistryAdded(address indexed registry);
71
+ event RegistryRemoved(address indexed registry);
72
+ event TitleEscrowLinked(
73
+ address indexed titleEscrow,
74
+ address indexed registry
75
+ );
76
+ event TitleEscrowAdded(address indexed titleEscrow);
77
+ event TitleEscrowRemoved(address indexed titleEscrow);
78
+ event UserWhitelistUpdated(address indexed user, uint256 credits);
79
+ event RegistryDeployed(
80
+ address indexed user,
81
+ address indexed deployed,
82
+ uint256 creditsLeft
83
+ );
84
+ event AuthorizedCallerUpdated(address indexed caller, bool authorized);
85
+ event UserOpSponsored(address indexed user, uint256 gasCost);
86
+ event UserOpRejected(address indexed user, string reason);
87
+ event DailyLimitUpdated(uint256 newLimit);
88
+
89
+ // Deployed once as the shared implementation; BasePaymaster sets owner = msg.sender.
90
+ // That non-zero owner prevents initialize() from running on the implementation itself.
91
+ constructor(IEntryPoint _entryPoint) BasePaymaster(_entryPoint) {}
92
+
93
+ // Called by the factory immediately after cloneDeterministic().
94
+ // A fresh clone has all-zero storage, so owner() == address(0) exactly once.
95
+ function initialize(
96
+ address _owner,
97
+ uint256 _dailyLimit,
98
+ address _tdocDeployer
99
+ ) external {
100
+ require(owner() == address(0), "Already initialized");
101
+ require(_owner != address(0), "Zero owner");
102
+ _transferOwnership(_owner);
103
+ dailyLimit = _dailyLimit;
104
+ tdocDeployer = ITDocDeployer(_tdocDeployer);
105
+ }
106
+
107
+ // Grant/update deployment credits for a user; credits cannot exceed 3
108
+ function setUserWhitelist(
109
+ address user,
110
+ uint256 credits
111
+ ) external onlyOwner {
112
+ require(user != address(0), "Zero address");
113
+ require(credits <= 3, "Exceeds max credits of 3");
114
+ userWhitelist[user] = credits;
115
+ emit UserWhitelistUpdated(user, credits);
116
+ }
117
+
118
+ // add function to remove user from whitelist
119
+ function removeUserFromWhitelist(address user) external onlyOwner {
120
+ require(user != address(0), "Zero address");
121
+ userWhitelist[user] = 0;
122
+ emit UserWhitelistUpdated(user, 0);
123
+ }
124
+
125
+ // Deploys a TDoc clone on behalf of a whitelisted user; consumes one credit.
126
+ // Role setup (atomic, no follow-up txs needed):
127
+ // - msg.sender (EOA) gets DEFAULT_ADMIN_ROLE
128
+ // - paymaster gets MINTER_ROLE + RESTORER_ROLE + ACCEPTER_ROLE
129
+ function deployRegistry(
130
+ address implementation,
131
+ string memory name,
132
+ string memory symbol
133
+ ) external returns (address deployed) {
134
+ require(address(tdocDeployer) != address(0), "TdocDeployer not set");
135
+ uint256 credits = userWhitelist[msg.sender];
136
+ require(credits > 0, "No deployment credits");
137
+
138
+ userWhitelist[msg.sender] = credits - 1;
139
+
140
+ // Paymaster is temporary admin so it can configure roles after deploy
141
+ bytes memory params = abi.encode(name, symbol, address(this));
142
+ deployed = tdocDeployer.deploy(implementation, params);
143
+
144
+ // Hand admin to the calling EOA
145
+ IAccessControl(deployed).grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
146
+
147
+ // Paymaster keeps only the operational roles it needs
148
+ // (MINTER_ROLE, RESTORER_ROLE, ACCEPTER_ROLE were granted to address(this) via initialize)
149
+
150
+ // Relinquish admin — EOA is now sole admin
151
+ IAccessControl(deployed).renounceRole(
152
+ DEFAULT_ADMIN_ROLE,
153
+ address(this)
154
+ );
155
+
156
+ authorizedRegistries[deployed] = true;
157
+ emit RegistryAdded(deployed);
158
+ emit RegistryDeployed(msg.sender, deployed, credits - 1);
159
+ }
160
+
161
+ // Mints a TradeTrust document, captures the TitleEscrow, and auto-authorizes it.
162
+ // Paymaster must hold MINTER_ROLE on the registry.
163
+ // Callable gaslessly by userWhitelist users (credits not consumed — only deployRegistry uses credits).
164
+ function mintDocument(
165
+ address registry,
166
+ address beneficiary,
167
+ address holder,
168
+ uint256 tokenId,
169
+ bytes calldata remark
170
+ ) external returns (address titleEscrow) {
171
+ require(authorizedRegistries[registry], "registry not authorized");
172
+
173
+ titleEscrow = ITradeTrustToken(registry).mint(
174
+ beneficiary,
175
+ holder,
176
+ tokenId,
177
+ remark
178
+ );
179
+
180
+ // Auto-authorize beneficiary and holder to call title escrow functions
181
+ authorizedCallers[beneficiary] = true;
182
+ authorizedCallers[holder] = true;
183
+
184
+ authorizedTitleEscrows[titleEscrow] = true;
185
+ documentsMinted[msg.sender]++;
186
+ emit TitleEscrowLinked(titleEscrow, registry);
187
+ }
188
+
189
+ function addRegistry(address registry) external onlyOwner {
190
+ require(registry != address(0), "Zero address");
191
+ authorizedRegistries[registry] = true;
192
+ emit RegistryAdded(registry);
193
+ }
194
+
195
+ function removeRegistry(address registry) external onlyOwner {
196
+ authorizedRegistries[registry] = false;
197
+ emit RegistryRemoved(registry);
198
+ }
199
+
200
+ function addTitleEscrow(address titleEscrow) external onlyOwner {
201
+ require(titleEscrow != address(0), "Zero address");
202
+ authorizedTitleEscrows[titleEscrow] = true;
203
+ emit TitleEscrowAdded(titleEscrow);
204
+ }
205
+
206
+ function removeTitleEscrow(address titleEscrow) external onlyOwner {
207
+ authorizedTitleEscrows[titleEscrow] = false;
208
+ emit TitleEscrowRemoved(titleEscrow);
209
+ }
210
+
211
+ function addAuthorizedCaller(address caller) external onlyOwner {
212
+ require(caller != address(0), "Zero address");
213
+ authorizedCallers[caller] = true;
214
+ emit AuthorizedCallerUpdated(caller, true);
215
+ }
216
+
217
+ function removeAuthorizedCaller(address caller) external onlyOwner {
218
+ authorizedCallers[caller] = false;
219
+ emit AuthorizedCallerUpdated(caller, false);
220
+ }
221
+
222
+ function setDailyLimit(uint256 _dailyLimit) external onlyOwner {
223
+ dailyLimit = _dailyLimit;
224
+ emit DailyLimitUpdated(_dailyLimit);
225
+ }
226
+
227
+ function _validatePaymasterUserOp(
228
+ PackedUserOperation calldata userOp,
229
+ bytes32, // userOpHash — not needed
230
+ uint256 maxCost
231
+ ) internal override returns (bytes memory context, uint256 validationData) {
232
+ address sender = userOp.sender;
233
+
234
+ // 1. callData must be long enough for selector + one address arg
235
+ if (userOp.callData.length < 36) {
236
+ emit UserOpRejected(sender, "callData too short");
237
+ return ("", _packValidationData(true, 0, 0));
238
+ }
239
+
240
+ // 2. Must call execute(address,uint256,bytes)
241
+ bytes4 sel = bytes4(userOp.callData[:4]);
242
+ if (sel != EXECUTE_SEL) {
243
+ emit UserOpRejected(sender, "wrong selector");
244
+ return ("", _packValidationData(true, 0, 0));
245
+ }
246
+
247
+ // 3. Decode target and inner calldata
248
+ (address target, , bytes memory innerData) = abi.decode(
249
+ userOp.callData[4:],
250
+ (address, uint256, bytes)
251
+ );
252
+
253
+ // Path A — calling an authorized registry or title escrow (regular sponsored op)
254
+ // Only authorizedCallers or the platform owner may be sponsored here.
255
+ if (authorizedRegistries[target] || authorizedTitleEscrows[target]) {
256
+ if (!authorizedCallers[sender] && sender != owner()) {
257
+ emit UserOpRejected(sender, "caller not authorized");
258
+ return ("", _packValidationData(true, 0, 0));
259
+ }
260
+ if (dailyLimit > 0 && dailySpend[sender] + maxCost > dailyLimit) {
261
+ emit UserOpRejected(sender, "daily limit exceeded");
262
+ return ("", _packValidationData(true, 0, 0));
263
+ }
264
+ return (
265
+ abi.encode(sender, maxCost, false),
266
+ _packValidationData(false, 0, 0)
267
+ );
268
+ }
269
+
270
+ // Path B — gasless paymaster call (deployRegistry or mintDocument)
271
+ // Double-spend safe: EntryPoint sequential nonces allow only one
272
+ // pending UserOp per sender in the mempool at a time.
273
+ if (target == address(this)) {
274
+ bytes4 innerSel;
275
+ assembly {
276
+ innerSel := mload(add(innerData, 32))
277
+ }
278
+
279
+ if (innerSel == DEPLOY_REGISTRY_SEL) {
280
+ // deployRegistry requires whitelist credits — platform-level gate
281
+ if (userWhitelist[sender] == 0) {
282
+ emit UserOpRejected(sender, "not whitelisted");
283
+ return ("", _packValidationData(true, 0, 0));
284
+ }
285
+ // credits consumed inside deployRegistry; flag as deployment to skip daily spend
286
+ return (
287
+ abi.encode(sender, maxCost, true),
288
+ _packValidationData(false, 0, 0)
289
+ );
290
+ }
291
+
292
+ if (innerSel == MINT_DOCUMENT_SEL) {
293
+ // mintDocument: registry enforces MINTER_ROLE — no extra whitelist needed
294
+ return (
295
+ abi.encode(sender, maxCost, false),
296
+ _packValidationData(false, 0, 0)
297
+ );
298
+ }
299
+
300
+ emit UserOpRejected(sender, "unauthorized paymaster call");
301
+ return ("", _packValidationData(true, 0, 0));
302
+ }
303
+
304
+ emit UserOpRejected(sender, "unauthorized target");
305
+ return ("", _packValidationData(true, 0, 0));
306
+ }
307
+
308
+ function _postOp(
309
+ PostOpMode mode,
310
+ bytes calldata context,
311
+ uint256 actualGasCost,
312
+ uint256 /*actualUserOpFeePerGas*/
313
+ ) internal override {
314
+ if (mode == PostOpMode.postOpReverted) return;
315
+
316
+ (address sender, , bool isDeployment) = abi.decode(
317
+ context,
318
+ (address, uint256, bool)
319
+ );
320
+
321
+ // Deployment ops are credit-gated; skip daily spend tracking for them
322
+ if (!isDeployment) {
323
+ if (block.timestamp > lastReset[sender] + 1 days) {
324
+ dailySpend[sender] = 0;
325
+ lastReset[sender] = block.timestamp;
326
+ }
327
+ dailySpend[sender] += actualGasCost;
328
+ }
329
+
330
+ emit UserOpSponsored(sender, actualGasCost);
331
+ }
332
+
333
+ // v0.8 contracts on Etherspot EntryPoint — skip interface mismatch check
334
+ function _validateEntryPointInterface(IEntryPoint) internal pure override {}
335
+
336
+ receive() external payable {
337
+ deposit();
338
+ }
339
+
340
+ function getUserDailySpend(
341
+ address user
342
+ ) external view returns (uint256 spent, uint256 limit, uint256 resetsAt) {
343
+ spent = dailySpend[user];
344
+ limit = dailyLimit;
345
+ resetsAt = lastReset[user] + 1 days;
346
+ }
347
+ }