@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.
@@ -5,17 +5,10 @@ import {IVerifier} from "./PviumZKVerifier.sol";
5
5
  import {IPviumIdentity} from "./interfaces/IPviumIdentity.sol";
6
6
 
7
7
  /// @title PviumIdentity
8
- /// @notice On-chain verifier for Pvium attestations: one deployment per circuit version. The
9
- /// accepted signer keys start as the Privy app's JWKS at deployment (Privy publishes more
10
- /// than one key per app and may sign with any of them). The owner manages the set so a
11
- /// Privy key rotation does not strand deposits: adding a key is proposed with the JWKS URL
12
- /// and key id it can be checked at, and takes effect after SIGNER_KEY_DELAY (7 days);
13
- /// removing one is immediate, because a leaked key must stop being trusted at once. A key
14
- /// proposal is the strongest power here (a forged key forges proofs), which is why it is
15
- /// public for 7 days: anyone can verify the key at the URL, and payees and funders can
16
- /// claim or refund before it activates. An owner of address(0) makes the set permanent.
17
- /// A new circuit build is still a new deployment (plus a new PviumVerifier), which the
18
- /// P2ID vault factory registers alongside the old one. Developers call it through IPviumIdentity.
8
+ /// @notice Verifies attestations using an immutable proof verifier and circuit version.
9
+ /// The constructor supplies the initial P-256 signer keys. The owner can propose additions
10
+ /// with a 7-day activation delay or remove accepted keys immediately.
11
+ /// @dev Proposal URLs and key ids are metadata; this contract does not validate them against JWKS.
19
12
  /// @dev Public input layout emitted by circuit/src/main.nr:
20
13
  /// [0] identity_type [1] wallet (EVM address checked in-circuit against the token; 0 if none)
21
14
  /// [2] signer_x_hi [3] signer_x_lo [4] signer_y_hi [5] signer_y_lo
