diff --git a/rust/contracts/src/bindings/identity.rs b/rust/contracts/src/bindings/identity.rs index 7d6c38e3..ac038a80 100644 --- a/rust/contracts/src/bindings/identity.rs +++ b/rust/contracts/src/bindings/identity.rs @@ -74,6 +74,23 @@ 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. `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 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. function resolveHandleAndId(bytes32 platformId, string calldata handle, string calldata id) external view returns (address holder, bool idAgrees); @@ -88,6 +105,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/ens/HandleResolver.sol b/solidity/contracts/ens/HandleResolver.sol index bfe2fad1..cfc2b948 100644 --- a/solidity/contracts/ens/HandleResolver.sol +++ b/solidity/contracts/ens/HandleResolver.sol @@ -41,9 +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/design/ens-integration.md`. +/// 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, @@ -53,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. diff --git a/solidity/contracts/escrow/test/EscrowMocks.sol b/solidity/contracts/escrow/test/EscrowMocks.sol index aed706c3..205377c8 100644 --- a/solidity/contracts/escrow/test/EscrowMocks.sol +++ b/solidity/contracts/escrow/test/EscrowMocks.sol @@ -39,6 +39,30 @@ contract SettableRegistry is IIdentityRegistry { function acceptsBindings(bytes32 platformId) external pure returns (bool) { return platformId != bytes32(0); } + + // 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, string calldata) external pure returns (address, uint64) { + revert("SettableRegistry: handleBindingOf not modelled"); + } + + function idBindingOf(bytes32, string calldata) external pure returns (address, uint64) { + revert("SettableRegistry: idBindingOf not modelled"); + } + + function handleOfId(bytes32, string calldata) external pure returns (string memory, bool) { + revert("SettableRegistry: handleOfId not modelled"); + } + + function idOfHandle(bytes32, string calldata) external pure returns (string memory) { + revert("SettableRegistry: idOfHandle not modelled"); + } } /// @notice A plain ERC-20 anybody can mint. diff --git a/solidity/contracts/identity/IIdentityRegistry.sol b/solidity/contracts/identity/IIdentityRegistry.sol index ffe03dcb..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); @@ -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,34 @@ 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 + /// 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 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 cf1b25c2..fcd8bf31 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,84 @@ 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 that holds this handle now, or empty. + /// + /// @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 ""; + 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, 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 + 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 +1053,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}); } } @@ -985,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 d5873854..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 @@ -1006,6 +1011,281 @@ 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); + } + + /// 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 + /// 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); + } + + /// 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"); + } + + /// 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"); + + _forbidUnderscoresOnX(); + + 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); + + 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(_unusable(HandleNormalizer.Problem.BadChar)); + registry.normalizeHandle(X, "ali-ce"); + vm.expectRevert(_unusable(HandleNormalizer.Problem.Empty)); + registry.normalizeHandle(X, "@"); + vm.expectRevert(_unusable(HandleNormalizer.Problem.TooLong)); + registry.normalizeHandle(X, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + 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"); + + _expectTheResolversRefuse(unwired); + vm.expectRevert(abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, unwired)); + registry.handleBindingOf(unwired, "ali ce"); + vm.expectRevert(abi.encodeWithSelector(IIdentityRegistry.UnknownPlatform.selector, unwired)); + 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)); + + _expectTheResolversRefuse(fresh); + + 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 _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 + { + (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..cb54efac 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, 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 @@ -77,8 +78,11 @@ const rotate = calls.googleJwtRoots.rotate(roots, fee, attestedData, proof) ```ts import { + handleBindingOf, + handleOfId, identitiesOf, identityCount, + idOfHandle, platformId, publishedHandleOf, resolveHandle, @@ -93,6 +97,16 @@ 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 +// `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') @@ -130,12 +144,23 @@ 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. ``` +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 Hash locally, so the handle text never reaches an RPC: 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.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 new file mode 100644 index 00000000..c97dcf3e --- /dev/null +++ b/ts/packages/contracts/src/handle/index.ts @@ -0,0 +1,10 @@ +/// 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 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' 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' diff --git a/ts/packages/contracts/src/identity/resolve.test.ts b/ts/packages/contracts/src/identity/resolve.test.ts index b2695fb1..e98fe042 100644 --- a/ts/packages/contracts/src/identity/resolve.test.ts +++ b/ts/packages/contracts/src/identity/resolve.test.ts @@ -2,15 +2,25 @@ 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, + handleOfId, + idBindingOf, identitiesOf, identityCount, + idOfHandle, platformId, publishedHandleOf, type RegistryReader, @@ -76,6 +86,128 @@ 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 { 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 { reader, calls } = chain({ functionName: 'handleBindingOf', result: [ALICE, 100n] }) + expect(await handleBindingOf(reader, X, ' @Alice ')).toEqual({ + holder: ALICE, + observedAt: 100n, + }) + 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 { 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 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) + }) + + /// 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 { 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 { reader } = chain({ functionName: 'handleOfId', result: ['', false] }) + expect(await handleOfId(reader, X, 'nobody')).toBeNull() + }) + + 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 { reader } = chain({ functionName: 'idOfHandle', result: '' }) + expect(await idOfHandle(reader, X, 'nobody')).toBeNull() + }) +}) + 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..ac85a363 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,27 @@ export interface RegistryReader { address: Address } -/// One view call. +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 +/// 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, @@ -54,10 +70,15 @@ 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 { - const rules = await read(reader, 'rulesOf', [platformId]) + const rules = await read(reader, 'rulesOf', [platformId]) return { maxLength: Number(rules.maxLength), stripLeadingAt: rules.stripLeadingAt, @@ -73,8 +94,8 @@ export async function resolveId( platformId: `0x${string}`, id: string, ): Promise
{ - const holder = await read
(reader, 'resolveId', [platformId, id]) - return holder === zeroAddress ? null : holder + const holder = await read(reader, 'resolveId', [platformId, id]) + return orNull(holder) } /// The holder that last proved this handle, or `null`. @@ -93,8 +114,79 @@ export async function resolveHandle( platformId: `0x${string}`, handle: string, ): Promise
{ - const holder = await read
(reader, 'resolveHandle', [platformId, handle]) - return holder === zeroAddress ? null : holder + const holder = await read(reader, 'resolveHandle', [platformId, handle]) + return orNull(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(reader, 'idBindingOf', [platformId, id]) + return { holder: orNull(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(reader, 'handleBindingOf', [platformId, handle]) + return { holder: orNull(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`. 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 +} + +/// 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(reader, 'handleOfId', [platformId, id]) + return handle.length === 0 ? null : { handle, current } +} + +/// The id of the identity that holds a handle now, or `null` +/// (`IdentityRegistry.idOfHandle`). Normalized on chain; a string the rules +/// 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]) + return id.length === 0 ? null : id } /// The handle to show for a holder, or `null`. @@ -108,7 +200,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 } @@ -142,13 +234,9 @@ 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 } + return { holder: orNull(holder), idAgrees } } /// One identity a holder proved, as the holder's list reports it. @@ -169,7 +257,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: @@ -189,7 +277,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, 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'