From 5d34836000bf4c8a7c2c3268d3563aac7a2e919a Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 18:07:30 +0300 Subject: [PATCH 01/11] feat(identity): one-call registry reads for bindings, ids and handles Add five views to IdentityRegistry and IIdentityRegistry: - handleBindingOf(platformId, handle) -> (holder, observedAt): resolveHandle's reading (normalized on chain, refused text answers (0, 0), unwired platform reverts UnknownPlatform) plus the binding's observedAt. A retired handle answers a zero holder beside its watermark, as handleBinding does. - idBindingOf(platformId, id) -> (holder, observedAt): resolveId plus observedAt. - handleOfId(platformId, id) -> (handle, current): the handle the identity proved most recently and identitiesOf's handleCurrent for it; ("", false) for an id never proved. - idOfHandle(platformId, handle) -> id: the id of the identity the handle belongs to now; empty when nobody holds it (never proved, or retired by a rename) and for refused text. - normalizeHandle(platformId, handle) -> normalized: joins the rules views (rulesOf, handleHashOf, handleNodeOf): needs a configured platform only and reverts UnusableHandle for refused text, as handleHashOf does. resolveId and resolveHandle now read through idBindingOf and handleBindingOf, handleHashOf hashes normalizeHandle's answer, and identitiesOf and handleOfId share one helper for the latest handle and its current flag. No storage change; the layout snapshot still matches. The Rust sol! bindings and the TS resolve helpers (idBindingOf, handleBindingOf, handleOfId, idOfHandle, normalizeHandle) expose the same reads. SettableRegistry, the escrow tests' registry mock, implements the widened interface. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- rust/contracts/src/bindings/identity.rs | 17 ++ .../contracts/escrow/test/EscrowMocks.sol | 21 ++ .../contracts/identity/IIdentityRegistry.sol | 34 +++ .../contracts/identity/IdentityRegistry.sol | 126 +++++++-- .../identity/test/IdentityRegistry.t.sol | 265 ++++++++++++++++++ ts/packages/contracts/README.md | 16 ++ .../contracts/src/identity/resolve.test.ts | 103 +++++++ ts/packages/contracts/src/identity/resolve.ts | 86 ++++++ 8 files changed, 648 insertions(+), 20 deletions(-) diff --git a/rust/contracts/src/bindings/identity.rs b/rust/contracts/src/bindings/identity.rs index 7d6c38e3..15d24fca 100644 --- a/rust/contracts/src/bindings/identity.rs +++ b/rust/contracts/src/bindings/identity.rs @@ -74,6 +74,20 @@ mod registry_inner { function unpublish(bytes32 platformId) external; function resolveId(bytes32 platformId, string calldata id) external view returns (address); function resolveHandle(bytes32 platformId, string calldata handle) external view returns (address); + /// The holder that proved an id and when, as `resolveId` reads + /// it; `(0, 0)` when nobody did. + function idBindingOf(bytes32 platformId, string calldata id) external view returns (address holder, uint64 observedAt); + /// The holder that last proved a handle and when, as + /// `resolveHandle` reads it. `(0, 0)` for text the rules refuse; + /// a retired handle answers a zero holder beside its watermark. + function handleBindingOf(bytes32 platformId, string calldata handle) external view returns (address holder, uint64 observedAt); + /// The handle an id proved most recently, and whether the handle + /// node still points back at that identity. Empty and false for an + /// id never proved. + function handleOfId(bytes32 platformId, string calldata id) external view returns (string memory handle, bool current); + /// The id of the identity a handle belongs to now. Empty when none + /// does, or for text the rules refuse. + function idOfHandle(bytes32 platformId, string calldata handle) external view returns (string memory id); /// The handle's holder, and whether `id` resolves to that same /// holder. function resolveHandleAndId(bytes32 platformId, string calldata handle, string calldata id) external view returns (address holder, bool idAgrees); @@ -88,6 +102,9 @@ mod registry_inner { /// `keccak256` of the normalized handle: what `HandleEscrow.deposit` /// takes. Reverts `UnknownPlatform` or `UnusableHandle`. function handleHashOf(bytes32 platformId, string calldata handle) external view returns (bytes32 handleHash); + /// The handle normalized under the platform's current rules. + /// Reverts `UnknownPlatform` or `UnusableHandle`. + function normalizeHandle(bytes32 platformId, string calldata handle) external view returns (string memory normalized); /// The node a handle hashes to now; reverts as `handleHashOf` does. function handleNodeOf(bytes32 platformId, string calldata handle) external view returns (bytes32 handleNode); /// The node of a handle given as its hash, as `bind` binds it. diff --git a/solidity/contracts/escrow/test/EscrowMocks.sol b/solidity/contracts/escrow/test/EscrowMocks.sol index aed706c3..7dbadd6d 100644 --- a/solidity/contracts/escrow/test/EscrowMocks.sol +++ b/solidity/contracts/escrow/test/EscrowMocks.sol @@ -39,6 +39,27 @@ contract SettableRegistry is IIdentityRegistry { function acceptsBindings(bytes32 platformId) external pure returns (bool) { return platformId != bytes32(0); } + + function normalizeHandle(bytes32, string calldata handle) external pure returns (string memory) { + return handle; + } + + function handleBindingOf(bytes32 platformId, string calldata handle) external view returns (address, uint64) { + return (holderOf[IdentityNodes.handleNode(platformId, handle)], 0); + } + + /// Ids are not modelled: every id reads as never proved. + function idBindingOf(bytes32, string calldata) external pure returns (address, uint64) { + return (address(0), 0); + } + + function handleOfId(bytes32, string calldata) external pure returns (string memory, bool) { + return ("", false); + } + + function idOfHandle(bytes32, string calldata) external pure returns (string memory) { + return ""; + } } /// @notice A plain ERC-20 anybody can mint. diff --git a/solidity/contracts/identity/IIdentityRegistry.sol b/solidity/contracts/identity/IIdentityRegistry.sol index ffe03dcb..a3e46b05 100644 --- a/solidity/contracts/identity/IIdentityRegistry.sol +++ b/solidity/contracts/identity/IIdentityRegistry.sol @@ -20,6 +20,13 @@ interface IIdentityRegistry { /// rules. Reverts `UnknownPlatform` or `UnusableHandle`. function handleHashOf(bytes32 platformId, string calldata handle) external view returns (bytes32 handleHash); + /// @notice A handle normalized under the platform's current rules. Reverts + /// as `handleHashOf` does. + function normalizeHandle(bytes32 platformId, string calldata handle) + external + view + returns (string memory normalized); + /// @notice The node a handle hashes to under the platform's current rules. /// Reverts as `handleHashOf` does. function handleNodeOf(bytes32 platformId, string calldata handle) external view returns (bytes32 handleNode); @@ -30,4 +37,31 @@ interface IIdentityRegistry { /// @notice Whether `bind` can bind a holder on this platform now. function acceptsBindings(bytes32 platformId) external view returns (bool); + + /// @notice The holder of this handle, normalized on chain, and when it was + /// last proved. `(0, 0)` for text the rules refuse; a retired + /// handle keeps its `observedAt`. Reverts `UnknownPlatform` for a + /// platform that is not wired. + function handleBindingOf(bytes32 platformId, string calldata handle) + external + view + returns (address holder, uint64 observedAt); + + /// @notice The holder that proved this id, and when. Reverts + /// `UnknownPlatform` for a platform that is not wired. + function idBindingOf(bytes32 platformId, string calldata id) + external + view + returns (address holder, uint64 observedAt); + + /// @notice The handle this id proved most recently, and whether the handle + /// still belongs to this identity. Empty for an id never proved. + function handleOfId(bytes32 platformId, string calldata id) + external + view + returns (string memory handle, bool current); + + /// @notice The id of the identity this handle belongs to now. Empty when + /// none does, or for text the rules refuse. + function idOfHandle(bytes32 platformId, string calldata handle) external view returns (string memory id); } diff --git a/solidity/contracts/identity/IdentityRegistry.sol b/solidity/contracts/identity/IdentityRegistry.sol index cf1b25c2..36cc153b 100644 --- a/solidity/contracts/identity/IdentityRegistry.sol +++ b/solidity/contracts/identity/IdentityRegistry.sol @@ -817,10 +817,10 @@ contract IdentityRegistry is // ─── Reading ──────────────────────────────────────────────────── - // `rulesOf`, `handleHashOf` and `handleNodeOf` answer from the rules, so - // they need a configured platform and nothing more: they agree with each - // other, and keep answering before a platform's first verifier and after - // its last. + // `rulesOf`, `normalizeHandle`, `handleHashOf` and `handleNodeOf` answer + // from the rules, so they need a configured platform and nothing more: + // they agree with each other, and keep answering before a platform's first + // verifier and after its last. /// @notice The platform's normalization rules as configured now, for a /// client that normalizes locally. Reverts `UnknownPlatform`. @@ -828,15 +828,26 @@ contract IdentityRegistry is return _requireConfigured(platformId).rules; } + /// @notice A handle normalized under the platform's current rules: the + /// string a proof of it binds, and the one `handleHashOf` hashes. + /// @dev Reverts `UnknownPlatform` for a platform with no rules, and + /// `UnusableHandle` for text the rules refuse (where `resolveHandle` + /// answers zero). + function normalizeHandle(bytes32 platformId, string calldata handle) + public + view + returns (string memory normalized) + { + HandleNormalizer.Problem problem; + (problem, normalized) = HandleNormalizer.tryNormalize(handle, _requireConfigured(platformId).rules); + if (problem != HandleNormalizer.Problem.None) revert UnusableHandle(problem); + } + /// @notice `keccak256` of a handle normalized under the platform's current /// rules: the `handleHash` `HandleEscrow.deposit` takes. - /// @dev Reverts `UnusableHandle` for text the rules refuse (where - /// `resolveHandle` answers zero). + /// @dev Reverts as `normalizeHandle` does. function handleHashOf(bytes32 platformId, string calldata handle) public view returns (bytes32 handleHash) { - (HandleNormalizer.Problem problem, string memory normalized) = - HandleNormalizer.tryNormalize(handle, _requireConfigured(platformId).rules); - if (problem != HandleNormalizer.Problem.None) revert UnusableHandle(problem); - return keccak256(bytes(normalized)); + return keccak256(bytes(normalizeHandle(platformId, handle))); } /// @notice The node a handle hashes to under the platform's current rules, @@ -867,8 +878,20 @@ contract IdentityRegistry is /// proved this" to a question that was never asked — the platform is /// not wired — and a caller cannot tell the two apart from a zero. function resolveId(bytes32 platformId, string calldata id) external view returns (address) { + (address holder,) = idBindingOf(platformId, id); + return holder; + } + + /// @notice The holder that proved this id and when the platform stated + /// it, or `(0, 0)`. Reverts as `resolveId` does. + function idBindingOf(bytes32 platformId, string calldata id) + public + view + returns (address holder, uint64 observedAt) + { _requireUsable(platformId); - return _s().idBindings[IdentityNodes.idNode(platformId, id)].holder; + Binding storage b = _s().idBindings[IdentityNodes.idNode(platformId, id)]; + return (b.holder, b.observedAt); } /// @notice The holder that last proved this handle, or the zero address. @@ -883,9 +906,76 @@ contract IdentityRegistry is /// `UnknownPlatform`. An unwired platform still reverts, because that /// question was never asked. function resolveHandle(bytes32 platformId, string calldata handle) external view returns (address) { + (address holder,) = handleBindingOf(platformId, handle); + return holder; + } + + /// @notice The holder that last proved this handle and when the platform + /// stated it, read the way `resolveHandle` reads it. + /// + /// @dev Text the rules refuse answers `(0, 0)`; an unwired platform + /// reverts `UnknownPlatform`. A retired handle answers a zero holder + /// beside the `observedAt` of the proof that last held it, which is + /// the watermark a new proof of it has to beat. + function handleBindingOf(bytes32 platformId, string calldata handle) + public + view + returns (address holder, uint64 observedAt) + { + (bool normalizes, bytes32 handleNode) = _usableHandleNode(platformId, handle); + if (!normalizes) return (address(0), 0); + Binding storage b = _s().handleBindings[handleNode]; + return (b.holder, b.observedAt); + } + + /// @notice The id of the identity this handle belongs to now, or empty. + /// + /// @dev Empty when nobody proved the handle, when the identity that + /// proved it has since proved another one, and for text the rules + /// refuse. An unwired platform reverts `UnknownPlatform`. The id is + /// byte for byte as the platform issued it. + function idOfHandle(bytes32 platformId, string calldata handle) external view returns (string memory id) { + (bool normalizes, bytes32 handleNode) = _usableHandleNode(platformId, handle); + if (!normalizes || _s().handleBindings[handleNode].holder == address(0)) return ""; + return _s().idPreimages[_s().idNodeByHandle[handleNode]].id; + } + + /// @notice The handle this id proved most recently, and whether it is + /// still this identity's. Empty and false for an id never proved. + /// + /// @dev `current` is `identitiesOf`'s `handleCurrent`: the handle node + /// points back at this identity. It turns false once another + /// identity proves the handle, and the string stays as the last + /// thing this identity was known as. It reads the nodes, so a change + /// to the platform's rules is seen by `resolveHandle` before it is + /// seen here. Reverts as `resolveId` does. + function handleOfId(bytes32 platformId, string calldata id) + external + view + returns (string memory handle, bool current) + { + _requireUsable(platformId); + return _handleOf(IdentityNodes.idNode(platformId, id)); + } + + /// @dev The node a resolver reads for this handle, behind the resolvers' + /// gate. `normalizes` is false for text the rules refuse. + function _usableHandleNode(bytes32 platformId, string calldata handle) + private + view + returns (bool normalizes, bytes32 handleNode) + { Platform memory platform = _requireUsable(platformId); - (HandleNormalizer.Problem problem, bytes32 handleNode) = _handleNode(platformId, handle, platform.rules); - return problem == HandleNormalizer.Problem.None ? _s().handleBindings[handleNode].holder : address(0); + HandleNormalizer.Problem problem; + (problem, handleNode) = _handleNode(platformId, handle, platform.rules); + normalizes = problem == HandleNormalizer.Problem.None; + } + + /// @dev The handle an identity proved most recently, as normalized when it + /// was written, and whether its node still points back at the identity. + function _handleOf(bytes32 idNode) private view returns (string memory handle, bool current) { + bytes32 handleNode = _s().handleNodeById[idNode]; + return (_s().handlePreimages[handleNode], _s().idNodeByHandle[handleNode] == idNode); } /// @dev The node a handle hashes to under the given rules, or the problem @@ -955,14 +1045,10 @@ contract IdentityRegistry is out = new Identity[](nodes.length); for (uint256 i = 0; i < nodes.length; i++) { bytes32 idNode = nodes[i]; - bytes32 handleNode = $.handleNodeById[idNode]; IdentityPreimage storage preimage = $.idPreimages[idNode]; - out[i] = Identity({ - platformId: preimage.platformId, - id: preimage.id, - handle: $.handlePreimages[handleNode], - handleCurrent: $.idNodeByHandle[handleNode] == idNode - }); + (string memory handle, bool current) = _handleOf(idNode); + out[i] = + Identity({platformId: preimage.platformId, id: preimage.id, handle: handle, handleCurrent: current}); } } diff --git a/solidity/contracts/identity/test/IdentityRegistry.t.sol b/solidity/contracts/identity/test/IdentityRegistry.t.sol index d5873854..cd2d6a46 100644 --- a/solidity/contracts/identity/test/IdentityRegistry.t.sol +++ b/solidity/contracts/identity/test/IdentityRegistry.t.sol @@ -1006,6 +1006,271 @@ contract IdentityRegistryTest is Test { assertEq(at, 100, "the watermark stays"); } + // ─── One-call reads ───────────────────────────────────────────── + + /// The holder and the proof's age in one call, read the way + /// `resolveHandle` reads: normalized on chain, so a reader's spelling does + /// not matter. + function test_handleBindingOfReadsTheHolderAndWhenItWasProved() public { + _bind(alice, "123", " @Alice_1 ", 100); + + _assertHandleBinding(X, "alice_1", alice, 100); + _assertHandleBinding(X, "@ALICE_1", alice, 100); + _assertHandleBinding(X, " @Alice_1 ", alice, 100); + _assertHandleBinding(X, "nobody", address(0), 0); + _assertHandleBinding(GITHUB, "alice_1", address(0), 0); + } + + /// Text the rules refuse answers `(0, 0)`, as `resolveHandle` answers + /// zero, rather than reverting. + function test_handleBindingOfAnswersNobodyForTextTheRulesRefuse() public { + _bind(alice, "123", "alice", 100); + + _assertHandleBinding(X, "ali ce", address(0), 0); + _assertHandleBinding(X, "ali-ce", address(0), 0); + _assertHandleBinding(X, "", address(0), 0); + _assertHandleBinding(GITHUB, "-octocat", address(0), 0); + } + + /// A retired handle has no holder and keeps the watermark a newer proof + /// has to beat; `handleBinding` on its node says the same. + function test_handleBindingOfShowsARetiredHandlesWatermark() public { + _bind(alice, "123", "alice", 100); + _bind(alice, "123", "alice2", 200); + + _assertHandleBinding(X, "alice", address(0), 100); + _assertHandleBinding(X, "alice2", alice, 200); + + _bind(bob, "456", "alice", 300); + _assertHandleBinding(X, "@Alice", bob, 300); + } + + function test_idBindingOfReadsTheHolderAndWhenItWasProved() public { + _bind(alice, "123", "alice", 100); + _bind(alice, "123", "alice2", 200); + + _assertIdBinding(X, "123", alice, 200); + _assertIdBinding(X, "456", address(0), 0); + _assertIdBinding(GITHUB, "123", address(0), 0); + + _bind(bob, "123", "bob", 300); + _assertIdBinding(X, "123", bob, 300); + } + + /// The id is read verbatim, as `resolveId` reads it: no normalization. + function test_idBindingOfDoesNotNormalizeTheId() public { + _bind(alice, "AbC", "alice", 100); + + _assertIdBinding(X, "AbC", alice, 100); + _assertIdBinding(X, "abc", address(0), 0); + _assertIdBinding(X, " AbC", address(0), 0); + } + + /// An identity's latest handle, current while the handle node points back + /// at it. A rename moves it; another identity proving the handle turns + /// `current` false and leaves the string. + function test_handleOfIdFollowsARenameAndATakeover() public { + _assertHandle(X, "123", "", false); + + _bind(alice, "123", "@Alice", 100); + _assertHandle(X, "123", "alice", true); + + _bind(alice, "123", "Alice2", 200); + _assertHandle(X, "123", "alice2", true); + + _bind(bob, "456", "alice2", 300); + _assertHandle(X, "123", "alice2", false); + _assertHandle(X, "456", "alice2", true); + } + + /// `current` is the identity's, not the holder's: a second identity of + /// the same holder taking the handle turns it false for the first. + function test_handleOfIdTellsTwoIdentitiesOfOneHolderApart() public { + _bind(alice, "123", "shared", 100); + _bind(alice, "456", "shared", 200); + + _assertHandle(X, "123", "shared", false); + _assertHandle(X, "456", "shared", true); + assertEq(registry.idOfHandle(X, "shared"), "456"); + } + + /// The same answer the identity's entry in `identitiesOf` gives. + function test_handleOfIdAgreesWithTheList() public { + _bind(alice, "123", "alice", 100); + _bind(bob, "456", "alice", 200); + + IdentityRegistry.Identity memory listed = _identity(alice, X, "123"); + (string memory handle, bool current) = registry.handleOfId(X, "123"); + assertEq(handle, listed.handle); + assertEq(current, listed.handleCurrent); + } + + /// Like `handleCurrent`, it reads the nodes: narrowing the rules is seen + /// by `resolveHandle` first. + function test_handleOfIdReadsTheNodesNotTheRules() public { + _bind(alice, "123", "with_score", 100); + HandleNormalizer.Rules memory rules = HandleVectors.rulesFor(X); + rules.allowUnderscore = false; + vm.prank(owner); + registry.setPlatform(X, rules); + + assertEq(registry.resolveHandle(X, "with_score"), address(0)); + _assertHandle(X, "123", "with_score", true); + } + + /// The id of the identity a handle belongs to, through every spelling the + /// rules fold together, and following the handle to its next owner. + function test_idOfHandleFollowsTheHandle() public { + assertEq(registry.idOfHandle(X, "alice"), "", "nobody proved it"); + + _bind(alice, "123", "alice", 100); + assertEq(registry.idOfHandle(X, "alice"), "123"); + assertEq(registry.idOfHandle(X, "@ALICE"), "123"); + assertEq(registry.idOfHandle(X, " @Alice "), "123"); + assertEq(registry.idOfHandle(GITHUB, "alice"), "", "another platform"); + + _bind(bob, "456", "alice", 200); + assertEq(registry.idOfHandle(X, "alice"), "456", "the new owner"); + } + + /// A handle the identity renamed away from belongs to nobody, as + /// `resolveHandle` answers zero for it, until another identity proves it. + function test_idOfHandleIsEmptyForARetiredHandle() public { + _bind(alice, "123", "alice", 100); + _bind(alice, "123", "alice2", 200); + + assertEq(registry.resolveHandle(X, "alice"), address(0)); + assertEq(registry.idOfHandle(X, "alice"), ""); + assertEq(registry.idOfHandle(X, "alice2"), "123"); + + _bind(bob, "456", "alice", 300); + assertEq(registry.idOfHandle(X, "alice"), "456"); + } + + function test_idOfHandleIsEmptyForTextTheRulesRefuse() public { + _bind(alice, "123", "alice", 100); + + assertEq(registry.idOfHandle(X, "ali ce"), ""); + assertEq(registry.idOfHandle(X, "ali-ce"), ""); + assertEq(registry.idOfHandle(X, ""), ""); + } + + /// The id comes back byte for byte as the platform issued it. + function test_idOfHandleAnswersTheIdVerbatim() public { + _bind(alice, "MDQ6VXNlcjIwMjEzMTc0", "octocat", 100); + assertEq(registry.idOfHandle(X, "OctoCat"), "MDQ6VXNlcjIwMjEzMTc0"); + } + + /// The string `handleHashOf` hashes and a proof of the handle binds. + function test_normalizeHandleAnswersWhatABindStores() public { + assertEq(registry.normalizeHandle(X, " @Alice_1 "), "alice_1"); + assertEq(registry.normalizeHandle(X, "@ALICE"), "alice"); + assertEq(registry.normalizeHandle(X, "alice"), "alice"); + assertEq(registry.normalizeHandle(GITHUB, "Octo-Cat"), "octo-cat"); + assertEq(keccak256(bytes(registry.normalizeHandle(X, "@ALICE"))), registry.handleHashOf(X, "@ALICE")); + + _bind(alice, "123", " @Alice_1 ", 100); + _assertHandle(X, "123", registry.normalizeHandle(X, "@ALICE_1"), true); + } + + /// Text the rules refuse reverts with the normalizer's reason, as the + /// other rules views do. + function test_normalizeHandleRefusesWhatTheRulesRefuse() public { + vm.expectRevert( + abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.BadChar) + ); + registry.normalizeHandle(X, "ali-ce"); + vm.expectRevert( + abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.Empty) + ); + registry.normalizeHandle(X, "@"); + vm.expectRevert( + abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.TooLong) + ); + registry.normalizeHandle(X, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + vm.expectRevert( + abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.Shape) + ); + registry.normalizeHandle(GITHUB, "-octocat"); + } + + /// Every one-call read refuses a platform that is not configured. + function test_theOneCallReadsRefuseAnUnknownPlatform() public { + bytes32 unwired = keccak256("nowhere"); + bytes memory unknown = abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, unwired); + + vm.expectRevert(unknown); + registry.handleBindingOf(unwired, "alice"); + vm.expectRevert(unknown); + registry.handleBindingOf(unwired, "ali ce"); + vm.expectRevert(unknown); + registry.idBindingOf(unwired, "123"); + vm.expectRevert(unknown); + registry.handleOfId(unwired, "123"); + vm.expectRevert(unknown); + registry.idOfHandle(unwired, "alice"); + vm.expectRevert(unknown); + registry.normalizeHandle(unwired, "alice"); + } + + /// The four resolvers refuse a platform with rules and no verifier, like + /// `resolveHandle`; `normalizeHandle` answers from the rules, like + /// `handleHashOf`. + function test_theOneCallReadsOnAPlatformWithoutAVerifier() public { + bytes32 fresh = keccak256("fresh"); + vm.prank(owner); + registry.setPlatform(fresh, HandleVectors.rulesFor(X)); + bytes memory unknown = abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, fresh); + + vm.expectRevert(unknown); + registry.handleBindingOf(fresh, "alice"); + vm.expectRevert(unknown); + registry.idBindingOf(fresh, "123"); + vm.expectRevert(unknown); + registry.handleOfId(fresh, "123"); + vm.expectRevert(unknown); + registry.idOfHandle(fresh, "alice"); + + assertEq(registry.normalizeHandle(fresh, "@Alice"), "alice"); + } + + /// A platform whose every version was retired keeps answering what it + /// holds, as the other resolvers do. + function test_theOneCallReadsOutliveTheLastVersion() public { + _bind(alice, "123", "alice", 100); + vm.prank(owner); + proofVerifier.setVerifier(X, V1, IPlatformVerifier(address(0))); + + _assertHandleBinding(X, "alice", alice, 100); + _assertIdBinding(X, "123", alice, 100); + _assertHandle(X, "123", "alice", true); + assertEq(registry.idOfHandle(X, "alice"), "123"); + } + + function _assertHandleBinding(bytes32 platformId, string memory handle, address wantHolder, uint64 wantAt) + internal + view + { + (address holder, uint64 observedAt) = registry.handleBindingOf(platformId, handle); + assertEq(holder, wantHolder, "holder"); + assertEq(observedAt, wantAt, "observedAt"); + } + + function _assertIdBinding(bytes32 platformId, string memory id, address wantHolder, uint64 wantAt) internal view { + (address holder, uint64 observedAt) = registry.idBindingOf(platformId, id); + assertEq(holder, wantHolder, "holder"); + assertEq(observedAt, wantAt, "observedAt"); + } + + function _assertHandle(bytes32 platformId, string memory id, string memory wantHandle, bool wantCurrent) + internal + view + { + (string memory handle, bool current) = registry.handleOfId(platformId, id); + assertEq(handle, wantHandle, "handle"); + assertEq(current, wantCurrent, "current"); + } + // ─── Ownership ────────────────────────────────────────────────── /// The owner has no privileged path to a binding. It obeys the same target diff --git a/ts/packages/contracts/README.md b/ts/packages/contracts/README.md index b1e561a4..2440c3ee 100644 --- a/ts/packages/contracts/README.md +++ b/ts/packages/contracts/README.md @@ -77,8 +77,11 @@ const rotate = calls.googleJwtRoots.rotate(roots, fee, attestedData, proof) ```ts import { + handleBindingOf, + handleOfId, identitiesOf, identityCount, + idOfHandle, platformId, publishedHandleOf, resolveHandle, @@ -93,6 +96,15 @@ const x = platformId(PLATFORM_X_KEY) // normalization happens on chain. const holder = await resolveHandle(reader, x, '@Alice') +// The holder and when the platform stated it, in one call. A handle its +// identity renamed away from has a null holder beside the old observedAt. +const { holder: h, observedAt } = await handleBindingOf(reader, x, '@Alice') + +// From a handle to the id that holds it now, and from an id to its latest +// handle (current: false once another identity has proved that handle). +const id = await idOfHandle(reader, x, '@Alice') // '42', or null +const latest = await handleOfId(reader, x, '42') // { handle: 'alice', current: true }, or null + // Before sending funds: does the id still agree with the handle? const { idAgrees } = await resolveHandleAndId(reader, x, 'alice', '42') @@ -136,6 +148,10 @@ normalize(' @Alice_1 ', RULES_X) // 'alice_1' // Throws HandleError (with a kind matching the on-chain error) on refusal. ``` +The same answer from the chain, under the rules configured there now, is +`await normalizeHandle(reader, x, ' @Alice_1 ')`; it rejects with +`UnusableHandle` where `normalize` throws. + ## Deriving a handle node Hash locally, so the handle text never reaches an RPC: diff --git a/ts/packages/contracts/src/identity/resolve.test.ts b/ts/packages/contracts/src/identity/resolve.test.ts index b2695fb1..b596d80b 100644 --- a/ts/packages/contracts/src/identity/resolve.test.ts +++ b/ts/packages/contracts/src/identity/resolve.test.ts @@ -9,8 +9,13 @@ import { describe, expect, it, vi } from 'vitest' import { PLATFORM_X_KEY } from './handleVectors.js' import { + handleBindingOf, + handleOfId, + idBindingOf, identitiesOf, identityCount, + idOfHandle, + normalizeHandle, platformId, publishedHandleOf, type RegistryReader, @@ -76,6 +81,104 @@ describe('resolving a handle', () => { }) }) +describe('reading a binding with its age', () => { + it('reads the holder and observedAt of an id', async () => { + const readContract = vi.fn().mockResolvedValue([ALICE, 100n]) + expect(await idBindingOf(reader(readContract), X, '42')).toEqual({ + holder: ALICE, + observedAt: 100n, + }) + + expect(readContract.mock.calls[0][0]).toMatchObject({ + address: CONTRACT, + functionName: 'idBindingOf', + args: [X, '42'], + }) + }) + + it('reads the holder and observedAt of a handle, passing the handle through', async () => { + const readContract = vi.fn().mockResolvedValue([ALICE, 100n]) + expect(await handleBindingOf(reader(readContract), X, ' @Alice ')).toEqual({ + holder: ALICE, + observedAt: 100n, + }) + + expect(readContract.mock.calls[0][0]).toMatchObject({ + functionName: 'handleBindingOf', + args: [X, ' @Alice '], + }) + }) + + /// A retired handle has no holder and keeps its watermark. + it('reports no holder beside a retired watermark', async () => { + const readContract = vi.fn().mockResolvedValue([zeroAddress, 100n]) + expect(await handleBindingOf(reader(readContract), X, 'alice')).toEqual({ + holder: null, + observedAt: 100n, + }) + }) + + it('reports an unbound id or handle as null at zero', async () => { + const readContract = vi.fn().mockResolvedValue([zeroAddress, 0n]) + const none = { holder: null, observedAt: 0n } + + expect(await idBindingOf(reader(readContract), X, 'nobody')).toEqual(none) + expect(await handleBindingOf(reader(readContract), X, 'not a handle')).toEqual(none) + }) +}) + +describe('moving between an id and its handle', () => { + it('reads the latest handle of an id and whether it is still current', async () => { + const readContract = vi.fn().mockResolvedValue(['alice', false]) + expect(await handleOfId(reader(readContract), X, '42')).toStrictEqual({ + handle: 'alice', + current: false, + }) + + expect(readContract.mock.calls[0][0]).toMatchObject({ + functionName: 'handleOfId', + args: [X, '42'], + }) + }) + + it('reports an id never proved as null', async () => { + const readContract = vi.fn().mockResolvedValue(['', false]) + expect(await handleOfId(reader(readContract), X, 'nobody')).toBeNull() + }) + + it('reads the id a handle belongs to', async () => { + const readContract = vi.fn().mockResolvedValue('42') + expect(await idOfHandle(reader(readContract), X, '@Alice')).toBe('42') + + expect(readContract.mock.calls[0][0]).toMatchObject({ + functionName: 'idOfHandle', + args: [X, '@Alice'], + }) + }) + + it('reports a handle nobody holds as null', async () => { + const readContract = vi.fn().mockResolvedValue('') + expect(await idOfHandle(reader(readContract), X, 'nobody')).toBeNull() + }) + + it('reads a handle normalized under the rules on chain', async () => { + const readContract = vi.fn().mockResolvedValue('alice') + expect(await normalizeHandle(reader(readContract), X, ' @Alice ')).toBe('alice') + + expect(readContract.mock.calls[0][0]).toMatchObject({ + functionName: 'normalizeHandle', + args: [X, ' @Alice '], + }) + }) + + /// The contract refuses text its rules refuse here, as `handleHashOf` does. + it('lets a refused handle surface', async () => { + const readContract = vi.fn().mockRejectedValue(revertingWith('UnusableHandle')) + + await expect(normalizeHandle(reader(readContract), X, 'ali ce')).rejects.toThrow() + }) +}) + describe('resolving a holder back to a handle', () => { it('reads the published handle', async () => { const readContract = vi.fn().mockResolvedValue('alice') diff --git a/ts/packages/contracts/src/identity/resolve.ts b/ts/packages/contracts/src/identity/resolve.ts index 177690fd..44be8b98 100644 --- a/ts/packages/contracts/src/identity/resolve.ts +++ b/ts/packages/contracts/src/identity/resolve.ts @@ -97,6 +97,92 @@ export async function resolveHandle( return holder === zeroAddress ? null : holder } +/// A holder and the moment the platform stated it. +export interface Binding { + /// The holder, or `null`. + holder: Address | null + /// When the platform stated the binding, in seconds on the scale every + /// platform shares. `0n` when nobody proved it. + observedAt: bigint +} + +/// The holder that proved this id and when, in one call +/// (`IdentityRegistry.idBindingOf`). +export async function idBindingOf( + reader: RegistryReader, + platformId: `0x${string}`, + id: string, +): Promise { + const [holder, observedAt] = await read<[Address, bigint]>(reader, 'idBindingOf', [ + platformId, + id, + ]) + return { holder: holder === zeroAddress ? null : holder, observedAt } +} + +/// The holder that last proved this handle and when, in one call +/// (`IdentityRegistry.handleBindingOf`). +/// +/// Read the way `resolveHandle` reads: normalized on chain, and a string the +/// rules refuse answers `{holder: null, observedAt: 0n}`. A handle its +/// identity renamed away from answers `holder: null` beside the `observedAt` +/// of the proof that last held it. +export async function handleBindingOf( + reader: RegistryReader, + platformId: `0x${string}`, + handle: string, +): Promise { + const [holder, observedAt] = await read<[Address, bigint]>(reader, 'handleBindingOf', [ + platformId, + handle, + ]) + return { holder: holder === zeroAddress ? null : holder, observedAt } +} + +/// The handle an identity proved most recently. +export interface HandleOfId { + /// As normalized on chain when it was proved. + handle: string + /// True while the handle node still points back at this identity, as + /// `Identity.handleCurrent`. + current: boolean +} + +/// The handle an id proved most recently, or `null` for an id never proved +/// (`IdentityRegistry.handleOfId`). +export async function handleOfId( + reader: RegistryReader, + platformId: `0x${string}`, + id: string, +): Promise { + const [handle, current] = await read<[string, boolean]>(reader, 'handleOfId', [platformId, id]) + return handle.length === 0 ? null : { handle, current } +} + +/// The id of the identity a handle belongs to now, or `null` +/// (`IdentityRegistry.idOfHandle`). Normalized on chain; a string the rules +/// refuse answers `null`. +export async function idOfHandle( + reader: RegistryReader, + platformId: `0x${string}`, + handle: string, +): Promise { + const id = await read(reader, 'idOfHandle', [platformId, handle]) + return id.length === 0 ? null : id +} + +/// A handle normalized under the platform's rules as configured on chain now +/// (`IdentityRegistry.normalizeHandle`). A string the rules refuse rejects +/// with `UnusableHandle`. `normalize` under `rulesOf` answers the same without +/// sending the handle to an RPC. +export async function normalizeHandle( + reader: RegistryReader, + platformId: `0x${string}`, + handle: string, +): Promise { + return read(reader, 'normalizeHandle', [platformId, handle]) +} + /// The handle to show for a holder, or `null`. /// /// Forward-checked on chain: empty once the stored handle resolves somewhere From 8fa5679ac5fc60f6c972ce475b2ad1d0c717231c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 18:07:35 +0300 Subject: [PATCH 02/11] feat(ts): viem-free @libid/contracts/handle subpath `@libid/contracts/identity` re-exports the resolvers and node hashing, which import viem when the module loads, so a consumer that only normalizes a handle had to install viem. The new `./handle` export carries handle.ts and handleVectors.ts alone: normalize, rulesFor, Rules, RULES_*, HandleError, the PLATFORM_*_KEY constants and the generated vector table. `./identity` keeps exporting all of it. Checked from the packed tarball installed alone in an empty directory: `import('@libid/contracts/handle')` loads and normalizes, while `@libid/contracts/identity` fails with ERR_MODULE_NOT_FOUND for viem. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/packages/contracts/README.md | 7 ++++--- ts/packages/contracts/package.json | 4 ++++ ts/packages/contracts/src/handle/index.ts | 8 ++++++++ ts/packages/contracts/src/index.ts | 3 ++- 4 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 ts/packages/contracts/src/handle/index.ts diff --git a/ts/packages/contracts/README.md b/ts/packages/contracts/README.md index 2440c3ee..f3a23018 100644 --- a/ts/packages/contracts/README.md +++ b/ts/packages/contracts/README.md @@ -20,8 +20,9 @@ pnpm add @libid/contracts viem ``` `viem` is an optional peer. The root export, `calls` and `identity` import it; -`@libid/contracts/ceremony` imports nothing, so a consumer of that subpath alone -can leave it out. +`@libid/contracts/ceremony` and `@libid/contracts/handle` (handle +normalization, the platform rules and the vector table) import nothing, so a +consumer of those subpaths alone can leave it out. ## Reading a contract with viem @@ -142,7 +143,7 @@ const call = calls.identityRegistry.bind(IDENTITY_REGISTRY, fee, platformId(PLAT ## Normalizing a handle locally ```ts -import { normalize, RULES_X, HandleError } from '@libid/contracts/identity' +import { normalize, RULES_X, HandleError } from '@libid/contracts/handle' normalize(' @Alice_1 ', RULES_X) // 'alice_1' // Throws HandleError (with a kind matching the on-chain error) on refusal. diff --git a/ts/packages/contracts/package.json b/ts/packages/contracts/package.json index 337b81af..c268f0fe 100644 --- a/ts/packages/contracts/package.json +++ b/ts/packages/contracts/package.json @@ -34,6 +34,10 @@ "types": "./dist/identity/index.d.ts", "default": "./dist/identity/index.js" }, + "./handle": { + "types": "./dist/handle/index.d.ts", + "default": "./dist/handle/index.js" + }, "./calls": { "types": "./dist/calls/index.d.ts", "default": "./dist/calls/index.js" diff --git a/ts/packages/contracts/src/handle/index.ts b/ts/packages/contracts/src/handle/index.ts new file mode 100644 index 00000000..12159f64 --- /dev/null +++ b/ts/packages/contracts/src/handle/index.ts @@ -0,0 +1,8 @@ +/// Handle normalization on its own, with no dependency on viem. +/// +/// `@libid/contracts/identity` also carries the resolvers and node hashing, +/// which import viem when the module loads. A consumer that only normalizes a +/// handle, or reads the generated rules and vectors, imports this instead. + +export * from '../identity/handle.js' +export * from '../identity/handleVectors.js' diff --git a/ts/packages/contracts/src/index.ts b/ts/packages/contracts/src/index.ts index 6b59250f..27174625 100644 --- a/ts/packages/contracts/src/index.ts +++ b/ts/packages/contracts/src/index.ts @@ -1,7 +1,8 @@ /// @libid/contracts — typed viem-ready ABIs for every contract, a call builder /// for every state-changing function, and the identity helper layer. Subpath /// imports work too: `@libid/contracts/abis`, `@libid/contracts/calls`, -/// `@libid/contracts/identity` and `@libid/contracts/ceremony`. +/// `@libid/contracts/identity`, `@libid/contracts/handle` and +/// `@libid/contracts/ceremony`. export * from './abis/index.js' export type { Call } from './call.js' From f1ddee1efc88d4ed70449c3691f54fb849af0d9c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 18:07:42 +0300 Subject: [PATCH 03/11] feat(ts): export the ENS Parent Name ENS_PARENT_NAME is `handles.link`, the name scripts/setup-ens-resolver.sh and the deploy workflow set HandleResolver on in production. The testnet deployment uses `testnet.handles.link` on Sepolia (deploy.yml). Exported from `@libid/contracts/identity` and the root; the Rust crate keeps no ENS constants, so it has no counterpart there. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/packages/contracts/src/identity/ens.ts | 4 ++++ ts/packages/contracts/src/identity/index.ts | 1 + 2 files changed, 5 insertions(+) create mode 100644 ts/packages/contracts/src/identity/ens.ts diff --git a/ts/packages/contracts/src/identity/ens.ts b/ts/packages/contracts/src/identity/ens.ts new file mode 100644 index 00000000..4aeaaa24 --- /dev/null +++ b/ts/packages/contracts/src/identity/ens.ts @@ -0,0 +1,4 @@ +/// The ENS name `HandleResolver` is set on in production: every handle's ENS +/// name sits under it. The testnet deployment uses `testnet.handles.link` on +/// Sepolia. +export const ENS_PARENT_NAME = 'handles.link' diff --git a/ts/packages/contracts/src/identity/index.ts b/ts/packages/contracts/src/identity/index.ts index f72f6548..cff23a93 100644 --- a/ts/packages/contracts/src/identity/index.ts +++ b/ts/packages/contracts/src/identity/index.ts @@ -8,6 +8,7 @@ /// Calldata for the writes is generated too, in `../calls` — `unpublish` had a /// hand-written builder here until every write function got one. +export * from './ens.js' export * from './handle.js' export * from './handleVectors.js' export * from './node.js' From efb48999de2cba0f1c3db941d618d8b76fee71a5 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 18:07:42 +0300 Subject: [PATCH 04/11] docs(ens): cite the ENS spec for HandleResolver's key separation libID PR #93 moves design/ens-integration.md to specs/ens-integration.md; the NatSpec now cites the spec and REQ-ENS-KEY-01. The existing rule against sharing a signing key between deployments that could land on one address agrees with REQ-ENS-KEY-02 and is unchanged. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- solidity/contracts/ens/HandleResolver.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/solidity/contracts/ens/HandleResolver.sol b/solidity/contracts/ens/HandleResolver.sol index bfe2fad1..81d37517 100644 --- a/solidity/contracts/ens/HandleResolver.sol +++ b/solidity/contracts/ens/HandleResolver.sol @@ -43,7 +43,8 @@ import {IExtendedResolver} from "./IExtendedResolver.sol"; /// lives, and that every change is a transaction visible in the registry. /// Holding the owner key, the deployer key and the ENS name's owner as ONE /// identity — which the current setup does — means one compromise reaches -/// all three. See `libid/design/ens-integration.md`. +/// all three. See libID `specs/ens-integration.md`, REQ-ENS-KEY-01 +/// (libID PR #93). /// /// The signature covers the resolver, an expiry, the request and the /// result, so an answer cannot be replayed against another resolver, From a10418a5c95b50d4818e1e9c4a8963aa26489729 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:40:20 +0300 Subject: [PATCH 05/11] fix(identity): review fixes for the one-call registry reads - IIdentityRegistry describes itself as the registry's reads, for contracts and clients alike, rather than only what other contracts ask. - Its NatSpec for handleOfId and idOfHandle, and the Rust sol! docs, state that both revert UnknownPlatform for an unwired platform. - SettableRegistry's stubs for the five reads revert "not modelled" instead of answering made-up values a later escrow test could come to rely on. - resolveHandleAndId reads its handle node through _usableHandleNode, the gate the other handle reads share. - handleOfId documents that `current` reads the nodes and stays true after the rules narrow while resolveHandle, handleBindingOf and idOfHandle answer nobody; idOfHandle and handleOfId note how they differ from the raw handleNodeById/idNodeByHandle pointers an indexer may mirror under similar names. The names stay: idOfHandle is documented as live, and handleOfId returns the pointer's handle with `current` beside it. - Tests: handleBindingOf keeps platforms apart for `alice`, valid on X and GitHub; idOfHandle answers empty after the rules narrow, with resolveHandle and handleBindingOf, while handleOfId stays current. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- rust/contracts/src/bindings/identity.rs | 9 +++-- .../contracts/escrow/test/EscrowMocks.sol | 19 +++++----- .../contracts/identity/IIdentityRegistry.sol | 13 ++++--- .../contracts/identity/IdentityRegistry.sol | 30 +++++++++------- .../identity/test/IdentityRegistry.t.sol | 35 ++++++++++++++++++- 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/rust/contracts/src/bindings/identity.rs b/rust/contracts/src/bindings/identity.rs index 15d24fca..ac038a80 100644 --- a/rust/contracts/src/bindings/identity.rs +++ b/rust/contracts/src/bindings/identity.rs @@ -83,10 +83,13 @@ mod registry_inner { function handleBindingOf(bytes32 platformId, string calldata handle) external view returns (address holder, uint64 observedAt); /// The handle an id proved most recently, and whether the handle /// node still points back at that identity. Empty and false for an - /// id never proved. + /// id never proved. `current` reads the nodes, not the rules, so it + /// stays true after the rules narrow and the handle no longer + /// resolves. Reverts `UnknownPlatform` for an unwired platform. function handleOfId(bytes32 platformId, string calldata id) external view returns (string memory handle, bool current); - /// The id of the identity a handle belongs to now. Empty when none - /// does, or for text the rules refuse. + /// The id of the identity that holds a handle now. Empty when + /// nobody holds it, or for text the rules refuse. Reverts + /// `UnknownPlatform` for an unwired platform. function idOfHandle(bytes32 platformId, string calldata handle) external view returns (string memory id); /// The handle's holder, and whether `id` resolves to that same /// holder. diff --git a/solidity/contracts/escrow/test/EscrowMocks.sol b/solidity/contracts/escrow/test/EscrowMocks.sol index 7dbadd6d..205377c8 100644 --- a/solidity/contracts/escrow/test/EscrowMocks.sol +++ b/solidity/contracts/escrow/test/EscrowMocks.sol @@ -40,25 +40,28 @@ contract SettableRegistry is IIdentityRegistry { return platformId != bytes32(0); } - function normalizeHandle(bytes32, string calldata handle) external pure returns (string memory) { - return handle; + // The registry's one-call reads are not modelled: escrow reads holders + // through `handleBinding` alone. Each reverts, so a test that starts + // depending on one fails here instead of reading a made-up answer. + + function normalizeHandle(bytes32, string calldata) external pure returns (string memory) { + revert("SettableRegistry: normalizeHandle not modelled"); } - function handleBindingOf(bytes32 platformId, string calldata handle) external view returns (address, uint64) { - return (holderOf[IdentityNodes.handleNode(platformId, handle)], 0); + function handleBindingOf(bytes32, string calldata) external pure returns (address, uint64) { + revert("SettableRegistry: handleBindingOf not modelled"); } - /// Ids are not modelled: every id reads as never proved. function idBindingOf(bytes32, string calldata) external pure returns (address, uint64) { - return (address(0), 0); + revert("SettableRegistry: idBindingOf not modelled"); } function handleOfId(bytes32, string calldata) external pure returns (string memory, bool) { - return ("", false); + revert("SettableRegistry: handleOfId not modelled"); } function idOfHandle(bytes32, string calldata) external pure returns (string memory) { - return ""; + revert("SettableRegistry: idOfHandle not modelled"); } } diff --git a/solidity/contracts/identity/IIdentityRegistry.sol b/solidity/contracts/identity/IIdentityRegistry.sol index a3e46b05..285693a4 100644 --- a/solidity/contracts/identity/IIdentityRegistry.sol +++ b/solidity/contracts/identity/IIdentityRegistry.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.20; import {HandleNormalizer} from "./HandleNormalizer.sol"; -/// @notice What other contracts, `HandleEscrow` among them, ask the identity -/// registry. +/// @notice The identity registry's reads: what `HandleEscrow` and other +/// contracts ask, and the one-call reads a client resolves with. interface IIdentityRegistry { /// This platform is not configured. error UnknownPlatform(bytes32 platformId); @@ -55,13 +55,16 @@ interface IIdentityRegistry { returns (address holder, uint64 observedAt); /// @notice The handle this id proved most recently, and whether the handle - /// still belongs to this identity. Empty for an id never proved. + /// node still points back at this identity. `("", false)` for an id + /// never proved. Reverts `UnknownPlatform` for a platform that is + /// not wired. function handleOfId(bytes32 platformId, string calldata id) external view returns (string memory handle, bool current); - /// @notice The id of the identity this handle belongs to now. Empty when - /// none does, or for text the rules refuse. + /// @notice The id of the identity that holds this handle now. Empty when + /// nobody holds it, or for text the rules refuse. Reverts + /// `UnknownPlatform` for a platform that is not wired. function idOfHandle(bytes32 platformId, string calldata handle) external view returns (string memory id); } diff --git a/solidity/contracts/identity/IdentityRegistry.sol b/solidity/contracts/identity/IdentityRegistry.sol index 36cc153b..fcd8bf31 100644 --- a/solidity/contracts/identity/IdentityRegistry.sol +++ b/solidity/contracts/identity/IdentityRegistry.sol @@ -928,12 +928,16 @@ contract IdentityRegistry is return (b.holder, b.observedAt); } - /// @notice The id of the identity this handle belongs to now, or empty. + /// @notice The id of the identity that holds this handle now, or empty. /// - /// @dev Empty when nobody proved the handle, when the identity that - /// proved it has since proved another one, and for text the rules - /// refuse. An unwired platform reverts `UnknownPlatform`. The id is - /// byte for byte as the platform issued it. + /// @dev Live: answers only while the handle has a holder, so it agrees + /// with `resolveHandle`. Empty when nobody proved the handle, when the + /// identity that proved it has since proved another one, and for text + /// the rules refuse. The `idNodeByHandle` pointer it reads keeps + /// naming the identity that renamed away; an indexer mirroring that + /// pointer under a similar name answers differently from this. An + /// unwired platform reverts `UnknownPlatform`. The id is byte for byte + /// as the platform issued it. function idOfHandle(bytes32 platformId, string calldata handle) external view returns (string memory id) { (bool normalizes, bytes32 handleNode) = _usableHandleNode(platformId, handle); if (!normalizes || _s().handleBindings[handleNode].holder == address(0)) return ""; @@ -946,9 +950,13 @@ contract IdentityRegistry is /// @dev `current` is `identitiesOf`'s `handleCurrent`: the handle node /// points back at this identity. It turns false once another /// identity proves the handle, and the string stays as the last - /// thing this identity was known as. It reads the nodes, so a change - /// to the platform's rules is seen by `resolveHandle` before it is - /// seen here. Reverts as `resolveId` does. + /// thing this identity was known as. It reads the nodes, not the + /// rules: after the owner narrows a platform's rules so the handle no + /// longer normalizes, `current` stays true while `resolveHandle`, + /// `handleBindingOf` and `idOfHandle` answer nobody for that text. + /// The handle comes from the `handleNodeById` pointer, which an + /// indexer may mirror under a similar name; `current` is what this + /// adds to it. Reverts as `resolveId` does. function handleOfId(bytes32 platformId, string calldata id) external view @@ -1071,10 +1079,8 @@ contract IdentityRegistry is view returns (address holder, bool idAgrees) { - Platform memory platform = _requireUsable(platformId); - - (HandleNormalizer.Problem problem, bytes32 handleNode) = _handleNode(platformId, handle, platform.rules); - holder = problem == HandleNormalizer.Problem.None ? _s().handleBindings[handleNode].holder : address(0); + (bool normalizes, bytes32 handleNode) = _usableHandleNode(platformId, handle); + if (normalizes) holder = _s().handleBindings[handleNode].holder; address idHolder = _s().idBindings[IdentityNodes.idNode(platformId, id)].holder; // An unknown id does not agree either. A caller with an id the chain diff --git a/solidity/contracts/identity/test/IdentityRegistry.t.sol b/solidity/contracts/identity/test/IdentityRegistry.t.sol index cd2d6a46..0f80ece7 100644 --- a/solidity/contracts/identity/test/IdentityRegistry.t.sol +++ b/solidity/contracts/identity/test/IdentityRegistry.t.sol @@ -1018,7 +1018,22 @@ contract IdentityRegistryTest is Test { _assertHandleBinding(X, "@ALICE_1", alice, 100); _assertHandleBinding(X, " @Alice_1 ", alice, 100); _assertHandleBinding(X, "nobody", address(0), 0); - _assertHandleBinding(GITHUB, "alice_1", address(0), 0); + } + + /// One handle text on two platforms is two bindings: `alice` is valid on + /// both, and each platform answers only its own. + function test_handleBindingOfKeepsPlatformsApart() public { + _bind(alice, "123", "alice", 100); + _assertHandleBinding(GITHUB, "alice", address(0), 0); + + _stage("123", "alice", bob, 200); + vm.prank(bob); + _submit(GITHUB, false); + + _assertHandleBinding(X, "alice", alice, 100); + _assertHandleBinding(GITHUB, "alice", bob, 200); + assertEq(registry.idOfHandle(X, "alice"), "123"); + assertEq(registry.idOfHandle(GITHUB, "alice"), "123"); } /// Text the rules refuse answers `(0, 0)`, as `resolveHandle` answers @@ -1147,6 +1162,24 @@ contract IdentityRegistryTest is Test { assertEq(registry.idOfHandle(X, "alice"), "456"); } + /// After the owner narrows the rules so a bound handle no longer + /// normalizes, `idOfHandle` answers nobody with `resolveHandle`, while + /// `handleOfId` still reports the handle as current. + function test_idOfHandleFollowsTheRulesWhenTheyNarrow() public { + _bind(alice, "123", "with_score", 100); + assertEq(registry.idOfHandle(X, "with_score"), "123"); + + HandleNormalizer.Rules memory rules = HandleVectors.rulesFor(X); + rules.allowUnderscore = false; + vm.prank(owner); + registry.setPlatform(X, rules); + + assertEq(registry.resolveHandle(X, "with_score"), address(0)); + assertEq(registry.idOfHandle(X, "with_score"), ""); + _assertHandleBinding(X, "with_score", address(0), 0); + _assertHandle(X, "123", "with_score", true); + } + function test_idOfHandleIsEmptyForTextTheRulesRefuse() public { _bind(alice, "123", "alice", 100); From 890cb34d52c9ab42469d325a9d4c4ec8c9337cf4 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:40:34 +0300 Subject: [PATCH 06/11] refactor(ts): type registry reads from the ABI, drop normalizeHandle - The resolvers' read helper takes its function name, arguments and result type from the generated identityRegistryAbi (viem's ContractFunctionName, ContractFunctionArgs and ContractFunctionReturnType), so a misspelled name or a result read in the wrong shape fails to compile. Every wrapper in resolve.ts uses it. - Tests for idBindingOf, handleBindingOf, handleOfId and idOfHandle run through a real viem PublicClient whose transport answers eth_call with results encoded by the ABI and decodes the calldata it receives, and they assert UnknownPlatform by name on a decoded revert. - The normalizeHandle wrapper is gone: it sent the handle to an RPC, and `normalize(handle, await rulesOf(reader, x))` gives the same answer locally. The README says so, and that rulesOf needs `/identity` (viem and an RPC). - HandleOfId.current and the README document that it reads the nodes and stays true after the rules narrow while handleBindingOf and idOfHandle answer null. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/packages/contracts/README.md | 11 +- .../contracts/src/identity/resolve.test.ts | 161 +++++++++++------- ts/packages/contracts/src/identity/resolve.ts | 92 +++++----- 3 files changed, 148 insertions(+), 116 deletions(-) diff --git a/ts/packages/contracts/README.md b/ts/packages/contracts/README.md index f3a23018..6d1cd989 100644 --- a/ts/packages/contracts/README.md +++ b/ts/packages/contracts/README.md @@ -105,6 +105,9 @@ const { holder: h, observedAt } = await handleBindingOf(reader, x, '@Alice') // handle (current: false once another identity has proved that handle). const id = await idOfHandle(reader, x, '@Alice') // '42', or null const latest = await handleOfId(reader, x, '42') // { handle: 'alice', current: true }, or null +// `current` reads the nodes, not the rules: after the platform's rules narrow so +// 'alice' no longer normalizes, it stays true while handleBindingOf and +// idOfHandle answer null. Route by those. // Before sending funds: does the id still agree with the handle? const { idAgrees } = await resolveHandleAndId(reader, x, 'alice', '42') @@ -149,9 +152,11 @@ normalize(' @Alice_1 ', RULES_X) // 'alice_1' // Throws HandleError (with a kind matching the on-chain error) on refusal. ``` -The same answer from the chain, under the rules configured there now, is -`await normalizeHandle(reader, x, ' @Alice_1 ')`; it rejects with -`UnusableHandle` where `normalize` throws. +To normalize under the rules a chain has configured now, read them once and +normalize locally: `normalize(handle, await rulesOf(reader, x))`. `rulesOf` +is in `@libid/contracts/identity`, which needs viem and an RPC; `normalize` +is in both subpaths. The contract's own `normalizeHandle` view gives the same +answer, but it sends the handle to the RPC, so the package does not wrap it. ## Deriving a handle node diff --git a/ts/packages/contracts/src/identity/resolve.test.ts b/ts/packages/contracts/src/identity/resolve.test.ts index b596d80b..e98fe042 100644 --- a/ts/packages/contracts/src/identity/resolve.test.ts +++ b/ts/packages/contracts/src/identity/resolve.test.ts @@ -2,11 +2,17 @@ import { type Address, BaseError, ContractFunctionRevertedError, + createPublicClient, + custom, + decodeFunctionData, + encodeErrorResult, + encodeFunctionResult, type PublicClient, zeroAddress, } from 'viem' import { describe, expect, it, vi } from 'vitest' +import { identityRegistryAbi } from '../abis/identityRegistry.js' import { PLATFORM_X_KEY } from './handleVectors.js' import { handleBindingOf, @@ -15,7 +21,6 @@ import { identitiesOf, identityCount, idOfHandle, - normalizeHandle, platformId, publishedHandleOf, type RegistryReader, @@ -81,101 +86,125 @@ describe('resolving a handle', () => { }) }) +/// A reader whose client is a real viem `PublicClient`, answering `eth_call` +/// with `result` encoded by the generated ABI, or reverting with `revert`. +/// The wrapper under test goes through viem's own encoding and decoding, so a +/// return shape the wrapper reads differently from the ABI fails here. +function chain( + answer: + | { functionName: string; result: unknown } + | { revert: { errorName: string; args: readonly unknown[] } }, +): { reader: RegistryReader; calls: { functionName: string; args: readonly unknown[] }[] } { + const calls: { functionName: string; args: readonly unknown[] }[] = [] + const client = createPublicClient({ + transport: custom( + { + async request({ method, params }) { + if (method !== 'eth_call') throw new Error(`unexpected ${method}`) + const [{ to, data }] = params as [{ to: Address; data: `0x${string}` }] + expect(to).toBe(CONTRACT) + const call = decodeFunctionData({ abi: identityRegistryAbi, data }) + calls.push({ functionName: call.functionName, args: call.args ?? [] }) + if ('revert' in answer) { + throw { + code: 3, + message: 'execution reverted', + data: encodeErrorResult({ + abi: identityRegistryAbi, + errorName: answer.revert.errorName, + args: answer.revert.args, + } as never), + } + } + expect(call.functionName).toBe(answer.functionName) + return encodeFunctionResult({ + abi: identityRegistryAbi, + functionName: answer.functionName, + result: answer.result, + } as never) + }, + }, + { retryCount: 0 }, + ), + }) + return { reader: { client: client as PublicClient, address: CONTRACT }, calls } +} + +/// The custom error a rejected read carries, decoded from the ABI. +async function revertName(promise: Promise): Promise { + const error = await promise.then( + () => { + throw new Error('expected a revert') + }, + (e: unknown) => e, + ) + expect(error).toBeInstanceOf(BaseError) + const reverted = (error as BaseError).walk((e) => e instanceof ContractFunctionRevertedError) + return (reverted as ContractFunctionRevertedError | null)?.data?.errorName +} + describe('reading a binding with its age', () => { it('reads the holder and observedAt of an id', async () => { - const readContract = vi.fn().mockResolvedValue([ALICE, 100n]) - expect(await idBindingOf(reader(readContract), X, '42')).toEqual({ - holder: ALICE, - observedAt: 100n, - }) - - expect(readContract.mock.calls[0][0]).toMatchObject({ - address: CONTRACT, - functionName: 'idBindingOf', - args: [X, '42'], - }) + const { reader, calls } = chain({ functionName: 'idBindingOf', result: [ALICE, 100n] }) + expect(await idBindingOf(reader, X, '42')).toEqual({ holder: ALICE, observedAt: 100n }) + expect(calls).toEqual([{ functionName: 'idBindingOf', args: [X, '42'] }]) }) it('reads the holder and observedAt of a handle, passing the handle through', async () => { - const readContract = vi.fn().mockResolvedValue([ALICE, 100n]) - expect(await handleBindingOf(reader(readContract), X, ' @Alice ')).toEqual({ + const { reader, calls } = chain({ functionName: 'handleBindingOf', result: [ALICE, 100n] }) + expect(await handleBindingOf(reader, X, ' @Alice ')).toEqual({ holder: ALICE, observedAt: 100n, }) - - expect(readContract.mock.calls[0][0]).toMatchObject({ - functionName: 'handleBindingOf', - args: [X, ' @Alice '], - }) + expect(calls).toEqual([{ functionName: 'handleBindingOf', args: [X, ' @Alice '] }]) }) /// A retired handle has no holder and keeps its watermark. it('reports no holder beside a retired watermark', async () => { - const readContract = vi.fn().mockResolvedValue([zeroAddress, 100n]) - expect(await handleBindingOf(reader(readContract), X, 'alice')).toEqual({ - holder: null, - observedAt: 100n, - }) + const { reader } = chain({ functionName: 'handleBindingOf', result: [zeroAddress, 100n] }) + expect(await handleBindingOf(reader, X, 'alice')).toEqual({ holder: null, observedAt: 100n }) }) it('reports an unbound id or handle as null at zero', async () => { - const readContract = vi.fn().mockResolvedValue([zeroAddress, 0n]) const none = { holder: null, observedAt: 0n } + const ids = chain({ functionName: 'idBindingOf', result: [zeroAddress, 0n] }) + expect(await idBindingOf(ids.reader, X, 'nobody')).toEqual(none) + const handles = chain({ functionName: 'handleBindingOf', result: [zeroAddress, 0n] }) + expect(await handleBindingOf(handles.reader, X, 'not a handle')).toEqual(none) + }) - expect(await idBindingOf(reader(readContract), X, 'nobody')).toEqual(none) - expect(await handleBindingOf(reader(readContract), X, 'not a handle')).toEqual(none) + /// An unwired platform is a deployment mistake, not an answer about a + /// binding, so the contract's `UnknownPlatform` reaches the caller. + it('lets UnknownPlatform surface', async () => { + const { reader } = chain({ revert: { errorName: 'UnknownPlatform', args: [X] } }) + expect(await revertName(handleBindingOf(reader, X, 'alice'))).toBe('UnknownPlatform') + expect(await revertName(idBindingOf(reader, X, '42'))).toBe('UnknownPlatform') + expect(await revertName(handleOfId(reader, X, '42'))).toBe('UnknownPlatform') + expect(await revertName(idOfHandle(reader, X, 'alice'))).toBe('UnknownPlatform') }) }) describe('moving between an id and its handle', () => { it('reads the latest handle of an id and whether it is still current', async () => { - const readContract = vi.fn().mockResolvedValue(['alice', false]) - expect(await handleOfId(reader(readContract), X, '42')).toStrictEqual({ - handle: 'alice', - current: false, - }) - - expect(readContract.mock.calls[0][0]).toMatchObject({ - functionName: 'handleOfId', - args: [X, '42'], - }) + const { reader, calls } = chain({ functionName: 'handleOfId', result: ['alice', false] }) + expect(await handleOfId(reader, X, '42')).toStrictEqual({ handle: 'alice', current: false }) + expect(calls).toEqual([{ functionName: 'handleOfId', args: [X, '42'] }]) }) it('reports an id never proved as null', async () => { - const readContract = vi.fn().mockResolvedValue(['', false]) - expect(await handleOfId(reader(readContract), X, 'nobody')).toBeNull() + const { reader } = chain({ functionName: 'handleOfId', result: ['', false] }) + expect(await handleOfId(reader, X, 'nobody')).toBeNull() }) - it('reads the id a handle belongs to', async () => { - const readContract = vi.fn().mockResolvedValue('42') - expect(await idOfHandle(reader(readContract), X, '@Alice')).toBe('42') - - expect(readContract.mock.calls[0][0]).toMatchObject({ - functionName: 'idOfHandle', - args: [X, '@Alice'], - }) + it('reads the id that holds a handle', async () => { + const { reader, calls } = chain({ functionName: 'idOfHandle', result: '42' }) + expect(await idOfHandle(reader, X, '@Alice')).toBe('42') + expect(calls).toEqual([{ functionName: 'idOfHandle', args: [X, '@Alice'] }]) }) it('reports a handle nobody holds as null', async () => { - const readContract = vi.fn().mockResolvedValue('') - expect(await idOfHandle(reader(readContract), X, 'nobody')).toBeNull() - }) - - it('reads a handle normalized under the rules on chain', async () => { - const readContract = vi.fn().mockResolvedValue('alice') - expect(await normalizeHandle(reader(readContract), X, ' @Alice ')).toBe('alice') - - expect(readContract.mock.calls[0][0]).toMatchObject({ - functionName: 'normalizeHandle', - args: [X, ' @Alice '], - }) - }) - - /// The contract refuses text its rules refuse here, as `handleHashOf` does. - it('lets a refused handle surface', async () => { - const readContract = vi.fn().mockRejectedValue(revertingWith('UnusableHandle')) - - await expect(normalizeHandle(reader(readContract), X, 'ali ce')).rejects.toThrow() + const { reader } = chain({ functionName: 'idOfHandle', result: '' }) + expect(await idOfHandle(reader, X, 'nobody')).toBeNull() }) }) diff --git a/ts/packages/contracts/src/identity/resolve.ts b/ts/packages/contracts/src/identity/resolve.ts index 44be8b98..fc11d14f 100644 --- a/ts/packages/contracts/src/identity/resolve.ts +++ b/ts/packages/contracts/src/identity/resolve.ts @@ -8,7 +8,16 @@ /// package ships separately from the repository it currently lives in, so it /// carries no configuration of its own and reaches for nothing outside itself. -import { type Address, keccak256, type PublicClient, toHex, zeroAddress } from 'viem' +import { + type Address, + type ContractFunctionArgs, + type ContractFunctionName, + type ContractFunctionReturnType, + keccak256, + type PublicClient, + toHex, + zeroAddress, +} from 'viem' import { identityRegistryAbi } from '../abis/identityRegistry.js' import type { Rules } from './handle.js' @@ -30,20 +39,28 @@ export interface RegistryReader { address: Address } -/// One view call. +type RegistryAbi = typeof identityRegistryAbi +type ReadOnly = 'view' | 'pure' +type RegistryRead = ContractFunctionName + +/// One view call, typed from the generated ABI: a function name the contract +/// does not have, wrong arguments, or a return shape read the wrong way fail +/// to compile. /// -/// Routed through a narrow cast for one reason: -/// viem's `PublicClient`, without its chain type parameters, types -/// `authorizationList` as required even for a read. The alternative is to -/// spread that noise across every call site here. -function read( +/// The client goes through a narrow cast for one reason: viem's +/// `PublicClient`, without its chain type parameters, types +/// `authorizationList` as required even for a read. The name, the arguments +/// and the result keep the ABI's types. +function read( reader: RegistryReader, - functionName: string, - args: readonly unknown[], -): Promise { + functionName: N, + args: ContractFunctionArgs, +): Promise> { return ( reader.client as unknown as { - readContract: (request: Record) => Promise + readContract: ( + request: Record, + ) => Promise> } ).readContract({ authorizationList: undefined, @@ -57,7 +74,7 @@ function read( /// The platform's normalization rules as configured on chain now /// (`IdentityRegistry.rulesOf`). export async function rulesOf(reader: RegistryReader, platformId: `0x${string}`): Promise { - const rules = await read(reader, 'rulesOf', [platformId]) + const rules = await read(reader, 'rulesOf', [platformId]) return { maxLength: Number(rules.maxLength), stripLeadingAt: rules.stripLeadingAt, @@ -73,7 +90,7 @@ export async function resolveId( platformId: `0x${string}`, id: string, ): Promise
{ - const holder = await read
(reader, 'resolveId', [platformId, id]) + const holder = await read(reader, 'resolveId', [platformId, id]) return holder === zeroAddress ? null : holder } @@ -93,7 +110,7 @@ export async function resolveHandle( platformId: `0x${string}`, handle: string, ): Promise
{ - const holder = await read
(reader, 'resolveHandle', [platformId, handle]) + const holder = await read(reader, 'resolveHandle', [platformId, handle]) return holder === zeroAddress ? null : holder } @@ -113,10 +130,7 @@ export async function idBindingOf( platformId: `0x${string}`, id: string, ): Promise { - const [holder, observedAt] = await read<[Address, bigint]>(reader, 'idBindingOf', [ - platformId, - id, - ]) + const [holder, observedAt] = await read(reader, 'idBindingOf', [platformId, id]) return { holder: holder === zeroAddress ? null : holder, observedAt } } @@ -132,10 +146,7 @@ export async function handleBindingOf( platformId: `0x${string}`, handle: string, ): Promise { - const [holder, observedAt] = await read<[Address, bigint]>(reader, 'handleBindingOf', [ - platformId, - handle, - ]) + const [holder, observedAt] = await read(reader, 'handleBindingOf', [platformId, handle]) return { holder: holder === zeroAddress ? null : holder, observedAt } } @@ -144,7 +155,10 @@ export interface HandleOfId { /// As normalized on chain when it was proved. handle: string /// True while the handle node still points back at this identity, as - /// `Identity.handleCurrent`. + /// `Identity.handleCurrent`. It reads the nodes, not the rules: after the + /// platform's rules narrow so this handle no longer normalizes, it stays + /// true while `resolveHandle`, `handleBindingOf` and `idOfHandle` answer + /// `null` for the handle. Route by those, not by this flag. current: boolean } @@ -155,34 +169,22 @@ export async function handleOfId( platformId: `0x${string}`, id: string, ): Promise { - const [handle, current] = await read<[string, boolean]>(reader, 'handleOfId', [platformId, id]) + const [handle, current] = await read(reader, 'handleOfId', [platformId, id]) return handle.length === 0 ? null : { handle, current } } -/// The id of the identity a handle belongs to now, or `null` +/// The id of the identity that holds a handle now, or `null` /// (`IdentityRegistry.idOfHandle`). Normalized on chain; a string the rules -/// refuse answers `null`. +/// refuse, and a handle its identity renamed away from, answer `null`. export async function idOfHandle( reader: RegistryReader, platformId: `0x${string}`, handle: string, ): Promise { - const id = await read(reader, 'idOfHandle', [platformId, handle]) + const id = await read(reader, 'idOfHandle', [platformId, handle]) return id.length === 0 ? null : id } -/// A handle normalized under the platform's rules as configured on chain now -/// (`IdentityRegistry.normalizeHandle`). A string the rules refuse rejects -/// with `UnusableHandle`. `normalize` under `rulesOf` answers the same without -/// sending the handle to an RPC. -export async function normalizeHandle( - reader: RegistryReader, - platformId: `0x${string}`, - handle: string, -): Promise { - return read(reader, 'normalizeHandle', [platformId, handle]) -} - /// The handle to show for a holder, or `null`. /// /// Forward-checked on chain: empty once the stored handle resolves somewhere @@ -194,7 +196,7 @@ export async function publishedHandleOf( holder: Address, platformId: `0x${string}`, ): Promise { - const handle = await read(reader, 'publishedHandleOf', [holder, platformId]) + const handle = await read(reader, 'publishedHandleOf', [holder, platformId]) return handle.length === 0 ? null : handle } @@ -228,11 +230,7 @@ export async function resolveHandleAndId( handle: string, id: string, ): Promise { - const [holder, idAgrees] = await read<[Address, boolean]>(reader, 'resolveHandleAndId', [ - platformId, - handle, - id, - ]) + const [holder, idAgrees] = await read(reader, 'resolveHandleAndId', [platformId, handle, id]) return { holder: holder === zeroAddress ? null : holder, idAgrees } } @@ -255,7 +253,7 @@ export interface Identity { /// How many identities a holder has, on every platform together. export async function identityCount(reader: RegistryReader, holder: Address): Promise { - return read(reader, 'identityCount', [holder]) + return read(reader, 'identityCount', [holder]) } /// A page of a holder's identities, on every platform together: @@ -275,7 +273,7 @@ export async function identitiesOf( from: bigint, limit: bigint, ): Promise { - const page = await read(reader, 'identitiesOf', [holder, from, limit]) + const page = await read(reader, 'identitiesOf', [holder, from, limit]) return page.map(({ platformId, id, handle, handleCurrent }) => ({ platformId, id, From 294d9645f48365aa1d37a1ead32d0c6fdb71d0e1 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:40:34 +0300 Subject: [PATCH 07/11] feat(ts): ENS Parent Name on the viem-free handle subpath, with a guard `@libid/contracts/handle` re-exports ENS_PARENT_NAME, which imports nothing. A test walks the subpath's relative imports from handle/index.ts and fails on any package specifier, viem included, and on any module joining the set it loads (handle/index, identity/ens, identity/handle, identity/handleVectors). Adding a viem import to identity/ens.ts makes it fail. Checked again from the packed tarball installed alone: the subpath loads and exports `handles.link`. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/packages/contracts/README.md | 4 +-- .../contracts/src/handle/index.test.ts | 31 +++++++++++++++++++ ts/packages/contracts/src/handle/index.ts | 4 ++- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 ts/packages/contracts/src/handle/index.test.ts diff --git a/ts/packages/contracts/README.md b/ts/packages/contracts/README.md index 6d1cd989..e1e278fd 100644 --- a/ts/packages/contracts/README.md +++ b/ts/packages/contracts/README.md @@ -21,8 +21,8 @@ pnpm add @libid/contracts viem `viem` is an optional peer. The root export, `calls` and `identity` import it; `@libid/contracts/ceremony` and `@libid/contracts/handle` (handle -normalization, the platform rules and the vector table) import nothing, so a -consumer of those subpaths alone can leave it out. +normalization, the platform rules, the vector table and `ENS_PARENT_NAME`) +import nothing, so a consumer of those subpaths alone can leave it out. ## Reading a contract with viem diff --git a/ts/packages/contracts/src/handle/index.test.ts b/ts/packages/contracts/src/handle/index.test.ts new file mode 100644 index 00000000..013d544c --- /dev/null +++ b/ts/packages/contracts/src/handle/index.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +/// `@libid/contracts/handle` exists so a consumer without `viem` can normalize +/// a handle. Every module it loads, followed through its relative imports, +/// imports nothing but other relative modules: not `viem`, and not a sibling +/// layer that does. Type-only imports count too, because they survive into the +/// published declarations. +describe('the handle subpath', () => { + it('loads no package, viem included', () => { + const seen = new Set() + const queue = [new URL('./index.ts', import.meta.url)] + while (queue.length > 0) { + const file = queue.pop()! + if (seen.has(file.href)) continue + seen.add(file.href) + const text = readFileSync(file, 'utf8') + for (const [, specifier] of text.matchAll(/\b(?:from|import)\s*\(?\s*['"]([^'"]+)['"]/g)) { + expect(specifier, file.pathname).toMatch(/^\.\.?\//) + queue.push(new URL(specifier.replace(/\.js$/, '.ts'), file)) + } + } + const loaded = [...seen].map((href) => href.slice(href.indexOf('/src/') + 5)).sort() + expect(loaded).toEqual([ + 'handle/index.ts', + 'identity/ens.ts', + 'identity/handle.ts', + 'identity/handleVectors.ts', + ]) + }) +}) diff --git a/ts/packages/contracts/src/handle/index.ts b/ts/packages/contracts/src/handle/index.ts index 12159f64..c97dcf3e 100644 --- a/ts/packages/contracts/src/handle/index.ts +++ b/ts/packages/contracts/src/handle/index.ts @@ -2,7 +2,9 @@ /// /// `@libid/contracts/identity` also carries the resolvers and node hashing, /// which import viem when the module loads. A consumer that only normalizes a -/// handle, or reads the generated rules and vectors, imports this instead. +/// handle, or reads the generated rules and vectors or the ENS Parent Name, +/// imports this instead. `index.test.ts` keeps it that way. +export * from '../identity/ens.js' export * from '../identity/handle.js' export * from '../identity/handleVectors.js' From b99c9ad7f446c8aa3b000fb006c4e8aafbb7918c Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 19:40:34 +0300 Subject: [PATCH 08/11] docs(ens): state the ENS spec's key rules in HandleResolver Cite libID's ENS integration spec by path, with no pull request number, and state what REQ-ENS-KEY-01 and REQ-ENS-KEY-02 require: the DNS registrar account, the Parent Name and resolver owner, and each Signer are distinct keys, and resolvers trusting one Signer sit at different addresses on whichever chains. The spec's note that the current deployment does not meet KEY-01 replaces the earlier wording about one identity. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- solidity/contracts/ens/HandleResolver.sol | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/solidity/contracts/ens/HandleResolver.sol b/solidity/contracts/ens/HandleResolver.sol index 81d37517..cfc2b948 100644 --- a/solidity/contracts/ens/HandleResolver.sol +++ b/solidity/contracts/ens/HandleResolver.sol @@ -41,10 +41,13 @@ import {IExtendedResolver} from "./IExtendedResolver.sol"; /// transactions, with no gateway key involved. This contract does not /// split that and cannot; what limits the damage is where the owner key /// lives, and that every change is a transaction visible in the registry. -/// Holding the owner key, the deployer key and the ENS name's owner as ONE -/// identity — which the current setup does — means one compromise reaches -/// all three. See libID `specs/ens-integration.md`, REQ-ENS-KEY-01 -/// (libID PR #93). +/// libID's ENS integration spec (`specs/ens-integration.md`, REQ-ENS-KEY-01) +/// keeps three keys apart: the DNS registrar account that holds the +/// Parent Name, the key that owns the Parent Name in ENS and this +/// resolver, and each Signer. Held together, the least guarded one +/// decides all three. The spec records that the current deployment does +/// not meet it: one key, the one the deploy workflow signs with, deploys +/// this resolver, owns it, and owns the Parent Name. /// /// The signature covers the resolver, an expiry, the request and the /// result, so an answer cannot be replayed against another resolver, @@ -54,9 +57,11 @@ import {IExtendedResolver} from "./IExtendedResolver.sol"; /// `ensdomains/offchain-resolver`'s `SignatureVerifier`, which the test /// suite pins byte for byte. So a deployment discipline stands in for it: /// an answer signed for a resolver at address A on one chain verifies -/// unchanged at address A on another. Never share a signing key between -/// deployments that could land on the same address — which deterministic -/// deployment makes the usual outcome rather than the exotic one. +/// unchanged at address A on another. Resolvers that trust one Signer +/// must sit at different addresses, whichever chain they are on +/// (REQ-ENS-KEY-02): one Signer may serve several chains only while the +/// addresses differ, and deterministic deployment makes a shared address +/// the usual outcome rather than the exotic one. /// /// **Not upgradeable, on purpose.** Replacing it is `setResolver` on the /// ENS name, which is one owner transaction and visible in the registry. From cfc09feadff9c6dd26eadf0021ed164854b560c2 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 20:00:47 +0300 Subject: [PATCH 09/11] test(identity): share the refusal and rule-narrowing setup The one-call read tests repeated the UnknownPlatform block for the four resolvers, the UnusableHandle encoding four times, and the X-without- underscores setup already used by the handleCurrent test. Each is one helper now. test_handleOfIdReadsTheNodesNotTheRules is dropped: test_idOfHandleFollowsTheRulesWhenTheyNarrow makes the same assertions after the same setup. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- .../identity/test/IdentityRegistry.t.sol | 90 ++++++++----------- 1 file changed, 36 insertions(+), 54 deletions(-) diff --git a/solidity/contracts/identity/test/IdentityRegistry.t.sol b/solidity/contracts/identity/test/IdentityRegistry.t.sol index 0f80ece7..133126dc 100644 --- a/solidity/contracts/identity/test/IdentityRegistry.t.sol +++ b/solidity/contracts/identity/test/IdentityRegistry.t.sol @@ -334,8 +334,7 @@ contract IdentityRegistryTest is Test { /// Text the rules of the moment refuse reverts with the normalizer's reason, where /// `resolveHandle` answers nobody. function test_theHashingViewsRefuseWhatTheCurrentRulesRefuse() public { - bytes memory badChar = - abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.BadChar); + bytes memory badChar = _unusable(HandleNormalizer.Problem.BadChar); vm.expectRevert(badChar); registry.handleHashOf(X, "ali-ce"); vm.expectRevert(badChar); @@ -698,13 +697,19 @@ contract IdentityRegistryTest is Test { /// does not. function test_handleCurrentReadsTheNodesNotTheRules() public { _bind(alice, "123", "with_score", 100); + _forbidUnderscoresOnX(); + + assertEq(registry.resolveHandle(X, "with_score"), address(0)); + assertTrue(_identity(alice, X, "123").handleCurrent); + } + + /// The owner narrows X's rules so a handle with an underscore no longer + /// normalizes. + function _forbidUnderscoresOnX() internal { HandleNormalizer.Rules memory rules = HandleVectors.rulesFor(X); rules.allowUnderscore = false; vm.prank(owner); registry.setPlatform(X, rules); - - assertEq(registry.resolveHandle(X, "with_score"), address(0)); - assertTrue(_identity(alice, X, "123").handleCurrent); } /// After any sequence of binds: an identity nobody proved is in no @@ -1120,19 +1125,6 @@ contract IdentityRegistryTest is Test { assertEq(current, listed.handleCurrent); } - /// Like `handleCurrent`, it reads the nodes: narrowing the rules is seen - /// by `resolveHandle` first. - function test_handleOfIdReadsTheNodesNotTheRules() public { - _bind(alice, "123", "with_score", 100); - HandleNormalizer.Rules memory rules = HandleVectors.rulesFor(X); - rules.allowUnderscore = false; - vm.prank(owner); - registry.setPlatform(X, rules); - - assertEq(registry.resolveHandle(X, "with_score"), address(0)); - _assertHandle(X, "123", "with_score", true); - } - /// The id of the identity a handle belongs to, through every spelling the /// rules fold together, and following the handle to its next owner. function test_idOfHandleFollowsTheHandle() public { @@ -1169,10 +1161,7 @@ contract IdentityRegistryTest is Test { _bind(alice, "123", "with_score", 100); assertEq(registry.idOfHandle(X, "with_score"), "123"); - HandleNormalizer.Rules memory rules = HandleVectors.rulesFor(X); - rules.allowUnderscore = false; - vm.prank(owner); - registry.setPlatform(X, rules); + _forbidUnderscoresOnX(); assertEq(registry.resolveHandle(X, "with_score"), address(0)); assertEq(registry.idOfHandle(X, "with_score"), ""); @@ -1209,40 +1198,24 @@ contract IdentityRegistryTest is Test { /// Text the rules refuse reverts with the normalizer's reason, as the /// other rules views do. function test_normalizeHandleRefusesWhatTheRulesRefuse() public { - vm.expectRevert( - abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.BadChar) - ); + vm.expectRevert(_unusable(HandleNormalizer.Problem.BadChar)); registry.normalizeHandle(X, "ali-ce"); - vm.expectRevert( - abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.Empty) - ); + vm.expectRevert(_unusable(HandleNormalizer.Problem.Empty)); registry.normalizeHandle(X, "@"); - vm.expectRevert( - abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.TooLong) - ); + vm.expectRevert(_unusable(HandleNormalizer.Problem.TooLong)); registry.normalizeHandle(X, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); - vm.expectRevert( - abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, HandleNormalizer.Problem.Shape) - ); + vm.expectRevert(_unusable(HandleNormalizer.Problem.Shape)); registry.normalizeHandle(GITHUB, "-octocat"); } /// Every one-call read refuses a platform that is not configured. function test_theOneCallReadsRefuseAnUnknownPlatform() public { bytes32 unwired = keccak256("nowhere"); - bytes memory unknown = abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, unwired); - vm.expectRevert(unknown); - registry.handleBindingOf(unwired, "alice"); - vm.expectRevert(unknown); + _expectTheResolversRefuse(unwired); + vm.expectRevert(abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, unwired)); registry.handleBindingOf(unwired, "ali ce"); - vm.expectRevert(unknown); - registry.idBindingOf(unwired, "123"); - vm.expectRevert(unknown); - registry.handleOfId(unwired, "123"); - vm.expectRevert(unknown); - registry.idOfHandle(unwired, "alice"); - vm.expectRevert(unknown); + vm.expectRevert(abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, unwired)); registry.normalizeHandle(unwired, "alice"); } @@ -1253,16 +1226,8 @@ contract IdentityRegistryTest is Test { bytes32 fresh = keccak256("fresh"); vm.prank(owner); registry.setPlatform(fresh, HandleVectors.rulesFor(X)); - bytes memory unknown = abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, fresh); - vm.expectRevert(unknown); - registry.handleBindingOf(fresh, "alice"); - vm.expectRevert(unknown); - registry.idBindingOf(fresh, "123"); - vm.expectRevert(unknown); - registry.handleOfId(fresh, "123"); - vm.expectRevert(unknown); - registry.idOfHandle(fresh, "alice"); + _expectTheResolversRefuse(fresh); assertEq(registry.normalizeHandle(fresh, "@Alice"), "alice"); } @@ -1280,6 +1245,23 @@ contract IdentityRegistryTest is Test { assertEq(registry.idOfHandle(X, "alice"), "123"); } + function _unusable(HandleNormalizer.Problem problem) internal pure returns (bytes memory) { + return abi.encodeWithSelector(IIdentityRegistry.UnusableHandle.selector, problem); + } + + /// The four one-call resolvers each revert `UnknownPlatform`. + function _expectTheResolversRefuse(bytes32 platformId) internal { + bytes memory unknown = abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, platformId); + vm.expectRevert(unknown); + registry.handleBindingOf(platformId, "alice"); + vm.expectRevert(unknown); + registry.idBindingOf(platformId, "123"); + vm.expectRevert(unknown); + registry.handleOfId(platformId, "123"); + vm.expectRevert(unknown); + registry.idOfHandle(platformId, "alice"); + } + function _assertHandleBinding(bytes32 platformId, string memory handle, address wantHolder, uint64 wantAt) internal view From 86f50e00fe55bdf29763282dad3800b3ce817008 Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 20:01:40 +0300 Subject: [PATCH 10/11] refactor(ts): one zero-address helper and a ReadResult alias in resolve.ts Five reads turned the zero address into null with the same ternary; they share `orNull`. The ABI return type `read` spelled twice is one alias. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/packages/contracts/src/identity/resolve.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/ts/packages/contracts/src/identity/resolve.ts b/ts/packages/contracts/src/identity/resolve.ts index fc11d14f..ac85a363 100644 --- a/ts/packages/contracts/src/identity/resolve.ts +++ b/ts/packages/contracts/src/identity/resolve.ts @@ -42,6 +42,7 @@ export interface RegistryReader { type RegistryAbi = typeof identityRegistryAbi type ReadOnly = 'view' | 'pure' type RegistryRead = ContractFunctionName +type ReadResult = ContractFunctionReturnType /// One view call, typed from the generated ABI: a function name the contract /// does not have, wrong arguments, or a return shape read the wrong way fail @@ -55,12 +56,10 @@ function read( reader: RegistryReader, functionName: N, args: ContractFunctionArgs, -): Promise> { +): Promise> { return ( reader.client as unknown as { - readContract: ( - request: Record, - ) => Promise> + readContract: (request: Record) => Promise> } ).readContract({ authorizationList: undefined, @@ -71,6 +70,11 @@ function read( }) } +/// The contract's zero address is "nobody". +function orNull(holder: Address): Address | null { + return holder === zeroAddress ? null : holder +} + /// The platform's normalization rules as configured on chain now /// (`IdentityRegistry.rulesOf`). export async function rulesOf(reader: RegistryReader, platformId: `0x${string}`): Promise { @@ -91,7 +95,7 @@ export async function resolveId( id: string, ): Promise
{ const holder = await read(reader, 'resolveId', [platformId, id]) - return holder === zeroAddress ? null : holder + return orNull(holder) } /// The holder that last proved this handle, or `null`. @@ -111,7 +115,7 @@ export async function resolveHandle( handle: string, ): Promise
{ const holder = await read(reader, 'resolveHandle', [platformId, handle]) - return holder === zeroAddress ? null : holder + return orNull(holder) } /// A holder and the moment the platform stated it. @@ -131,7 +135,7 @@ export async function idBindingOf( id: string, ): Promise { const [holder, observedAt] = await read(reader, 'idBindingOf', [platformId, id]) - return { holder: holder === zeroAddress ? null : holder, observedAt } + return { holder: orNull(holder), observedAt } } /// The holder that last proved this handle and when, in one call @@ -147,7 +151,7 @@ export async function handleBindingOf( handle: string, ): Promise { const [holder, observedAt] = await read(reader, 'handleBindingOf', [platformId, handle]) - return { holder: holder === zeroAddress ? null : holder, observedAt } + return { holder: orNull(holder), observedAt } } /// The handle an identity proved most recently. @@ -232,7 +236,7 @@ export async function resolveHandleAndId( ): Promise { const [holder, idAgrees] = await read(reader, 'resolveHandleAndId', [platformId, handle, id]) - return { holder: holder === zeroAddress ? null : holder, idAgrees } + return { holder: orNull(holder), idAgrees } } /// One identity a holder proved, as the holder's list reports it. From dcb568abdb62c8a877ec27dad75d623b867e729e Mon Sep 17 00:00:00 2001 From: SupremaLex Date: Mon, 5 Oct 2026 20:01:40 +0300 Subject: [PATCH 11/11] docs(ts): show normalizing under the chain's rules as code The paragraph that described `normalize(handle, await rulesOf(...))` is a snippet with its import now, and the note on `current` under narrowed rules is one line; the full rule stays in `HandleOfId`'s doc comment. Assisted-by: Claude Opus 5.5 Signed-off-by: SupremaLex --- ts/packages/contracts/README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/ts/packages/contracts/README.md b/ts/packages/contracts/README.md index e1e278fd..cb54efac 100644 --- a/ts/packages/contracts/README.md +++ b/ts/packages/contracts/README.md @@ -105,9 +105,7 @@ const { holder: h, observedAt } = await handleBindingOf(reader, x, '@Alice') // handle (current: false once another identity has proved that handle). const id = await idOfHandle(reader, x, '@Alice') // '42', or null const latest = await handleOfId(reader, x, '42') // { handle: 'alice', current: true }, or null -// `current` reads the nodes, not the rules: after the platform's rules narrow so -// 'alice' no longer normalizes, it stays true while handleBindingOf and -// idOfHandle answer null. Route by those. +// `current` ignores rule changes. Route by idOfHandle or handleBindingOf. // Before sending funds: does the id still agree with the handle? const { idAgrees } = await resolveHandleAndId(reader, x, 'alice', '42') @@ -152,11 +150,16 @@ normalize(' @Alice_1 ', RULES_X) // 'alice_1' // Throws HandleError (with a kind matching the on-chain error) on refusal. ``` -To normalize under the rules a chain has configured now, read them once and -normalize locally: `normalize(handle, await rulesOf(reader, x))`. `rulesOf` -is in `@libid/contracts/identity`, which needs viem and an RPC; `normalize` -is in both subpaths. The contract's own `normalizeHandle` view gives the same -answer, but it sends the handle to the RPC, so the package does not wrap it. +Under the rules a chain has configured now (`rulesOf` needs viem and an RPC): + +```ts +import { rulesOf } from '@libid/contracts/identity' + +normalize(' @Alice_1 ', await rulesOf(reader, x)) +``` + +The contract's `normalizeHandle` view gives the same answer. It sends the +handle to the RPC, so the package does not wrap it. ## Deriving a handle node