@@ -27,19 +20,19 @@ contract PviumIdentity is IPviumIdentity {
27
20
  uint256 public constant PUBLIC_INPUT_COUNT = 11;
28
21
 
29
22
  IVerifier public immutable verifier;
30
- /// @notice Circuit version this deployment verifies (see circuit/version.json). One deployment per version.
23
+ /// @notice Circuit version identifier supplied at deployment; not checked against the verifier's bytecode.
31
24
  uint16 public immutable circuitVersion;
25
+ /// @notice SHA-256 of the verification-key artifact pinned by this deployment.
26
+ bytes32 public immutable vkHash;
32
27
  /// @notice Notice a key proposal gives before it can be activated.
33
28
  uint64 public constant SIGNER_KEY_DELAY = 7 days;
34
- /// @notice Number of accepted signer keys. Zero (every key revoked) refuses every proof until a
35
- /// proposal matures: a freeze, never a payout.
29
+ /// @notice Number of accepted signer keys. When zero, the signer check rejects attestations.
36
30
  uint256 public signerKeyCount;
37
- /// @notice Incremented whenever a key is revoked, i.e. whenever proofs that verified before may
38
- /// no longer be trusted. Vaults discard wallets cached under an older revision.
31
+ /// @notice Incremented by removeSignerKey; exposed to consumers for cache invalidation.
39
32
  uint64 public keySetRevision;
40
33
  /// @dev Accepted signer keys, keyed by signerKeyHash(x, y).
41
34
  mapping(bytes32 keyHash => bool) private _signerKeys;
42
- /// @notice Manages the signer key set (two-step transfer); address(0) = the set is permanent.
35
+ /// @notice Owner authorized to propose/remove keys and transfer ownership; zero disables owner-only calls.
43
36
  address public owner;
44
37
  address public pendingOwner;
45
38
 
@@ -48,7 +41,7 @@ contract PviumIdentity is IPviumIdentity {
48
41
  uint256 y;
49
42
  /// Earliest activation time; zero when there is no proposal for this key.
50
43
  uint64 eta;
51
- /// Where the key is published: the JWKS URL and the key id in it, for anyone to check.
44
+ /// Caller-supplied JWKS URL and key id; stored without publication checks.
52
45
  string url;
53
46
  string kid;
54
47
  }
@@ -62,13 +55,11 @@ contract PviumIdentity is IPviumIdentity {
62
55
  event OwnershipTransferStarted(address indexed from, address indexed to);
63
56
  event OwnershipTransferred(address indexed from, address indexed to);
64
57
 
65
- /// @notice Everything an attestation asserts, for Pvium's own contracts (e.g. vault claims
66
- /// that pay `wallet`). Developers should use the IPviumIdentity functions.
58
+ /// @notice Decoded public inputs returned by verifyAttestation after verification.
67
59
  struct Attestation {
68
60
  uint8 identityType;
69
- /// EVM address of the wallet linked in the same token. The circuit decodes it from the
70
- /// signed token and asserts equality, so it is never prover-chosen. Zero when the proof
71
- /// carries no wallet or the wallet is not an EVM address (then only walletHash is set).
61
+ /// EVM wallet public input. The circuit checks the selected wallet against the signed token;
62
+ /// zero represents an absent or non-EVM wallet.
72
63
  address wallet;
73
64
  /// When Privy issued the token (unix seconds). Freshness policy is the caller's.
74
65
  uint64 iat;
@@ -96,16 +87,20 @@ contract PviumIdentity is IPviumIdentity {
96
87
  error TimelockNotElapsed(uint64 eta);
97
88
  error UnknownKey(uint256 x, uint256 y);
98
89
 
99
- /// @param _owner Manages the key set; address(0) fixes the set given here forever.
100
- /// @param _signerXs/_signerYs Raw P-256 coordinates of every key the Privy app signs with.
90
+ /// @param _vkHash Trusted SHA-256 of the pinned verification-key artifact; not derived on-chain.
91
+ /// @param _owner Key-set administrator; address(0) disables owner-only calls.
92
+ /// @param _signerXs X coordinates of the initial accepted P-256 keys.
93
+ /// @param _signerYs Corresponding Y coordinates.
101
94
  constructor(
102
95
  IVerifier _verifier,
103
96
  uint16 _circuitVersion,
97
+ bytes32 _vkHash,
104
98
  address _owner,
105
99
  uint256[] memory _signerXs,
106
100
  uint256[] memory _signerYs
107
101
  ) {
108
102
  if (address(_verifier).code.length == 0) revert InvalidVerifier();
103
+ if (_vkHash == bytes32(0)) revert InvalidVerifier();
109
104
  if (_circuitVersion == 0) revert InvalidCircuitVersion();
110
105
  if (_signerXs.length == 0 || _signerXs.length != _signerYs.length) revert NoSignerKeys();
111
106
  for (uint256 i = 0; i < _signerXs.length; i++) {
@@ -117,6 +112,7 @@ contract PviumIdentity is IPviumIdentity {
117
112
  }
118
113
  verifier = _verifier;
119
114
  circuitVersion = _circuitVersion;
115
+ vkHash = _vkHash;
120
116
  signerKeyCount = _signerXs.length;
121
117
  owner = _owner;
122
118
  emit OwnershipTransferred(address(0), _owner);
@@ -124,7 +120,7 @@ contract PviumIdentity is IPviumIdentity {
124
120
 
125
121
  // ---- signer key management ------------------------------------------------------------
126
122
 
127
- /// @notice Propose accepting a key, published at `url` under `kid`, after SIGNER_KEY_DELAY.
123
+ /// @notice Propose a key with caller-supplied `url` and `kid`, eligible after SIGNER_KEY_DELAY.
128
124
  /// Re-proposing a key restarts its delay.
129
125
  function proposeSignerKey(uint256 x, uint256 y, string calldata url, string calldata kid) external onlyOwner {
130
126
  if (!_isOnCurve(x, y)) revert InvalidPublicKey();
@@ -142,8 +138,7 @@ contract PviumIdentity is IPviumIdentity {
142
138
  emit SignerKeyProposalCancelled(x, y);
143
139
  }
144
140
 
145
- /// @notice Accept a proposed key once its delay has passed. Anyone may call it: the owner
146
- /// already decided by proposing, and can still cancel until this runs.
141
+ /// @notice Accept an uncancelled proposal once its delay has passed. Callable by any address.
147
142
  function activateSignerKey(uint256 x, uint256 y) external {
148
143
  bytes32 h = signerKeyHash(x, y);
149
144
  uint64 eta = signerKeyProposals[h].eta;
@@ -156,10 +151,8 @@ contract PviumIdentity is IPviumIdentity {
156
151
  emit SignerKeyAdded(x, y);
157
152
  }
158
153
 
159
- /// @notice Stop accepting a key at once (e.g. Privy retired it, or it leaked). Proofs made from
160
- /// tokens it signed fail from now on, and wallets that vaults cached from any proof are
161
- /// discarded (keySetRevision). Removing the last key is allowed: it freezes claims,
162
- /// which is the safe state while a compromised key is replaced.
154
+ /// @notice Remove an accepted key and increment keySetRevision. The last key may be removed.
155
+ /// Attestations naming this key fail the signer check until it is added again.
163
156
  function removeSignerKey(uint256 x, uint256 y) external onlyOwner {
164
157
  bytes32 h = signerKeyHash(x, y);
165
158
  if (!_signerKeys[h]) revert UnknownKey(x, y);
@@ -210,8 +203,8 @@ contract PviumIdentity is IPviumIdentity {
210
203
 
211
204
  // ---- lower level ----------------------------------------------------------------------
212
205
 
213
- /// @notice Verify a proof and return everything it asserts. Reverts unless the proof is valid
214
- /// and was produced from a token signed by one of the accepted keys.
206
+ /// @notice Check the public-input count and accepted signer, require verifier.verify to return
207
+ /// true, and return the decoded attestation.
215
208
  function verifyAttestation(bytes calldata proof, bytes32[] calldata publicInputs)
216
209
  public
217
210
  view
@@ -239,7 +232,7 @@ contract PviumIdentity is IPviumIdentity {
239
232
  return a.iat;
240
233
  }
241
234
 
242
- /// @dev Cheap checks first (layout, signer), so bad requests fail before the 4M-gas verify.
235
+ /// @dev Check input count and signer membership before calling the proof verifier.
243
236
  function _decode(bytes32[] calldata publicInputs) internal view returns (Attestation memory a) {
244
237
  if (publicInputs.length != PUBLIC_INPUT_COUNT) revert WrongPublicInputCount(publicInputs.length);
245
238
  bytes32 x = _join(publicInputs[2], publicInputs[3]);
@@ -256,7 +249,7 @@ contract PviumIdentity is IPviumIdentity {
256
249
  return bytes32((uint256(hi) << 128) | uint256(lo));
257
250
  }
258
251
 
259
- /// @dev y^2 == x^3 - 3x + b (mod p) on NIST P-256. Guards against registering a typo.
252
+ /// @dev Check coordinate bounds and y^2 == x^3 - 3x + b (mod p) on NIST P-256.
260
253
  function _isOnCurve(uint256 x, uint256 y) internal pure returns (bool) {
261
254
  uint256 p = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF;
262
255
  uint256 b = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B;
@@ -4,12 +4,9 @@ pragma solidity ^0.8.27;
4
4
  import {IP2IDPolicy} from "./interfaces/IP2IDPolicy.sol";
5
5
 
6
6
  /// @title PviumP2IDPolicy
7
- /// @notice The launch policy for P2ID vaults: an owner-managed allowlist of verifiers, and no fee.
8
- /// Later capabilities (permissionless verifier registration with a stake, protocol fees)
9
- /// ship as a new policy that the vault factory switches to through its timelock; the
10
- /// vaults and their addresses do not change.
11
- /// @dev `owner` should be a Pvium multisig. Approving a verifier only lets payers opt into it;
12
- /// revoking one freezes claims under it (refunds still work) and never moves funds.
7
+ /// @notice Owner-managed verifier allowlist with a zero fee quote.
8
+ /// @dev Approval checks code presence, not verifier behavior. P2IDVault uses the allowlist for
9
+ /// funding and non-default-verifier claims; its current default verifier bypasses the claim check.
13
10
  contract PviumP2IDPolicy is IP2IDPolicy {
14
11
  address public owner;
15
12
  address public pendingOwner;
@@ -48,7 +45,7 @@ contract PviumP2IDPolicy is IP2IDPolicy {
48
45
  }
49
46
 
50
47
  /// @inheritdoc IP2IDPolicy
51
- /// @dev The launch policy charges no fee, so a vault never has fees for it to distribute.
48
+ /// @dev Distribution is unsupported, including for fees accrued under an earlier policy.
52
49
  function distributeFee(address, address, uint256) external payable {
53
50
  revert NoFees();
54
51
  }
@@ -0,0 +1,199 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.27;
3
+
4
+ import {IP2IDVaultProxy} from "./interfaces/IP2IDVaultProxy.sol";
5
+ import {IP2IdVaultFactory} from "./interfaces/IP2IdVaultFactory.sol";
6
+ import {IP2IDVault} from "./interfaces/IP2IDVault.sol";
7
+ import {IP2IDVerifier} from "./interfaces/IP2IDVerifier.sol";
8
+
9
+ /// @title PviumP2IDVaultProxy
10
+ /// @notice CREATE2 vault proxy initialized from its deploying factory's baseImplementation.
11
+ /// upgradeTo requires a registered target and an identity proof resolving to the caller.
12
+ /// @dev Stores the implementation in the ERC-1967 slot and upgrade history in UPGRADE_SLOT.
13
+ /// Delegated code can write both slots. Proxy-defined selectors take precedence over the
14
+ /// implementation, and empty-calldata native transfers are handled here without delegation.
15
+ contract PviumP2IDVaultProxy is IP2IDVaultProxy {
16
+ error AlphaAuthorizationRequired();
17
+ error AlphaImplementationRequired();
18
+ bytes32 private constant ALPHA_UPGRADE_SLOT = keccak256("pvium.vault.proxy.alpha.upgrade.nonce.v1");
19
+
20
+ function alphaUpgradeNonceUsed(uint256 nonce) public view returns (bool used) {
21
+ bytes32 slot = keccak256(abi.encode(nonce, ALPHA_UPGRADE_SLOT));
22
+ assembly { used := sload(slot) }
23
+ }
24
+
25
+ error AlphaNonceAlreadyUsed(uint256 nonce);
26
+
27
+ function upgradeToWithAttestation(bytes32 identityHash, address newImplementation, bytes calldata proof, IP2IDVault.AlphaAttestation calldata attestation) external {
28
+ _upgradeTo(identityHash, newImplementation, proof, attestation);
29
+ }
30
+
31
+ /// @dev bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
32
+ bytes32 private constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
33
+ /// @dev bytes32(uint256(keccak256("pvium.vault.proxy.upgrading")) - 1): 1 while upgradeTo runs (a plain slot: the Paris target has no transient storage).
34
+ bytes32 private constant UPGRADING_SLOT = 0x1355c758876c0a085036d5960a223942d04ee15c92162da025899b2513e77bae;
35
+ /// @dev bytes32(uint256(keccak256("pvium.vault.proxy.lastUpgrade")) - 1): wallet (160 bits) | iat << 160.
36
+ bytes32 private constant UPGRADE_SLOT = 0x01d942a94cdff9c1162dd0a9d8cabadc4e9bf5ab8b614fc78cedc4d44e694edf;
37
+ /// @dev Maximum accepted issue time ahead of block.timestamp, matching P2IDVault.
38
+ uint64 private constant FUTURE_SLACK = 15 minutes;
39
+ /// @dev Gas for reading the vault's own freshness floors through the implementation.
40
+ uint256 private constant READ_GAS = 100_000;
41
+
42
+ /// @inheritdoc IP2IDVaultProxy
43
+ address public immutable factory;
44
+
45
+ error NotRegisteredImplementation(address implementation);
46
+ error NotVaultOf(bytes32 identityHash);
47
+ error NotOwner(address proven, address caller);
48
+ error ProofTooOld();
49
+ error ProofFromFuture();
50
+ error SameImplementation();
51
+ error NotAContract(address implementation);
52
+ error HookNotAcknowledged(address implementation);
53
+ error UpgradeReentered();
54
+ error ImplementationChangedDuringHook(address found);
55
+ error HookDidNotApply(address owner, uint64 latest);
56
+
57
+ constructor() {
58
+ factory = msg.sender;
59
+ _setImplementation(IP2IdVaultFactory(msg.sender).baseImplementation());
60
+ }
61
+
62
+ /// @inheritdoc IP2IDVaultProxy
63
+ function implementation() public view returns (address impl) {
64
+ assembly {
65
+ impl := sload(IMPLEMENTATION_SLOT)
66
+ }
67
+ }
68
+
69
+ /// @inheritdoc IP2IDVaultProxy
70
+ function lastUpgrade() public view returns (address wallet, uint64 iat) {
71
+ uint256 packed;
72
+ assembly {
73
+ packed := sload(UPGRADE_SLOT)
74
+ }
75
+ wallet = address(uint160(packed));
76
+ iat = uint64(packed >> 160);
77
+ }
78
+
79
+ /// @inheritdoc IP2IDVaultProxy
80
+ function upgradeTo(bytes32 identityHash, address newImplementation, bytes calldata proof) external {
81
+ _upgradeTo(identityHash, newImplementation, proof, IP2IDVault.AlphaAttestation(0, 0, hex""));
82
+ }
83
+
84
+ function _upgradeTo(bytes32 identityHash, address newImplementation, bytes calldata proof, IP2IDVault.AlphaAttestation memory attestation) private {
85
+ _enterUpgrade();
86
+ IP2IdVaultFactory f = IP2IdVaultFactory(factory);
87
+ if (f.vaultFor(identityHash) != address(this)) revert NotVaultOf(identityHash);
88
+ if (!f.isRegisteredImplementation(newImplementation)) revert NotRegisteredImplementation(newImplementation);
89
+ try IP2IDVault(newImplementation).supportsAlphaGuard() returns (bool supported) {
90
+ if (!supported) revert AlphaImplementationRequired();
91
+ } catch { revert AlphaImplementationRequired(); }
92
+ if (newImplementation == implementation()) revert SameImplementation();
93
+ address verifier = f.defaultVerifier();
94
+ if (f.isAlpha(IP2IDVerifier(verifier).vkHash())) {
95
+ if (attestation.signature.length == 0) revert AlphaAuthorizationRequired();
96
+ if (alphaUpgradeNonceUsed(attestation.nonce)) revert AlphaNonceAlreadyUsed(attestation.nonce);
97
+ f.verifyAlphaAuthorization(address(this), msg.sender,
98
+ keccak256(abi.encodeCall(IP2IDVaultProxy.upgradeTo, (identityHash, newImplementation, proof))), attestation.nonce, attestation.deadline, attestation.signature);
99
+ bytes32 slot = keccak256(abi.encode(attestation.nonce, ALPHA_UPGRADE_SLOT));
100
+ assembly { sstore(slot, 1) }
101
+ }
102
+ (address wallet, uint64 iat) =
103
+ IP2IDVerifier(verifier).getIdentityWallet(identityHash, proof, IP2IDVerifier.Constraint(bytes32(0), ""));
104
+ if (wallet != msg.sender) revert NotOwner(wallet, msg.sender);
105
+ if (iat > block.timestamp + FUTURE_SLACK) revert ProofFromFuture();
106
+ // Combine stored upgrade history with the readable issue-time floors of the current implementation.
107
+ (address lastWallet, uint64 lastIat) = lastUpgrade();
108
+ // Ignore stored history beyond this entry point's accepted future-time bound.
109
+ if (lastIat > block.timestamp + FUTURE_SLACK) (lastWallet, lastIat) = (address(0), 0);
110
+ if (iat < lastIat || (iat == lastIat && wallet != lastWallet)) revert ProofTooOld();
111
+ if (iat < _vaultFloor(verifier)) revert ProofTooOld();
112
+ uint256 packed = uint256(uint160(wallet)) | (uint256(iat) << 160);
113
+ assembly {
114
+ sstore(UPGRADE_SLOT, packed)
115
+ }
116
+ _setImplementation(newImplementation);
117
+ // Apply the verified result through the new implementation. Revert the upgrade on hook
118
+ // failure, invalid acknowledgement or failed post-condition checks below.
119
+ (bool ok, bytes memory ret) = address(this).call(abi.encodeCall(IP2IDVault.acceptOwnerProof, (verifier, wallet, iat)));
120
+ if (!ok) {
121
+ assembly {
122
+ revert(add(ret, 32), mload(ret)) // the vault's own reason (ProofTooOld, NotOwnerProof, …)
123
+ }
124
+ }
125
+ // Require a canonical ABI-encoded selector. A fallback returning the same data can satisfy this.
126
+ if (ret.length != 32 || abi.decode(ret, (bytes4)) != IP2IDVault.acceptOwnerProof.selector) {
127
+ revert HookNotAcknowledged(newImplementation);
128
+ }
129
+ // Post-conditions, read through the new implementation: it is still what was installed,
130
+ // and it now records this wallet as the payout owner with a floor at least this fresh.
131
+ // These detect some incompatible hooks. Registered code can fake getters or alter other storage;
132
+ // the registry does not audit implementation behavior.
133
+ if (implementation() != newImplementation) revert ImplementationChangedDuringHook(implementation());
134
+ address ownerNow = address(uint160(_read(abi.encodeCall(IP2IDVault.owner, (verifier)))));
135
+ uint256 latestNow = _read(abi.encodeCall(IP2IDVault.latestProofIat, (verifier)));
136
+ if (ownerNow != wallet || latestNow < iat) revert HookDidNotApply(ownerNow, uint64(latestNow));
137
+ _exitUpgrade();
138
+ }
139
+
140
+ /// @dev upgradeTo is not reentrant: the hook runs with the new implementation installed, and
141
+ /// must not be able to start another upgrade from inside this one.
142
+ function _enterUpgrade() private {
143
+ uint256 flag;
144
+ assembly {
145
+ flag := sload(UPGRADING_SLOT)
146
+ }
147
+ if (flag != 0) revert UpgradeReentered();
148
+ assembly {
149
+ sstore(UPGRADING_SLOT, 1)
150
+ }
151
+ }
152
+
153
+ function _exitUpgrade() private {
154
+ assembly {
155
+ sstore(UPGRADING_SLOT, 0)
156
+ }
157
+ }
158
+
159
+ /// @dev Maximum of the current implementation's per-verifier and cross-default floors.
160
+ /// Failed, malformed and excessively future-dated reads contribute zero.
161
+ function _vaultFloor(address verifier) private view returns (uint64 floor) {
162
+ uint256 a = _read(abi.encodeCall(IP2IDVault.latestProofIat, (verifier)));
163
+ uint256 b = _read(abi.encodeCall(IP2IDVault.untrackedProofIat, ()));
164
+ // Discard values beyond the same future-time bound used for proofs.
165
+ uint256 limit = block.timestamp + FUTURE_SLACK;
166
+ if (a > limit) a = 0;
167
+ if (b > limit) b = 0;
168
+ floor = uint64(a > b ? a : b);
169
+ }
170
+
171
+ function _read(bytes memory data) private view returns (uint256 value) {
172
+ (bool ok, bytes memory ret) = address(this).staticcall{gas: READ_GAS}(data);
173
+ if (!ok || ret.length != 32) return 0;
174
+ value = abi.decode(ret, (uint256));
175
+ }
176
+
177
+ /// @dev Accepted here, not delegated: keeps plain sends within the 2300-gas stipend.
178
+ receive() external payable {}
179
+
180
+ fallback() external payable {
181
+ address impl = implementation();
182
+ assembly {
183
+ calldatacopy(0, 0, calldatasize())
184
+ let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
185
+ returndatacopy(0, 0, returndatasize())
186
+ switch ok
187
+ case 0 { revert(0, returndatasize()) }
188
+ default { return(0, returndatasize()) }
189
+ }
190
+ }
191
+
192
+ function _setImplementation(address impl) private {
193
+ if (impl.code.length == 0) revert NotAContract(impl);
194
+ assembly {
195
+ sstore(IMPLEMENTATION_SLOT, impl)
196
+ }
197
+ emit Upgraded(impl);
198
+ }
199
+ }