@trustvc/eip7702 1.1.0-beta.4 → 1.1.0-beta.6

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 CHANGED
@@ -41,7 +41,7 @@ Per-platform ERC-4337 paymaster cloned from a shared implementation.
41
41
  | `tdocDeployer` | TDocDeployer contract that mints registry clones |
42
42
  | `authorizedRegistries` | Registries whose calls this paymaster will sponsor |
43
43
  | `authorizedTitleEscrows` | Title escrows whose calls this paymaster will sponsor |
44
- | `authorizedCallers` | Addresses (beneficiary/holder) allowed on Path A |
44
+ | `authorizedCallers` | Owner-managed addresses allowed to call authorized registries |
45
45
  | `userWhitelist` | Deployment credits per user (max 3) |
46
46
  | `documentsMinted` | Count of documents minted per caller |
47
47
  | `dailyLimit` | Max ETH sponsored per user per day |
@@ -50,11 +50,11 @@ Per-platform ERC-4337 paymaster cloned from a shared implementation.
50
50
 
51
51
  - `initialize(owner, dailyLimit, tdocDeployer)` — called once by the factory after clone; guarded by `owner() == address(0)`
52
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
53
+ - `mintDocument(registry, beneficiary, holder, tokenId, remark)` — mints a document, auto-authorizes the title escrow. Escrow calls are sponsored only for the escrow's current holder/beneficiary (read live at validation) or the platform owner, so sponsorship follows the document across transfers
54
54
  - `setUserWhitelist / removeUserFromWhitelist` — manage deployment credits (`onlyOwner`)
55
55
  - `addRegistry / removeRegistry` — manage authorized registries (`onlyOwner`)
56
56
  - `addTitleEscrow / removeTitleEscrow` — manage authorized title escrows (`onlyOwner`)
57
- - `addAuthorizedCaller / removeAuthorizedCaller` — manage Path A callers (`onlyOwner`)
57
+ - `addAuthorizedCaller / removeAuthorizedCaller` — manage extra registry callers; registry role holders (admin/minter/restorer/accepter) are sponsored without this (`onlyOwner`)
58
58
  - `setDailyLimit` — update daily spend cap (`onlyOwner`)
59
59
  - `getUserDailySpend(user)` — returns `(spent, limit, resetsAt)`
60
60
 
@@ -7,6 +7,7 @@ import {SignerEIP7702} from "@openzeppelin/contracts/utils/cryptography/signers/
7
7
  import {EIP7702Utils} from "@openzeppelin/contracts/account/utils/EIP7702Utils.sol";
8
8
  import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
9
9
  import {IEntryPoint} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
10
+ import {IERC7821} from "@openzeppelin/contracts/interfaces/draft-IERC7821.sol";
10
11
 
11
12
  /**
12
13
  * EIP-7702 smart account implementation using OpenZeppelin base contracts.
@@ -76,11 +77,11 @@ contract EIP7702Implementation is Account, SignerEIP7702, ERC7821, ERC165 {
76
77
 
77
78
  /**
78
79
  * @dev Advertise ERC-7821 (batch executor) and ERC-165 support.
79
- * The ERC-7821 interface ID 0x4e49c5c7 is the XOR of:
80
+ * The ERC-7821 interface ID (0x39922547) is derived from the imported interface:
80
81
  * execute(bytes32,bytes).selector ^ supportsExecutionMode(bytes32).selector
81
82
  */
82
83
  function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
83
- return interfaceId == 0x4e49c5c7 // ERC-7821 batch executor
84
+ return interfaceId == type(IERC7821).interfaceId // ERC-7821 batch executor
84
85
  || super.supportsInterface(interfaceId); // covers 0x01ffc9a7 (ERC-165)
85
86
  }
86
87
 
