@pvium/p2id-core 0.1.2 → 0.2.0

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.
@@ -7,75 +7,28 @@ import {IP2IDPolicy} from "./interfaces/IP2IDPolicy.sol";
7
7
  import {IP2IdVaultFactory} from "./interfaces/IP2IdVaultFactory.sol";
8
8
 
9
9
  /// @title P2IDVault
10
- /// @notice A namespace-bound vault that holds ERC-20 funds until the identity owner claims them.
11
- /// @dev Holds ERC-20 tokens and the chain's native coin (BNB, ETH), the latter identified by
12
- /// token == NATIVE (address(0)) everywhere: in deposits, buckets, sweeps, fees and views.
13
- ///
14
- /// **A bare transfer to this contract (ERC-20 transfer, or plain native send) is irrevocable:
15
- /// it records no funder, has no refund path, and is claimable only through the factory's
16
- /// default verifier.** Use fund() when refund rights, a funding constraint, or a specific
17
- /// verifier are required.
18
- ///
19
- /// This contract holds mechanics only; everything expected to evolve is decided by the
20
- /// factory's policy (IP2IDPolicy), consulted on every call, so it can change without
21
- /// changing this bytecode and therefore without moving any P2ID address. The limits a
22
- /// policy can never cross are enforced here:
23
- /// - fees are capped at MAX_FEE_BPS, fixed per deposit when it is made (a failing quote
24
- /// means no fee), and accrue in the vault per (verifier, token); a payout never calls
25
- /// the policy about fees, so fee handling can never block a claim. withdrawFees hands
26
- /// accrued fees to the current policy, which pulls exactly the approved amount and
27
- /// distributes it;
28
- /// - refunds never pay a fee and never consult the policy;
29
- /// - the policy can gate which verifiers are usable (freezing claims under a disallowed
30
- /// one), but cannot redirect a payout: every claim still pays the wallet the verifier
31
- /// resolved for this vault's identity. Claims through the factory's current default
32
- /// verifier can never be frozen: direct transfers have no refund path, so the only thing
33
- /// that may change who receives them is a default-verifier change with 14 days' notice.
34
- /// (The policy still gates *funding* under the default, so a revoked default takes no
35
- /// new deposits.)
36
- ///
37
- /// Verifiers. Every deposit names the IP2IDVerifier whose proofs can release it, chosen by
38
- /// the payer among those the policy allows (fund() takes the factory default). Every claim
39
- /// path hands the verifier this vault's `saltCommitment` plus a proof and gets back the
40
- /// wallet to pay; the verifier reverts unless the proof is for exactly that identity.
41
- /// A constrained deposit is refused under a verifier that cannot satisfy constraints.
42
- ///
43
- /// Accounting is per bucket, where a bucket is (verifier, constraint, token) and the
44
- /// default bucket of a verifier is constraint == bytes32(0). Each bucket keeps its own
45
- /// deposit-id list and cursor, so sweeps are bounded per bucket, can be paged
46
- /// (`depositCountLimit` = number of deposit records visited from the cursor, consumed or
47
- /// not; 0 = all) or aimed at specific ids, and spam in other tokens or buckets never blocks
48
- /// a sweep.
49
- ///
50
- /// Proof freshness ratchet, per verifier: every proof presented must be at least as fresh
51
- /// as the newest one this vault has seen under that verifier, and a newer proof becomes the
52
- /// owner proof, so presenting a proof for a new wallet retires every older proof. Funds
53
- /// already inside before that reset are not protected: reset immediately after a wallet
54
- /// compromise.
55
- ///
56
- /// Direct transfers follow the factory's default verifier, which governance can change after 14
57
- /// days' public notice. So that a change cannot bring back a wallet the owner has already moved away from,
58
- /// direct transfers have their own freshness floor, `untrackedProofIat`: the newest proof
59
- /// presented through whichever verifier was the default at the time. It carries across a
60
- /// default change, and direct transfers are paid only to a default-verifier owner whose proof
61
- /// is at least that fresh. Only the default verifier (chosen by governance, under the
62
- /// timelock) can raise it, so a verifier payers merely opted into cannot affect it.
63
- ///
64
- /// Invariant kept by every path: token balance >= trackedTotal[token] + feesOwedTotal[token].
10
+ /// @notice Vault implementation for ERC-20 and native-coin deposits associated with an identity.
11
+ /// Uses its immutable factory for policy and default-verifier settings.
12
+ /// @dev Deposits are indexed by (verifier, constraint, token). Cursor-based sweeps visit at most
13
+ /// MAX_SWEEP_PAGE records; explicit-id sweeps iterate the supplied list. Owner caches and
14
+ /// proof floors are per verifier, with an additional cross-default floor for untracked funds.
15
+ /// Direct transfers create no deposit record and have no refund entry point. Token accounting
16
+ /// relies on the token's reported balances and transfer behavior.
65
17
  contract P2IDVault is IP2IDVault {
66
- /// @notice Hard cap on the fee any policy can charge on a payout: 1%. Part of this bytecode.
18
+ /// @notice Maximum fee rate recorded or quoted by this implementation: 100 basis points (1%).
67
19
  uint16 public constant MAX_FEE_BPS = 100;
68
- /// @notice The token address that stands for the chain's native coin (BNB on BNB Chain).
20
+ /// @notice Token identifier used for the native coin.
69
21
  address public constant NATIVE = address(0);
70
22
  uint256 private constant BPS = 10_000;
71
- /// @dev Gas given to optional policy/verifier queries, so a misbehaving contract cannot burn
72
- /// the caller's gas; a query that fails or runs out is treated as "no fee" / "unsupported".
23
+ /// @dev Gas cap for _query calls. Failed fee/support queries return zero/false; failed revision
24
+ /// queries invalidate cached-owner reads and prevent proof updates.
73
25
  uint256 private constant QUERY_GAS = 50_000;
74
- /// @dev How far ahead of the block a proof's issue time may be (clock skew between the token
75
- /// issuer and the chain); proofs are made after their token is issued, so this is slack only.
26
+ /// @notice Record limit for cursor-based sweeps; a zero or larger requested limit uses this value.
27
+ uint256 public constant MAX_SWEEP_PAGE = 100;
28
+ /// @dev Maximum accepted issue time ahead of block.timestamp.
76
29
  uint64 private constant FUTURE_SLACK = 15 minutes;
77
30
 
78
- /// @notice Deployer (PviumP2IdVaultFactory): the policy's source, and the only caller of initialize() and fundFor().
31
+ /// @notice Factory supplied at construction; source of settings and authorized caller of initialize()/fundFor().
79
32
  address public immutable factory;
80
33
  bytes32 public nsHash;
81
34
  bytes32 public saltCommitment;
@@ -83,18 +36,17 @@ contract P2IDVault is IP2IDVault {
83
36
  uint64 public maxRefundWindow;
84
37
  bool private _initialized;
85
38
 
86
- /// @notice Wallet of the newest proof presented under a verifier; paid by that verifier's default-bucket sweeps.
39
+ /// @notice Cached payout wallet per verifier. Equal-time proofs do not replace an existing wallet.
87
40
  mapping(address verifier => address) public owner;
88
- /// @notice Issue time of that proof. Older proofs are refused under that verifier on every path.
41
+ /// @notice Stored issue-time floor checked by _apply for this verifier.
89
42
  mapping(address verifier => uint64) public latestProofIat;
90
- /// @notice The verifier's `revision()` when its owner was cached. A cache from an older revision
91
- /// is void: the verifier revoked something it trusted then, so the owner must re-prove.
43
+ /// @notice Revision stored with the cached owner; _ownerFresh requires equality with the current revision.
92
44
  mapping(address verifier => uint64) public ownerRevision;
93
45
  /// @notice Freshness floor for direct transfers: the newest proof presented through the verifier
94
46
  /// that was the default at the time. Carries across default changes.
95
47
  uint64 public untrackedProofIat;
96
48
 
97
- /// @notice All deposits ever made, by id.
49
+ /// @notice Deposit records, including consumed records, indexed by id.
98
50
  Deposit[] public deposits;
99
51
 
100
52
  /// @notice Unconsumed total per (verifier, constraint, token) bucket.
@@ -105,16 +57,16 @@ contract P2IDVault is IP2IDVault {
105
57
  mapping(address verifier => mapping(bytes32 constraint => mapping(address token => uint256[]))) private _bucketDeposits;
106
58
  /// @notice Index into the bucket's deposit list below which every deposit is consumed.
107
59
  mapping(address verifier => mapping(bytes32 constraint => mapping(address token => uint256))) public bucketCursor;
108
- /// @dev keccak256(constraint, funder) => depositId + 1. A funder can use a constraint once, so a
109
- /// signature over it releases at most one of their deposits; another funder reusing the
110
- /// same value only puts their own money in the bucket.
60
+ /// @dev keccak256(abi.encode(constraint, funder)) => depositId + 1 for nonzero constraints.
61
+ /// Entries persist after claims and refunds, preventing reuse by the same funder in this vault.
111
62
  mapping(bytes32 => uint256) private _constraintDeposit;
112
63
  /// @notice Fees accrued and not yet distributed, per verifier they were earned through and token.
113
64
  mapping(address verifier => mapping(address token => uint256)) public feesOwed;
114
65
  /// @notice Sum of feesOwed per token: held for distribution, never part of a payout.
115
66
  mapping(address token => uint256) public feesOwedTotal;
116
67
 
117
- uint256 private reentrancyLock = 1;
68
+ /// @dev 2 while a guarded call runs; any other value (including the 0 a fresh proxy starts with) is idle.
69
+ uint256 private reentrancyLock;
118
70
 
119
71
  error InvalidRefundWindow();
120
72
  error VerifierNotApproved(address verifier);
@@ -123,6 +75,9 @@ contract P2IDVault is IP2IDVault {
123
75
  error InvalidToken();
124
76
  error OwnerNotInitialized();
125
77
  error OwnerRevoked();
78
+ error RevisionUnavailable(address verifier);
79
+ error NotSelf();
80
+ error NotOwnerProof(address owner);
126
81
  error ProofFromFuture();
127
82
  error ProofTooOld();
128
83
  error InvalidWallet();
@@ -142,19 +97,18 @@ contract P2IDVault is IP2IDVault {
142
97
  error NativeValueMismatch();
143
98
  error NativeTransferFailed();
144
99
 
145
- /// @dev No constructor arguments, so the creation code is identical for every vault and
146
- /// `keccak256(creationCode)` is a constant anyone can use to derive an identity's vault
147
- /// address from the factory address. The deployer is recorded and must call initialize().
148
- constructor() {
149
- factory = msg.sender;
100
+ /// @dev This constructor runs only for the implementation; each proxy's storage is set up
101
+ /// separately by the factory calling initialize(). Address derivation uses proxy creation code.
102
+ /// @param _factory Address embedded in this implementation for internal factory references.
103
+ constructor(address _factory) {
104
+ factory = _factory;
150
105
  }
151
106
 
152
- /// @notice Plain native-coin sends (e.g. fee payouts from a launchpad) are accepted as direct
153
- /// transfers: untracked, claimable through the default verifier, never refundable.
154
- /// Deliberately empty, so it works with the 2300-gas stipend of `transfer`/`send`.
107
+ /// @notice Accept native coin without creating a deposit record.
108
+ /// @dev Calls to a vault proxy with empty calldata are handled by the proxy's own receive().
155
109
  receive() external payable {}
156
110
 
157
- /// @notice Set by the factory once, immediately after deployment.
111
+ /// @notice Initialize vault settings once; callable only by the configured factory.
158
112
  function initialize(
159
113
  bytes32 _nsHash,
160
114
  bytes32 _saltCommitment,
@@ -170,6 +124,172 @@ contract P2IDVault is IP2IDVault {
170
124
  maxRefundWindow = _maxRefundWindow;
171
125
  }
172
126
 
127
+ // ------------------------------------------------------------------ alpha authorization
128
+
129
+ /// @notice Alpha authorization nonces already consumed by this vault (caller-chosen, single use).
130
+ /// @dev Preserve this mapping and append later storage after the cache bindings below.
131
+ mapping(uint256 => bool) public alphaNonceUsed;
132
+ mapping(address => bytes32) public ownerVkHash;
133
+ mapping(address => uint256) public ownerAlphaRevision;
134
+ error AlphaAuthorizationRequired();
135
+ error InvalidVkHash();
136
+ error InvalidAlphaCall();
137
+ error AlphaNonceAlreadyUsed(uint256 nonce);
138
+
139
+ function supportsAlphaGuard() external pure returns (bool) { return true; }
140
+
141
+ function executeWithAttestation(bytes calldata action, AlphaAttestation calldata attestation) external validAlphaCall(action) returns (bytes memory) {
142
+ return _dispatchAlphaCall(action, attestation);
143
+ }
144
+
145
+ function _dispatchAlphaCall(bytes calldata action, AlphaAttestation memory attestation) private returns (bytes memory) {
146
+ if (action.length < 4) revert InvalidAlphaCall();
147
+ bytes4 selector = bytes4(action[:4]);
148
+ if (selector == IP2IDVault.refreshProof.selector) {
149
+ (address verifier, bytes memory proof) = abi.decode(action[4:], (address, bytes));
150
+ refreshProofWithAttestation(verifier, proof, attestation);
151
+ return hex"";
152
+ }
153
+ if (selector == IP2IDVault.refreshProofAndSweep.selector) {
154
+ (address verifier, bytes memory proof, address token, uint256 depositCountLimit) = abi.decode(action[4:], (address, bytes, address, uint256));
155
+ (uint256 amount, uint256 consumed) = refreshProofAndSweepWithAttestation(verifier, proof, token, depositCountLimit, attestation);
156
+ return abi.encode(amount, consumed);
157
+ }
158
+
159
+ if (selector == IP2IDVault.sweepBucket.selector) {
160
+ (address verifier, IP2IDVerifier.Constraint memory constraint, address token, bytes memory proof, uint256 depositCountLimit) = abi.decode(action[4:], (address, IP2IDVerifier.Constraint, address, bytes, uint256));
161
+ (uint256 amount, uint256 consumed) = sweepBucketWithAttestation(verifier, constraint, token, proof, depositCountLimit, attestation);
162
+ return abi.encode(amount, consumed);
163
+ }
164
+ if (selector == IP2IDVault.sweepBucketDeposits.selector) {
165
+ (address verifier, IP2IDVerifier.Constraint memory constraint, address token, uint256[] memory depositIds, bytes memory proof) = abi.decode(action[4:], (address, IP2IDVerifier.Constraint, address, uint256[], bytes));
166
+ return abi.encode(sweepBucketDepositsWithAttestation(verifier, constraint, token, depositIds, proof, attestation));
167
+ }
168
+
169
+ revert InvalidAlphaCall();
170
+ }
171
+
172
+ function refreshProofWithAttestation(address verifier, bytes memory proof, AlphaAttestation memory attestation)
173
+ public nonReentrant
174
+ alphaAttestationRequired(verifier, attestation, abi.encodeCall(IP2IDVault.refreshProof, (verifier, proof)))
175
+ {
176
+ _refreshProof(verifier, proof);
177
+ }
178
+
179
+ function _refreshProof(address verifier, bytes memory proof) private {
180
+ _present(verifier, proof, _noConstraint());
181
+ }
182
+
183
+ function refreshProofAndSweepWithAttestation(address verifier, bytes memory proof, address token, uint256 depositCountLimit, AlphaAttestation memory attestation)
184
+ public nonReentrant
185
+ alphaAttestationRequired(verifier, attestation, abi.encodeCall(IP2IDVault.refreshProofAndSweep, (verifier, proof, token, depositCountLimit)))
186
+ returns (uint256 amount, uint256 consumed)
187
+ {
188
+ return _refreshProofAndSweep(verifier, proof, token, depositCountLimit);
189
+ }
190
+
191
+ function _refreshProofAndSweep(address verifier, bytes memory proof, address token, uint256 depositCountLimit) private returns (uint256 amount, uint256 consumed) {
192
+ _present(verifier, proof, _noConstraint());
193
+ return _sweepDefault(verifier, token, depositCountLimit);
194
+ }
195
+
196
+ function _sweep(address verifier, address token, uint256 depositCountLimit) private returns (uint256 amount, uint256 consumed) {
197
+ return _sweepDefault(verifier, token, depositCountLimit); // checks the owner is set and current
198
+ }
199
+
200
+ function _sweepUntracked(address token) private returns (uint256 amount) {
201
+ address verifier = defaultVerifier();
202
+ address to = _ownerOf(verifier);
203
+ if (latestProofIat[verifier] < untrackedProofIat) revert ProofTooOld();
204
+ uint256 gross = _untrackedBalance(token);
205
+ uint256 fee = (gross * _quoteFeeBps(verifier, token)) / BPS;
206
+ uint256 charged;
207
+ (amount, charged) = _payout(verifier, token, to, gross, fee);
208
+ emit Swept(verifier, token, amount, charged, to, 0);
209
+ }
210
+
211
+ function _sweepDeposits(address verifier, address token, uint256[] memory depositIds) private onlyInitialized(verifier) returns (uint256 amount) {
212
+ address to = owner[verifier];
213
+ (uint256 gross, uint256 fee) = _consumeIds(verifier, bytes32(0), token, depositIds, to);
214
+ uint256 charged;
215
+ (amount, charged) = _payout(verifier, token, to, gross, fee);
216
+ emit Swept(verifier, token, amount, charged, to, depositIds.length);
217
+ }
218
+
219
+ function sweepBucketWithAttestation(address verifier, IP2IDVerifier.Constraint memory constraint, address token, bytes memory proof, uint256 depositCountLimit, AlphaAttestation memory attestation)
220
+ public nonReentrant
221
+ alphaAttestationRequired(verifier, attestation, abi.encodeCall(IP2IDVault.sweepBucket, (verifier, constraint, token, proof, depositCountLimit)))
222
+ returns (uint256 amount, uint256 consumed)
223
+ {
224
+ return _sweepBucket(verifier, constraint, token, proof, depositCountLimit);
225
+ }
226
+
227
+ function _sweepBucket(address verifier, IP2IDVerifier.Constraint memory constraint, address token, bytes memory proof, uint256 depositCountLimit) private returns (uint256 amount, uint256 consumed) {
228
+ address to = _verifyForThisVault(verifier, proof, constraint);
229
+ uint256 gross;
230
+ uint256 fee;
231
+ (gross, fee, consumed) = _consumeFromCursor(verifier, constraint.commitment, token, depositCountLimit, to);
232
+ uint256 charged;
233
+ (amount, charged) = _payout(verifier, token, to, gross, fee);
234
+ emit SweptBucket(verifier, constraint.commitment, token, amount, charged, to, consumed);
235
+ }
236
+
237
+ function sweepBucketDepositsWithAttestation(address verifier, IP2IDVerifier.Constraint memory constraint, address token, uint256[] memory depositIds, bytes memory proof, AlphaAttestation memory attestation)
238
+ public nonReentrant
239
+ alphaAttestationRequired(verifier, attestation, abi.encodeCall(IP2IDVault.sweepBucketDeposits, (verifier, constraint, token, depositIds, proof)))
240
+ returns (uint256 amount)
241
+ {
242
+ return _sweepBucketDeposits(verifier, constraint, token, depositIds, proof);
243
+ }
244
+
245
+ function _sweepBucketDeposits(address verifier, IP2IDVerifier.Constraint memory constraint, address token, uint256[] memory depositIds, bytes memory proof) private returns (uint256 amount) {
246
+ address to = _verifyForThisVault(verifier, proof, constraint);
247
+ (uint256 gross, uint256 fee) = _consumeIds(verifier, constraint.commitment, token, depositIds, to);
248
+ uint256 charged;
249
+ (amount, charged) = _payout(verifier, token, to, gross, fee);
250
+ emit SweptBucket(verifier, constraint.commitment, token, amount, charged, to, depositIds.length);
251
+ }
252
+
253
+ function _withdrawFees(address verifier, address token) private returns (uint256 amount) {
254
+ uint256 owed = feesOwed[verifier][token];
255
+ if (owed == 0) return 0;
256
+ address pol = policy();
257
+ if (token == NATIVE) {
258
+ // Native coin cannot be pulled: it is sent along with the call, exactly the amount owed.
259
+ IP2IDPolicy(pol).distributeFee{value: owed}(verifier, token, owed);
260
+ amount = owed;
261
+ } else {
262
+ uint256 before = _balanceOf(token);
263
+ _callToken(token, abi.encodeWithSignature("approve(address,uint256)", pol, owed));
264
+ IP2IDPolicy(pol).distributeFee(verifier, token, owed);
265
+ _callToken(token, abi.encodeWithSignature("approve(address,uint256)", pol, 0));
266
+ uint256 afterBalance = _balanceOf(token);
267
+ amount = before > afterBalance ? before - afterBalance : 0;
268
+ if (amount > owed) revert FeeOverdrawn();
269
+ }
270
+ feesOwed[verifier][token] = owed - amount;
271
+ feesOwedTotal[token] -= amount;
272
+ emit FeesDistributed(verifier, token, amount, pol);
273
+ }
274
+
275
+ modifier validAlphaCall(bytes calldata action) {
276
+ if (action.length < 4) revert InvalidAlphaCall();
277
+ bytes4 selector = bytes4(action[:4]);
278
+ if (!(selector == IP2IDVault.refreshProof.selector || selector == IP2IDVault.refreshProofAndSweep.selector || selector == IP2IDVault.sweepBucket.selector || selector == IP2IDVault.sweepBucketDeposits.selector)) revert InvalidAlphaCall();
279
+ _;
280
+ }
281
+
282
+ /// @dev Empty signatures are allowed only after alpha. Bind approval to the entire ordinary call.
283
+ modifier alphaAttestationRequired(address verifier, AlphaAttestation memory attestation, bytes memory action) {
284
+ if (IP2IdVaultFactory(factory).isAlpha(IP2IDVerifier(verifier).vkHash())) {
285
+ if (attestation.signature.length == 0) revert AlphaAuthorizationRequired();
286
+ if (alphaNonceUsed[attestation.nonce]) revert AlphaNonceAlreadyUsed(attestation.nonce);
287
+ IP2IdVaultFactory(factory).verifyAlphaAuthorization(address(this), msg.sender, keccak256(action), attestation.nonce, attestation.deadline, attestation.signature);
288
+ alphaNonceUsed[attestation.nonce] = true;
289
+ }
290
+ _;
291
+ }
292
+
173
293
  // ------------------------------------------------------------------ funding
174
294
 
175
295
  /// @notice Fund under the factory's default verifier.
@@ -201,9 +321,8 @@ contract P2IDVault is IP2IDVault {
201
321
  return _fund(msg.sender, verifier, token, amount, constraint, refundWindow, ref);
202
322
  }
203
323
 
204
- /// @notice Factory-only: fund on behalf of `funder`, who keeps the refund right. Tokens are
205
- /// pulled from the factory, which has already collected them from `funder`; native
206
- /// coin arrives as msg.value.
324
+ /// @notice Factory-only: record a deposit for `funder`. ERC-20 tokens are pulled from the
325
+ /// factory; native coin is supplied as msg.value.
207
326
  /// @param ref Opaque application reference emitted in Funded; bytes32(0) for none.
208
327
  function fundFor(
209
328
  address funder,
@@ -218,8 +337,8 @@ contract P2IDVault is IP2IDVault {
218
337
  return _fund(funder, verifier, token, amount, constraint, refundWindow, ref);
219
338
  }
220
339
 
221
- /// @dev Take `amount` of `token` from msg.sender (native: exactly msg.value; ERC-20: pulled,
222
- /// crediting what actually arrived) and record a deposit owned by `funder`, with the fee
340
+ /// @dev Receive native msg.value or pull ERC-20 from msg.sender, crediting the reported balance
341
+ /// increase for ERC-20. Record a deposit owned by `funder`, with the fee
223
342
  /// rate the policy quotes now (capped) fixed for it.
224
343
  function _fund(
225
344
  address funder,
@@ -244,7 +363,7 @@ contract P2IDVault is IP2IDVault {
244
363
  if (msg.value != amount) revert NativeValueMismatch();
245
364
  credited = amount;
246
365
  } else {
247
- if (msg.value != 0) revert NativeValueMismatch(); // never strand native coin on an ERC-20 deposit
366
+ if (msg.value != 0) revert NativeValueMismatch(); // ERC-20 deposits do not accept msg.value
248
367
  if (token.code.length == 0) revert InvalidToken();
249
368
  uint256 beforeBalance = _balanceOf(token);
250
369
  _callToken(token, abi.encodeWithSignature("transferFrom(address,address,uint256)", msg.sender, address(this), amount));
@@ -291,126 +410,76 @@ contract P2IDVault is IP2IDVault {
291
410
 
292
411
  // ------------------------------------------------------------------ proofs
293
412
 
294
- /// @notice Present a proof under `verifier` without claiming: sets that verifier's owner
295
- /// wallet from it if it is newer than anything seen before, and retires every older
296
- /// proof. Call this immediately after moving to a new wallet.
297
- function refreshProof(address verifier, bytes calldata proof) external nonReentrant {
298
- _present(verifier, proof, _noConstraint());
413
+ /// @notice Verify a proof and update this verifier's owner cache and issue-time floor using _apply.
414
+ function refreshProof(address verifier, bytes memory proof) external {
415
+ refreshProofWithAttestation(verifier, proof, AlphaAttestation(0, 0, hex""));
299
416
  }
300
417
 
301
- /// @notice Present a proof and sweep `verifier`'s default bucket for `token` in one call (first claim).
418
+ /// @notice Present a proof, then sweep `verifier`'s default bucket for `token`.
302
419
  /// @param depositCountLimit Number of deposit records to visit from the bucket cursor (not
303
- /// an amount, not an index); 0 = all. For the default verifier, untracked
304
- /// (bare-transfer) funds are always included.
420
+ /// an amount, not an index); zero or above MAX_SWEEP_PAGE uses MAX_SWEEP_PAGE.
421
+ /// @dev Includes untracked funds only when _paysUntracked(verifier) is true.
305
422
  function refreshProofAndSweep(
306
423
  address verifier,
307
- bytes calldata proof,
424
+ bytes memory proof,
308
425
  address token,
309
426
  uint256 depositCountLimit
310
- ) external nonReentrant returns (uint256 amount, uint256 consumed) {
311
- _present(verifier, proof, _noConstraint());
312
- return _sweepDefault(verifier, token, depositCountLimit);
427
+ ) external returns (uint256 amount, uint256 consumed) {
428
+ return refreshProofAndSweepWithAttestation(verifier, proof, token, depositCountLimit, AlphaAttestation(0, 0, hex""));
313
429
  }
314
430
 
315
431
  // ------------------------------------------------------------------ claiming
316
432
 
317
433
  /// @notice Sweep `verifier`'s default bucket for `token` to that verifier's owner. Walks only
318
- /// this bucket's list from its cursor; `depositCountLimit` records per call (0 = all).
319
- /// When `verifier` is the factory default, untracked funds are included.
320
- function sweep(address verifier, address token, uint256 depositCountLimit)
321
- external
322
- nonReentrant
323
- returns (uint256 amount, uint256 consumed)
324
- {
325
- return _sweepDefault(verifier, token, depositCountLimit); // checks the owner is set and current
434
+ /// this bucket's list from its cursor; `depositCountLimit` records per call (0 = MAX_SWEEP_PAGE, also the cap).
435
+ /// Includes untracked funds when the verifier is the current default and its owner cache
436
+ /// meets the revision and untrackedProofIat checks.
437
+ function sweep(address verifier, address token, uint256 depositCountLimit) external nonReentrant returns (uint256 amount, uint256 consumed) {
438
+ return _sweep(verifier, token, depositCountLimit);
326
439
  }
327
440
 
328
- /// @notice Sweep only untracked funds (bare ERC-20 transfers backed by no deposit record) to
441
+ /// @notice Sweep only untracked ERC-20 or native funds to
329
442
  /// the default verifier's owner. The fee rate is quoted now.
330
443
  function sweepUntracked(address token) external nonReentrant returns (uint256 amount) {
331
- address verifier = defaultVerifier();
332
- address to = _ownerOf(verifier);
333
- if (latestProofIat[verifier] < untrackedProofIat) revert ProofTooOld();
334
- uint256 gross = _untrackedBalance(token);
335
- uint256 fee = (gross * _quoteFeeBps(verifier, token)) / BPS;
336
- uint256 charged;
337
- (amount, charged) = _payout(verifier, token, to, gross, fee);
338
- emit Swept(verifier, token, amount, charged, to, 0);
444
+ return _sweepUntracked(token);
339
445
  }
340
446
 
341
447
  /// @notice Sweep specific default-bucket deposits of `verifier` by id (e.g. to skip spam in the same bucket).
342
- function sweepDeposits(address verifier, address token, uint256[] calldata depositIds)
343
- external
344
- nonReentrant
345
- onlyInitialized(verifier)
346
- returns (uint256 amount)
347
- {
348
- address to = owner[verifier];
349
- (uint256 gross, uint256 fee) = _consumeIds(verifier, bytes32(0), token, depositIds, to);
350
- uint256 charged;
351
- (amount, charged) = _payout(verifier, token, to, gross, fee);
352
- emit Swept(verifier, token, amount, charged, to, depositIds.length);
448
+ function sweepDeposits(address verifier, address token, uint256[] memory depositIds) external nonReentrant returns (uint256 amount) {
449
+ return _sweepDeposits(verifier, token, depositIds);
353
450
  }
354
451
 
355
452
  /// @notice Sweep the bucket funded under `verifier` and `constraint.commitment`. The verifier
356
453
  /// must accept both the identity proof and the constraint evidence; funds go to the
357
- /// wallet the proof resolves to. Default buckets (zero commitment) are swept via sweep().
454
+ /// wallet the proof resolves to. This entry point rejects a zero commitment.
358
455
  function sweepBucket(
359
456
  address verifier,
360
- IP2IDVerifier.Constraint calldata constraint,
457
+ IP2IDVerifier.Constraint memory constraint,
361
458
  address token,
362
- bytes calldata proof,
459
+ bytes memory proof,
363
460
  uint256 depositCountLimit
364
- ) external nonReentrant returns (uint256 amount, uint256 consumed) {
365
- address to = _verifyForThisVault(verifier, proof, constraint);
366
- uint256 gross;
367
- uint256 fee;
368
- (gross, fee, consumed) = _consumeFromCursor(verifier, constraint.commitment, token, depositCountLimit, to);
369
- uint256 charged;
370
- (amount, charged) = _payout(verifier, token, to, gross, fee);
371
- emit SweptBucket(verifier, constraint.commitment, token, amount, charged, to, consumed);
461
+ ) external returns (uint256 amount, uint256 consumed) {
462
+ return sweepBucketWithAttestation(verifier, constraint, token, proof, depositCountLimit, AlphaAttestation(0, 0, hex""));
372
463
  }
373
464
 
374
465
  /// @notice Sweep specific deposits of a constrained bucket by id.
375
466
  function sweepBucketDeposits(
376
467
  address verifier,
377
- IP2IDVerifier.Constraint calldata constraint,
468
+ IP2IDVerifier.Constraint memory constraint,
378
469
  address token,
379
- uint256[] calldata depositIds,
380
- bytes calldata proof
381
- ) external nonReentrant returns (uint256 amount) {
382
- address to = _verifyForThisVault(verifier, proof, constraint);
383
- (uint256 gross, uint256 fee) = _consumeIds(verifier, constraint.commitment, token, depositIds, to);
384
- uint256 charged;
385
- (amount, charged) = _payout(verifier, token, to, gross, fee);
386
- emit SweptBucket(verifier, constraint.commitment, token, amount, charged, to, depositIds.length);
470
+ uint256[] memory depositIds,
471
+ bytes memory proof
472
+ ) external returns (uint256 amount) {
473
+ return sweepBucketDepositsWithAttestation(verifier, constraint, token, depositIds, proof, AlphaAttestation(0, 0, hex""));
387
474
  }
388
475
 
389
476
  // ------------------------------------------------------------------ fees
390
477
 
391
- /// @notice Hand the fees earned through `verifier` in `token` to the current policy, which
392
- /// pulls them and distributes them. Anyone may call it: the tokens can only go to the
393
- /// policy, and only as much as is owed. Returns the amount the policy took.
478
+ /// @notice Call the current policy to distribute accrued fees; callable by any address.
479
+ /// Native fees are sent as msg.value. For ERC-20, approve the accrued amount, reset the
480
+ /// allowance after the call, and deduct the observed balance decrease; revert if it exceeds fees owed.
394
481
  function withdrawFees(address verifier, address token) external nonReentrant returns (uint256 amount) {
395
- uint256 owed = feesOwed[verifier][token];
396
- if (owed == 0) return 0;
397
- address pol = policy();
398
- if (token == NATIVE) {
399
- // Native coin cannot be pulled: it is sent along with the call, exactly the amount owed.
400
- IP2IDPolicy(pol).distributeFee{value: owed}(verifier, token, owed);
401
- amount = owed;
402
- } else {
403
- uint256 before = _balanceOf(token);
404
- _callToken(token, abi.encodeWithSignature("approve(address,uint256)", pol, owed));
405
- IP2IDPolicy(pol).distributeFee(verifier, token, owed);
406
- _callToken(token, abi.encodeWithSignature("approve(address,uint256)", pol, 0));
407
- uint256 afterBalance = _balanceOf(token);
408
- amount = before > afterBalance ? before - afterBalance : 0;
409
- if (amount > owed) revert FeeOverdrawn();
410
- }
411
- feesOwed[verifier][token] = owed - amount;
412
- feesOwedTotal[token] -= amount;
413
- emit FeesDistributed(verifier, token, amount, pol);
482
+ return _withdrawFees(verifier, token);
414
483
  }
415
484
 
416
485
  // ------------------------------------------------------------------ views
@@ -420,12 +489,12 @@ contract P2IDVault is IP2IDVault {
420
489
  return "p2id.vault.v1";
421
490
  }
422
491
 
423
- /// @notice The factory's current policy (replaceable there only through a timelock).
492
+ /// @notice Policy address returned by the configured factory.
424
493
  function policy() public view returns (address) {
425
494
  return IP2IdVaultFactory(factory).policy();
426
495
  }
427
496
 
428
- /// @notice The factory's current default verifier (timelocked there): used by fund() and for untracked funds.
497
+ /// @notice Default verifier returned by the configured factory, used by fund() and untracked claims.
429
498
  function defaultVerifier() public view returns (address) {
430
499
  return IP2IdVaultFactory(factory).defaultVerifier();
431
500
  }
@@ -434,56 +503,83 @@ contract P2IDVault is IP2IDVault {
434
503
  return deposits.length;
435
504
  }
436
505
 
437
- /// @notice Deposit ids in a bucket, in funding order (consumed ones included; see `deposits`).
438
- function bucketDepositIds(address verifier, bytes32 constraint, address token) external view returns (uint256[] memory) {
439
- return _bucketDeposits[verifier][constraint][token];
506
+ /// @notice A page of a bucket's deposit ids, in funding order (consumed ones included; see
507
+ /// `deposits`): up to `limit` ids from `offset`, or all from `offset` when `limit` is 0.
508
+ function bucketDepositIds(address verifier, bytes32 constraint, address token, uint256 offset, uint256 limit)
509
+ external
510
+ view
511
+ returns (uint256[] memory ids)
512
+ {
513
+ uint256[] storage all = _bucketDeposits[verifier][constraint][token];
514
+ if (offset >= all.length) return ids;
515
+ uint256 end = all.length;
516
+ if (limit != 0 && end - offset > limit) end = offset + limit;
517
+ ids = new uint256[](end - offset);
518
+ for (uint256 k = offset; k < end; k++) ids[k - offset] = all[k];
440
519
  }
441
520
 
442
521
  function bucketDepositCount(address verifier, bytes32 constraint, address token) external view returns (uint256) {
443
522
  return _bucketDeposits[verifier][constraint][token].length;
444
523
  }
445
524
 
446
- /// @notice The deposit `funder` made under `constraint`, if any. Each funder can use a constraint once.
525
+ /// @notice Recorded deposit for a nonzero constraint and funder; the record persists after consumption.
447
526
  function constraintDeposit(bytes32 constraint, address funder) external view returns (bool used, uint256 depositId) {
448
527
  uint256 stored = _constraintDeposit[keccak256(abi.encode(constraint, funder))];
449
528
  return stored == 0 ? (false, 0) : (true, stored - 1);
450
529
  }
451
530
 
452
- /// @notice Gross amount `sweep(verifier, token, 0)` would release now, before fees: every
453
- /// unconsumed default deposit under `verifier`, plus untracked funds when it is the default verifier.
531
+ /// @notice Total default-bucket amount plus untracked funds when _paysUntracked is true.
532
+ /// @dev Does not apply the sweep page limit or validate default-bucket claimability.
454
533
  function sweepable(address verifier, address token) external view returns (uint256) {
455
534
  uint256 amount = bucketTotal[verifier][bytes32(0)][token];
456
535
  if (_paysUntracked(verifier)) amount += _untrackedBalance(token);
457
536
  return amount;
458
537
  }
459
538
 
460
- /// @notice Untracked funds only: bare ERC-20 transfers not backed by any deposit record.
539
+ /// @notice Reported balance minus tracked deposits and accrued fees, floored at zero; supports native coin.
461
540
  function untrackedBalance(address token) external view returns (uint256) {
462
541
  return _untrackedBalance(token);
463
542
  }
464
543
 
465
544
  // ------------------------------------------------------------------ internals
466
545
 
467
- /// @dev Every proof enters through here. Requires `verifier` to be allowed, verifies the proof
468
- /// for this vault's identity (the verifier reverts for any other identity), refuses it if
469
- /// older than the newest proof seen under that verifier, and if it is newer makes its
470
- /// wallet that verifier's owner. Returns the wallet to pay.
546
+ /// @dev Check verifier claimability, request verification for saltCommitment, then apply the
547
+ /// returned wallet and issue time. Proxy-verified results enter through acceptOwnerProof.
471
548
  function _present(
472
549
  address verifier,
473
- bytes calldata proof,
550
+ bytes memory proof,
474
551
  IP2IDVerifier.Constraint memory constraint
475
552
  ) private onlyClaimable(verifier) returns (address wallet) {
476
553
  uint64 iat;
477
554
  (wallet, iat) = IP2IDVerifier(verifier).getIdentityWallet(saltCommitment, proof, constraint);
555
+ _apply(verifier, wallet, iat);
556
+ }
557
+
558
+ /// @notice Apply a proxy-verified wallet and issue time; require msg.sender == address(this).
559
+ /// Revert unless wallet is the cached owner afterwards, then return the hook selector.
560
+ /// @dev The self-call check relies on the proxy verifying the result before invoking this hook.
561
+ function acceptOwnerProof(address verifier, address wallet, uint64 iat)
562
+ external
563
+ nonReentrant
564
+ onlyClaimable(verifier)
565
+ returns (bytes4)
566
+ {
567
+ if (msg.sender != address(this)) revert NotSelf();
568
+ _apply(verifier, wallet, iat);
569
+ if (owner[verifier] != wallet) revert NotOwnerProof(owner[verifier]);
570
+ return IP2IDVault.acceptOwnerProof.selector; // compatibility acknowledgement after applying the result
571
+ }
572
+
573
+ /// @dev The freshness and revision rules for a verified (wallet, iat) under `verifier`: refuses
574
+ /// proofs older than the newest seen, and makes a newer wallet the owner.
575
+ function _apply(address verifier, address wallet, uint64 iat) private {
478
576
  if (wallet == address(0)) revert InvalidWallet();
479
- // A forged token could carry any issue time; refusing future ones (beyond clock skew) bounds
480
- // how far a forged proof can push the freshness ratchets, so a real owner recovers with a
481
- // token issued minutes later rather than days.
577
+ // Bound the issue time before storing it as a freshness floor.
482
578
  if (iat > block.timestamp + FUTURE_SLACK) revert ProofFromFuture();
483
- uint64 rev = _revision(verifier);
484
- bool fresh = ownerRevision[verifier] == rev;
485
- // The ratchet survives a revision change: a void cache pays nobody, but proofs it already
486
- // retired stay retired, so re-proving takes a proof at least as recent as the last one.
579
+ (bool revOk, uint64 rev) = _revision(verifier);
580
+ if (!revOk) revert RevisionUnavailable(verifier);
581
+ bool fresh = _ownerFresh(verifier);
582
+ // Revision changes do not reset this verifier's stored issue-time floor.
487
583
  uint64 latest = latestProofIat[verifier];
488
584
  if (iat < latest) revert ProofTooOld();
489
585
  if (iat > latest || owner[verifier] == address(0)) {
@@ -497,29 +593,34 @@ contract P2IDVault is IP2IDVault {
497
593
  ownerRevision[verifier] = rev;
498
594
  emit OwnerRefreshed(verifier, wallet, iat);
499
595
  }
596
+ if (wallet == owner[verifier] && iat == latestProofIat[verifier]) {
597
+ bytes32 key = IP2IDVerifier(verifier).vkHash();
598
+ if (key == bytes32(0)) revert InvalidVkHash();
599
+ ownerVkHash[verifier] = key;
600
+ ownerAlphaRevision[verifier] = IP2IdVaultFactory(factory).alphaRevision(key);
601
+ }
500
602
  if (iat > untrackedProofIat && verifier == defaultVerifier()) untrackedProofIat = iat;
501
603
  // Otherwise iat == latest with the owner set: a same-age proof (a replayed copy, or another
502
604
  // wallet slot of the same token). The owner stays, and constrained paths pay `wallet`.
503
605
  }
504
606
 
505
- /// @dev Constrained-bucket entry: default buckets are only reachable through sweep().
607
+ /// @dev Verify a constrained claim; reject zero commitments at this entry point.
506
608
  function _verifyForThisVault(
507
609
  address verifier,
508
- bytes calldata proof,
509
- IP2IDVerifier.Constraint calldata constraint
610
+ bytes memory proof,
611
+ IP2IDVerifier.Constraint memory constraint
510
612
  ) private returns (address wallet) {
511
613
  if (constraint.commitment == bytes32(0)) revert ConstraintRequired();
512
614
  return _present(verifier, proof, constraint);
513
615
  }
514
616
 
515
- /// @dev Direct transfers go to `verifier`'s owner only if it is the current default and its
516
- /// owner proof is at least as fresh as the newest proof any default has seen.
617
+ /// @dev Whether verifier is the current default, its revision matches the cache, and its
618
+ /// stored proof time meets untrackedProofIat. Does not check for a nonzero owner.
517
619
  function _paysUntracked(address verifier) private view returns (bool) {
518
620
  return verifier == defaultVerifier() && _ownerFresh(verifier) && latestProofIat[verifier] >= untrackedProofIat;
519
621
  }
520
622
 
521
- /// @dev Owner wallet for proof-less default sweeps; the verifier must still be claimable and
522
- /// must not have revoked anything since the owner was proven.
623
+ /// @dev Require a claimable verifier, nonzero cached owner and matching readable revision.
523
624
  function _ownerOf(address verifier) private view onlyClaimable(verifier) returns (address to) {
524
625
  to = owner[verifier];
525
626
  if (to == address(0)) revert OwnerNotInitialized();
@@ -528,7 +629,12 @@ contract P2IDVault is IP2IDVault {
528
629
 
529
630
  /// @dev Whether the cached owner was proven under the verifier's current revision.
530
631
  function _ownerFresh(address verifier) private view returns (bool) {
531
- return ownerRevision[verifier] == _revision(verifier);
632
+ (bool ok, uint64 rev) = _revision(verifier);
633
+ if (!ok || ownerRevision[verifier] != rev) return false;
634
+ try IP2IDVerifier(verifier).vkHash() returns (bytes32 key) {
635
+ return key != bytes32(0) && ownerVkHash[verifier] == key
636
+ && ownerAlphaRevision[verifier] == IP2IdVaultFactory(factory).alphaRevision(key);
637
+ } catch { return false; }
532
638
  }
533
639
 
534
640
  function _noConstraint() private pure returns (IP2IDVerifier.Constraint memory c) {
@@ -536,9 +642,8 @@ contract P2IDVault is IP2IDVault {
536
642
  c.signature = "";
537
643
  }
538
644
 
539
- /// @dev Default-bucket sweep for one verifier: the default deposits consumed here at their
540
- /// fixed rates, plus the untracked surplus (at the rate quoted now) when `verifier` is the
541
- /// factory default.
645
+ /// @dev Consume a page of default-bucket deposits at their stored fee rates. Include untracked
646
+ /// surplus at the current quoted rate when _paysUntracked returns true.
542
647
  function _sweepDefault(address verifier, address token, uint256 depositCountLimit)
543
648
  private
544
649
  returns (uint256 amount, uint256 consumed)
@@ -569,9 +674,9 @@ contract P2IDVault is IP2IDVault {
569
674
  uint256[] storage ids = _bucketDeposits[verifier][constraint][token];
570
675
  uint256 i = bucketCursor[verifier][constraint][token];
571
676
  uint256 end = ids.length;
572
- // Bound by records visited, not records consumed: a run of already-consumed records
573
- // (refunded, or swept by id) is paged through at a fixed cost per call.
574
- if (depositCountLimit != 0 && end - i > depositCountLimit) end = i + depositCountLimit;
677
+ // Limit records visited, including already-consumed entries, to MAX_SWEEP_PAGE.
678
+ uint256 page = (depositCountLimit == 0 || depositCountLimit > MAX_SWEEP_PAGE) ? MAX_SWEEP_PAGE : depositCountLimit;
679
+ if (end - i > page) end = i + page;
575
680
  while (i < end) {
576
681
  uint256 id = ids[i];
577
682
  Deposit storage deposit = deposits[id];
@@ -590,7 +695,7 @@ contract P2IDVault is IP2IDVault {
590
695
  address verifier,
591
696
  bytes32 constraint,
592
697
  address token,
593
- uint256[] calldata depositIds,
698
+ uint256[] memory depositIds,
594
699
  address to
595
700
  ) private returns (uint256 amount, uint256 fee) {
596
701
  for (uint256 k = 0; k < depositIds.length; k++) {
@@ -656,10 +761,12 @@ contract P2IDVault is IP2IDVault {
656
761
  return v > MAX_FEE_BPS ? MAX_FEE_BPS : uint16(v);
657
762
  }
658
763
 
659
- /// @dev The verifier's revision; 0 if it does not report one (nothing it trusts is revocable).
660
- function _revision(address verifier) private view returns (uint64) {
661
- (bool ok, uint256 v) = _query(verifier, abi.encodeCall(IP2IDVerifier.revision, ()));
662
- return ok ? uint64(v) : 0;
764
+ /// @dev Query revision() with QUERY_GAS; ok is false on call failure, wrong length or uint64 overflow.
765
+ function _revision(address verifier) private view returns (bool ok, uint64 rev) {
766
+ uint256 v;
767
+ (ok, v) = _query(verifier, abi.encodeCall(IP2IDVerifier.revision, ()));
768
+ if (ok && v > type(uint64).max) ok = false;
769
+ rev = uint64(v);
663
770
  }
664
771
 
665
772
  /// @dev Whether `verifier` declares it can satisfy constraints; false if it does not say.
@@ -678,8 +785,8 @@ contract P2IDVault is IP2IDVault {
678
785
 
679
786
  // ------------------------------------------------------------------ tokens
680
787
 
681
- /// @dev Pay out `amount` of `token`; native coin goes with all remaining gas (every caller
682
- /// holds the reentrancy lock), so smart-contract wallets can receive it.
788
+ /// @dev Transfer tokens or call the native recipient without an explicit gas limit.
789
+ /// Entry points reaching this helper hold the reentrancy lock.
683
790
  function _transfer(address token, address to, uint256 amount) private {
684
791
  if (token == NATIVE) {
685
792
  (bool ok, ) = payable(to).call{value: amount}("");
@@ -709,8 +816,7 @@ contract P2IDVault is IP2IDVault {
709
816
  _;
710
817
  }
711
818
 
712
- /// @dev Claiming: through the factory's current default verifier always (see the contract
713
- /// notes), otherwise only while the policy allows the verifier.
819
+ /// @dev Skip policy approval for the current default verifier; check approval for other verifiers.
714
820
  modifier onlyClaimable(address verifier) {
715
821
  if (verifier != defaultVerifier() && !_policy().isVerifierAllowed(verifier)) revert VerifierNotApproved(verifier);
716
822
  _;
@@ -721,14 +827,14 @@ contract P2IDVault is IP2IDVault {
721
827
  _;
722
828
  }
723
829
 
724
- /// @dev Proof-less default-bucket payouts need an owner wallet under an allowed verifier.
830
+ /// @dev Require a cached owner that passes _ownerOf's claimability and revision checks.
725
831
  modifier onlyInitialized(address verifier) {
726
832
  _ownerOf(verifier);
727
833
  _;
728
834
  }
729
835
 
730
836
  modifier nonReentrant() {
731
- if (reentrancyLock != 1) revert Reentrancy();
837
+ if (reentrancyLock == 2) revert Reentrancy();
732
838
  reentrancyLock = 2;
733
839
  _;
734
840
  reentrancyLock = 1;