@@ -8,6 +8,7 @@ import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
8
8
  contract PlatformAccountFactory is Ownable {
9
9
  address public tdocDeployer;
10
10
  address public paymasterImplementation;
11
+ mapping(address => address) public attachedPaymaster;
11
12
 
12
13
  event PlatformOnboarded(
13
14
  address indexed platformAddress,
@@ -54,9 +55,17 @@ contract PlatformAccountFactory is Ownable {
54
55
  dailyLimit,
55
56
  tdocDeployer
56
57
  );
58
+ attachedPaymaster[platformAddress] = paymaster;
57
59
  emit PlatformOnboarded(platformAddress, paymaster);
58
60
  }
59
61
 
62
+ // Returns the paymaster currently attached to a platform (address(0) if none).
63
+ function getAttachedPaymaster(
64
+ address platformAddress
65
+ ) external view returns (address) {
66
+ return attachedPaymaster[platformAddress];
67
+ }
68
+
60
69
  // Predict the clone address for a specific deployer and platform.
61
70
  // `deployer` must be the account that will call deployPlatformPaymaster.
62
71
  function computePaymasterAddress(
@@ -26,19 +26,26 @@ interface IAccessControl {
26
26
  function hasRole(bytes32 role, address account) external view returns (bool);
27
27
  }
28
28
 
29
+ interface ITitleEscrow {
30
+ function holder() external view returns (address);
31
+ function beneficiary() external view returns (address);
32
+ }
33
+
29
34
  /**
30
35
  * PlatformPaymaster — validates by inspecting the target contract in callData.
31
36
  *
32
37
  * Validation strategy (paymaster must be staked → bundler compliant):
33
38
  * 1. Decode userOp.callData → must be execute(address to, uint256, bytes)
34
- * 2. Check `to` is in authorizedRegistries (paymaster's own storage)
35
- * 3. mintDocument only: sender holds MINTER_ROLE on the registry
36
- * (registry storage associated with sender)
37
- * 4. Check daily spend (paymaster's own storage)
39
+ * 2. Check `to` is in authorizedRegistries / authorizedTitleEscrows (paymaster's own storage)
40
+ * 3. Title escrow: sender is the escrow's *current* holder or beneficiary
41
+ * (read live from the escrow — requires the paymaster to be staked, ERC-7562 STO-033)
42
+ * 4. Registry: sender holds any registry role (admin/minter/restorer/accepter) or is an
43
+ * owner-added authorizedCaller (registry storage associated with sender)
44
+ * 5. mintDocument only: sender holds MINTER_ROLE on the registry
45
+ * 6. Check daily spend (paymaster's own storage)
38
46
  *
39
- * Access control is enforced by the Storage contract itself:
40
- * require(msg.sender == beneficiary || msg.sender == holder)
41
- * so non-beneficiary/holder callers are rejected at execution time.
47
+ * Escrow sponsorship follows the document: after a holder/beneficiary transfer the
48
+ * previous party is no longer sponsored and the new one is, with no owner action.
42
49
  * Reverted ops are still charged to dailySpend in _postOp (gas was paid).
43
50
  */
44
51
  contract PlatformPaymaster is BasePaymaster {
@@ -63,7 +70,7 @@ contract PlatformPaymaster is BasePaymaster {
63
70
  mapping(address => bool) public authorizedTitleEscrows;
64
71
  // remaining deployment credits per user; max 3 per user
65
72
  mapping(address => uint256) public userWhitelist;
66
- // Addresses permitted to call title escrow functions via this paymaster
73
+ // Owner-managed addresses permitted to call authorized registries via this paymaster
67
74
  mapping(address => bool) public authorizedCallers;
68
75
  // Count of documents minted per user
69
76
  mapping(address => uint256) public documentsMinted;
@@ -137,8 +144,10 @@ contract PlatformPaymaster is BasePaymaster {
137
144
 
138
145
  // Deploys a TDoc clone on behalf of a whitelisted user; consumes one credit.
139
146
  // Role setup (atomic, no follow-up txs needed):
140
- // - msg.sender (EOA) gets DEFAULT_ADMIN_ROLE
141
- // - paymaster gets MINTER_ROLE + RESTORER_ROLE + ACCEPTER_ROLE
147
+ // - msg.sender (EOA) gets DEFAULT_ADMIN_ROLE + MINTER_ROLE + RESTORER_ROLE + ACCEPTER_ROLE
148
+ // (MINTER_ROLE is checked by mintDocument; restore/accept are called on the registry directly)
149
+ // - paymaster keeps MINTER_ROLE + RESTORER_ROLE + ACCEPTER_ROLE from initialization
150
+ // and renounces DEFAULT_ADMIN_ROLE
142
151
  function deployRegistry(
143
152
  address implementation,
144
153
  string memory name,
@@ -153,6 +162,8 @@ contract PlatformPaymaster is BasePaymaster {
153
162
  // Paymaster is temporary admin so it can configure roles after deploy
154
163
  bytes memory params = abi.encode(name, symbol, address(this));
155
164
  deployed = tdocDeployer.deploy(implementation, params);
165
+ // Don't rely on the deployer: only configure/authorize a deployed contract (also rules out address(0))
166
+ require(deployed.code.length > 0, "invalid registry");
156
167
 
157
168
  IAccessControl(deployed).grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
158
169
  IAccessControl(deployed).grantRole(RESTORER_ROLE, msg.sender);
@@ -170,8 +181,10 @@ contract PlatformPaymaster is BasePaymaster {
170
181
  }
171
182
 
172
183
  // Mints a TradeTrust document, captures the TitleEscrow, and auto-authorizes it.
173
- // Paymaster must hold MINTER_ROLE on the registry.
174
- // Caller must hold MINTER_ROLE on the registry (the deployer, or whoever they granted it to).
184
+ // Paymaster must hold MINTER_ROLE on the registry, and so must the caller
185
+ // (the deployer, or whoever they granted it to).
186
+ // Beneficiary/holder are not granted anything here: escrow sponsorship is checked
187
+ // against the escrow's live holder/beneficiary at validation time.
175
188
  function mintDocument(
176
189
  address registry,
177
190
  address beneficiary,
@@ -185,6 +198,8 @@ contract PlatformPaymaster is BasePaymaster {
185
198
  IAccessControl(registry).hasRole(MINTER_ROLE, msg.sender),
186
199
  "not registry minter"
187
200
  );
201
+ require(beneficiary != address(0), "invalid beneficiary");
202
+ require(holder != address(0), "invalid holder");
188
203
 
189
204
  titleEscrow = ITradeTrustToken(registry).mint(
190
205
  beneficiary,
@@ -192,10 +207,8 @@ contract PlatformPaymaster is BasePaymaster {
192
207
  tokenId,
193
208
  remark
194
209
  );
195
-
196
- // Auto-authorize beneficiary and holder to call title escrow functions
197
- authorizedCallers[beneficiary] = true;
198
- authorizedCallers[holder] = true;
210
+ // Don't rely on the registry: only authorize a deployed escrow (also rules out address(0))
211
+ require(titleEscrow.code.length > 0, "invalid title escrow");
199
212
 
200
213
  authorizedTitleEscrows[titleEscrow] = true;
201
214
  documentsMinted[msg.sender]++;
@@ -266,10 +279,25 @@ contract PlatformPaymaster is BasePaymaster {
266
279
  (address, uint256, bytes)
267
280
  );
268
281
 
269
- // Path A — calling an authorized registry or title escrow (regular sponsored op)
270
- // Only authorizedCallers or the platform owner may be sponsored here.
271
- if (authorizedRegistries[target] || authorizedTitleEscrows[target]) {
272
- if (!authorizedCallers[sender] && sender != owner()) {
282
+ // Path A1 — calling an authorized title escrow.
283
+ // Only its current holder/beneficiary (or the platform owner) is sponsored,
284
+ // so a party that has transferred the document away can't keep using it.
285
+ if (authorizedTitleEscrows[target]) {
286
+ if (sender != owner() && !_isEscrowParty(target, sender)) {
287
+ emit UserOpRejected(sender, "not escrow holder/beneficiary");
288
+ return ("", _packValidationData(true, 0, 0));
289
+ }
290
+ return _sponsorWithinDailyLimit(sender, maxCost);
291
+ }
292
+
293
+ // Path A2 — calling an authorized registry.
294
+ // Any registry role holder, owner-added callers, or the platform owner.
295
+ if (authorizedRegistries[target]) {
296
+ if (
297
+ sender != owner() &&
298
+ !authorizedCallers[sender] &&
299
+ !_hasRegistryRole(target, sender)
300
+ ) {
273
301
  emit UserOpRejected(sender, "caller not authorized");
274
302
  return ("", _packValidationData(true, 0, 0));
275
303
  }
@@ -313,13 +341,22 @@ contract PlatformPaymaster is BasePaymaster {
313
341
  if (innerSel == MINT_DOCUMENT_SEL) {
314
342
  // Mirror mintDocument's checks so non-minters can't get failing ops sponsored.
315
343
  // hasRole reads _roles[role][sender] — storage associated with sender (ERC-7562).
316
- if (innerData.length < 36) {
344
+ // selector + registry + beneficiary + holder
345
+ if (innerData.length < 100) {
317
346
  emit UserOpRejected(sender, "mint calldata too short");
318
347
  return ("", _packValidationData(true, 0, 0));
319
348
  }
320
349
  address registry;
350
+ address beneficiary;
351
+ address holder;
321
352
  assembly {
322
353
  registry := mload(add(innerData, 36))
354
+ beneficiary := mload(add(innerData, 68))
355
+ holder := mload(add(innerData, 100))
356
+ }
357
+ if (beneficiary == address(0) || holder == address(0)) {
358
+ emit UserOpRejected(sender, "invalid beneficiary/holder");
359
+ return ("", _packValidationData(true, 0, 0));
323
360
  }
324
361
  if (!authorizedRegistries[registry]) {
325
362
  emit UserOpRejected(sender, "registry not authorized");
@@ -402,6 +439,46 @@ contract PlatformPaymaster is BasePaymaster {
402
439
  emit UserOpSponsored(sender, actualGasCost);
403
440
  }
404
441
 
442
+ // True if `account` is the escrow's current holder or beneficiary.
443
+ // An escrow that doesn't answer (e.g. a bad owner-added address) is treated as no match.
444
+ function _isEscrowParty(
445
+ address escrow,
446
+ address account
447
+ ) private view returns (bool) {
448
+ return
449
+ _readAddress(escrow, ITitleEscrow.holder.selector) == account ||
450
+ _readAddress(escrow, ITitleEscrow.beneficiary.selector) == account;
451
+ }
452
+
453
+ // Low-level so a target without code (or a reverting one) returns address(0)
454
+ // instead of reverting on ABI decode, which try/catch can't intercept.
455
+ function _readAddress(
456
+ address target,
457
+ bytes4 selector
458
+ ) private view returns (address result) {
459
+ (bool ok, bytes memory ret) = target.staticcall(
460
+ abi.encodeWithSelector(selector)
461
+ );
462
+ if (ok && ret.length >= 32) {
463
+ // Decode as uint256: abi.decode to address reverts on dirty high bits
464
+ uint256 word = abi.decode(ret, (uint256));
465
+ if (word <= type(uint160).max) result = address(uint160(word));
466
+ }
467
+ }
468
+
469
+ // hasRole reads _roles[role][account] — storage associated with the sender (ERC-7562).
470
+ function _hasRegistryRole(
471
+ address registry,
472
+ address account
473
+ ) private view returns (bool) {
474
+ IAccessControl r = IAccessControl(registry);
475
+ return
476
+ r.hasRole(DEFAULT_ADMIN_ROLE, account) ||
477
+ r.hasRole(MINTER_ROLE, account) ||
478
+ r.hasRole(RESTORER_ROLE, account) ||
479
+ r.hasRole(ACCEPTER_ROLE, account);
480
+ }
481
+
405
482
  // Strips the 4-byte selector and ABI-decodes deployRegistry's arguments.
406
483
  function _decodeDeployArgs(bytes memory innerData) private pure {
407
484
  uint256 argsLength = innerData.length - 4;
package/dist/index.d.mts CHANGED
@@ -320,6 +320,20 @@ declare const platformAccountFactoryAbi: readonly [{
320
320
  }];
321
321
  readonly name: "TdocDeployerUpdated";
322
322
  readonly type: "event";
323
+ }, {
324
+ readonly inputs: readonly [{
325
+ readonly internalType: "address";
326
+ readonly name: "";
327
+ readonly type: "address";
328
+ }];
329
+ readonly name: "attachedPaymaster";
330
+ readonly outputs: readonly [{
331
+ readonly internalType: "address";
332
+ readonly name: "";
333
+ readonly type: "address";
334
+ }];
335
+ readonly stateMutability: "view";
336
+ readonly type: "function";
323
337
  }, {
324
338
  readonly inputs: readonly [{
325
339
  readonly internalType: "address";
@@ -364,6 +378,20 @@ declare const platformAccountFactoryAbi: readonly [{
364
378
  }];
365
379
  readonly stateMutability: "nonpayable";
366
380
  readonly type: "function";
381
+ }, {
382
+ readonly inputs: readonly [{
383
+ readonly internalType: "address";
384
+ readonly name: "platformAddress";
385
+ readonly type: "address";
386
+ }];
387
+ readonly name: "getAttachedPaymaster";
388
+ readonly outputs: readonly [{
389
+ readonly internalType: "address";
390
+ readonly name: "";
391
+ readonly type: "address";
392
+ }];
393
+ readonly stateMutability: "view";
394
+ readonly type: "function";
367
395
  }, {
368
396
  readonly inputs: readonly [];
369
397
  readonly name: "owner";
package/dist/index.d.ts CHANGED
@@ -320,6 +320,20 @@ declare const platformAccountFactoryAbi: readonly [{
320
320
  }];
321
321
  readonly name: "TdocDeployerUpdated";
322
322
  readonly type: "event";
323
+ }, {
324
+ readonly inputs: readonly [{
325
+ readonly internalType: "address";
326
+ readonly name: "";
327
+ readonly type: "address";
328
+ }];
329
+ readonly name: "attachedPaymaster";
330
+ readonly outputs: readonly [{
331
+ readonly internalType: "address";
332
+ readonly name: "";
333
+ readonly type: "address";
334
+ }];
335
+ readonly stateMutability: "view";
336
+ readonly type: "function";
323
337
  }, {
324
338
  readonly inputs: readonly [{
325
339
  readonly internalType: "address";
@@ -364,6 +378,20 @@ declare const platformAccountFactoryAbi: readonly [{
364
378
  }];
365
379
  readonly stateMutability: "nonpayable";
366
380
  readonly type: "function";
381
+ }, {
382
+ readonly inputs: readonly [{
383
+ readonly internalType: "address";
384
+ readonly name: "platformAddress";
385
+ readonly type: "address";
386
+ }];
387
+ readonly name: "getAttachedPaymaster";
388
+ readonly outputs: readonly [{
389
+ readonly internalType: "address";
390
+ readonly name: "";
391
+ readonly type: "address";
392
+ }];
393
+ readonly stateMutability: "view";
394
+ readonly type: "function";
367
395
  }, {
368
396
  readonly inputs: readonly [];
369
397
  readonly name: "owner";
package/dist/index.js CHANGED
@@ -438,6 +438,25 @@ var platformAccountFactoryAbi = [
438
438
  "name": "TdocDeployerUpdated",
439
439
  "type": "event"
440
440
  },
441
+ {
442
+ "inputs": [
443
+ {
444
+ "internalType": "address",
445
+ "name": "",
446
+ "type": "address"
447
+ }
448
+ ],
449
+ "name": "attachedPaymaster",
450
+ "outputs": [
451
+ {
452
+ "internalType": "address",
453
+ "name": "",
454
+ "type": "address"
455
+ }
456
+ ],
457
+ "stateMutability": "view",
458
+ "type": "function"
459
+ },
441
460
  {
442
461
  "inputs": [
443
462
  {
@@ -496,6 +515,25 @@ var platformAccountFactoryAbi = [
496
515
  "stateMutability": "nonpayable",
497
516
  "type": "function"
498
517
  },
518
+ {
519
+ "inputs": [
520
+ {
521
+ "internalType": "address",
522
+ "name": "platformAddress",
523
+ "type": "address"
524
+ }
525
+ ],
526
+ "name": "getAttachedPaymaster",
527
+ "outputs": [
528
+ {
529
+ "internalType": "address",
530
+ "name": "",
531
+ "type": "address"
532
+ }
533
+ ],
534
+ "stateMutability": "view",
535
+ "type": "function"
536
+ },
499
537
  {
500
538
  "inputs": [],
501
539
  "name": "owner",
package/dist/index.mjs CHANGED
@@ -436,6 +436,25 @@ var platformAccountFactoryAbi = [
436
436
  "name": "TdocDeployerUpdated",
437
437
  "type": "event"
438
438
  },
439
+ {
440
+ "inputs": [
441
+ {
442
+ "internalType": "address",
443
+ "name": "",
444
+ "type": "address"
445
+ }
446
+ ],
447
+ "name": "attachedPaymaster",
448
+ "outputs": [
449
+ {
450
+ "internalType": "address",
451
+ "name": "",
452
+ "type": "address"
453
+ }
454
+ ],
455
+ "stateMutability": "view",
456
+ "type": "function"
457
+ },
439
458
  {
440
459
  "inputs": [
441
460
  {
@@ -494,6 +513,25 @@ var platformAccountFactoryAbi = [
494
513
  "stateMutability": "nonpayable",
495
514
  "type": "function"
496
515
  },
516
+ {
517
+ "inputs": [
518
+ {
519
+ "internalType": "address",
520
+ "name": "platformAddress",
521
+ "type": "address"
522
+ }
523
+ ],
524
+ "name": "getAttachedPaymaster",
525
+ "outputs": [
526
+ {
527
+ "internalType": "address",
528
+ "name": "",
529
+ "type": "address"
530
+ }
531
+ ],
532
+ "stateMutability": "view",
533
+ "type": "function"
534
+ },
497
535
  {
498
536
  "inputs": [],
499
537
  "name": "owner",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trustvc/eip7702",
3
- "version": "1.1.0-beta.4",
3
+ "version": "1.1.0-beta.6",
4
4
  "description": "EIP-7702 smart account, platform paymaster, and factory contracts for TrustVC",
5
5
  "author": "DexTech",
6
6
  "license": "MIT",