Skip to content

feat(sol): LibID, a Solidity library for using libID from contracts - #71

Open
SupremaLex wants to merge 22 commits into
mainfrom
feat/solidity-lib
Open

SupremaLex wants to merge 22 commits into
mainfrom
feat/solidity-lib

Conversation

@SupremaLex

@SupremaLex SupremaLex commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

LibID, a Solidity library for using libID from contracts. It lives in sol/ so the contracts repo does not have to know its own deployed addresses.

import {LibID} from "libid/LibID.sol";

LibID.requireHolder(LibID.GITHUB, handle, 2 hours);
address holder = LibID.resolve(LibID.GITHUB, "octocat");
bytes32 node = LibID.pay(LibID.GITHUB, handle, msg.value, msg.sender);

What it is

  • One file, sol/src/LibID.sol. It imports nothing, and every function is internal, so there is nothing to deploy or link. It works with solc 0.8.20 and later.
  • Built-in addresses, one library per environment. libID-contracts 0.17.0 gives each environment its own factory, so LibID has the production addresses (Ethereum mainnet: IdentityRegistry 0xbefD…9a83, HandleEscrow 0x17a2…bbf7). LibIDTestnet has the same functions with the testnet addresses (Sepolia and Eden: 0x25F2…D640, 0x5735…d8e5). It is generated from LibID.sol by sol/script/testnet.sh, so an integrator switches with import {LibIDTestnet as LibID} from "libid/LibIDTestnet.sol";. Within an environment the addresses never change; contract updates are upgrades at the same address.
  • Reads:
    • resolve, with or without a maximum proof age;
    • bindingOf, which returns the holder and observedAt;
    • resolveId and publishedHandleOf.
  • Gates: isHolder and requireHolder, which revert NotHolder(holder, caller) or ProofTooOld(observedAt).
  • Payments through HandleEscrow:
    • pay (ETH) and payToken (ERC-20) both return the escrow node, which comes from the registry's handleNodeOfHash; both revert LibIDUnavailable unless the escrow resolves through LibID's registry;
    • refund(handleNode, …) takes that node back.
  • Availability:
    • isAvailable() covers the registry, which is enough for reads and gates;
    • isEscrowAvailable() covers payments, and also checks that the escrow resolves through that registry.
    • Every other function reverts LibIDUnavailable on a chain without the contract it needs.

Behaviour to know

  • Proof age. A proof bound a moment ago can read 0 to 65 minutes old on GitHub and X, and 0 to 2 hours on Google (5 minutes and 1 hour when the notary's and chain's clocks agree). The verifiers date observedAt a fixed allowance before the evidence, and accept evidence that is already a while old. The NatSpec and README give these ranges.
  • Handles: case never matters. A leading @ is dropped on GitHub and X. Google handles keep dots and +tags, and a leading @ is invalid there.
  • Payments call out. When the handle is held, the holder's code receives the ETH. Callers must guard against reentrancy, and should avoid paying many handles in one call.
  • Approvals: payToken approves the amount first and resets to zero only if the token refuses. It reads only the first word of the answer.

Tests (sol/test/LibID.t.sol)

  • Real contracts: the libID-contracts v0.15.0 proxies are placed at the built-in addresses with vm.etch, and handles are bound through the real bind. Bindings are dated as the real verifiers date them, using the allowances from CeremonyProfile, at both ends of each acceptance window.
  • Addresses: a test recomputes both libraries' addresses from their environment's factory and the CREATE3 derivation of the canonical names.
  • 54 tests, including payments whose node comes from the registry's handleNodeOfHash, an escrow bound to another registry, token refunds (plain and both fee-on-transfer kinds), both ends of each verifier's age window, and the test base below.
  • LibIDTestBase (sol/src/test/LibIDTestBase.sol): a Foundry base for integrators testing their own contracts.
    • It places the real contracts at the built-in addresses, with a stand-in verifier this repo owns.
    • On a fork where libID is already deployed, it uses that deployment.
    • It works inside a caller's prank.
    • bindHandle dates proofs at the platform's real age (5 minutes for GitHub and X, 1 hour for Google) and advances the clock, so binds in one block do not collide.
    • The README gives context-specific remappings and compiler settings. sol/integrator/ is a project built from exactly those lines on solc 0.8.24, and CI builds and tests it.
  • Fork tests against live Ethereum mainnet (LibID), Sepolia and Eden (LibIDTestnet). Each reads, then pays a fresh handle in ETH and in a token and refunds it, and binds through LibIDTestBase on the live deployment, on the local fork only. They are skipped locally without ETH_RPC_URL, SEPOLIA_RPC_URL and EDEN_RPC_URL. In CI, LIBID_REQUIRE_FORK makes a missing RPC fail the step.
  • Dependencies: libID-contracts v0.15.0 and forge-std v1.17.0 are git submodules under sol/lib/, pinned in the committed foundry.lock. LibIDTestBase imports libID-contracts; LibID.sol imports nothing.

CI

A new sol job runs on pushes to main, nightly (to catch drift in the live deployments), and on pull requests that touch sol/, .gitmodules or the CI workflow:

  • forge fmt --check;
  • forge lint -D notes;
  • script/testnet.sh, failing if LibIDTestnet.sol changes;
  • a solc 0.8.20 legacy-pipeline build of LibID.sol and LibIDTestnet.sol;
  • forge test;
  • the mainnet, Sepolia and Eden fork tests;
  • the integrator project (script/integrator.sh).

Known limitations

  • Install size: forge install libid-org/libid pulls the whole repo and the test-only submodules for one file.
  • Registry calls: a held handle costs two calls, an unheld one three. libID-contracts popup: mobile-WebKit flake in close-right-after-navigate (CI only) #86 adds handleBindingOf, which makes it one, once deployed.
  • Local chains: a local deployment lands at other addresses; in tests, LibIDTestBase places the contracts at the built-in ones.
  • CI depends on public RPCs: the fork step calls publicnode for mainnet and Sepolia, and Eden's own RPC.

LibID has the canonical IdentityRegistry and HandleEscrow addresses built
in and wraps the calls a contract needs: resolve a handle or an id (with
an optional maximum proof age), read a published handle, check or
require that the caller holds a handle, and pay a handle in ETH or an
ERC-20 through the escrow. Every function is internal and the file
imports nothing. On a chain without libID, calls revert LibIDUnavailable.

The tests place the real libID-contracts v0.15.0 proxies at the built-in
addresses, check those addresses against the factory's CREATE3
derivation of their canonical names, and read the live Eden deployment
in a fork test. CI gets a Solidity job.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
isAvailable now needs only the IdentityRegistry, since reads and gates
work without the escrow; isEscrowAvailable covers payments. The token
notes say what the escrow supports: a fee taken from the amount received
works, while a sender surcharge, a rebasing token or one that can block
the escrow does not. refund lets a contract named as refundTo take its
deposits back. The library and README say that payments call out, so
callers must guard against reentrancy.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…yments

The verifiers date a proof a fixed allowance before its evidence (5
minutes on GitHub and X, two hours before a Google token's expiry), so
observedAt is never ahead of the block and a fresh proof already has an
age. The docs say so, and the tests bind with that timing, using the
allowances from CeremonyProfile, instead of a time the verifiers never
produce.

requireHolder no longer lets the zero address in for a handle nobody
holds. pay and payToken return the escrow node, and refund takes it, so
a refund still reaches the deposit after the platform's rules change.
payToken approves the amount first and resets to zero only if the token
refuses, so tokens that reject a zero approval work, and an approve that
answers anything but true is ApproveFailed. isEscrowAvailable also
requires the escrow to resolve through REGISTRY. The handle check costs
two registry calls instead of three, letting only UnusableHandle read as
unheld. CI compiles LibID.sol alone with solc 0.8.20 on the legacy
pipeline.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The verifiers accept a session up to an hour old and a Google token until
it expires, so a just-bound proof reads 5 to 65 minutes old on GitHub and
X and 1 to 2 hours on Google; the docs give that range, and the tests bind
at both ends of each window.

The age-checking reads again revert UnknownPlatform for a platform with
rules but no verifier, by calling resolveHandle whenever the answer is
unheld. bindingOf returns the holder and observedAt so callers can tell
unheld, stale and no-handle apart. The escrow node is computed locally
from its fixed formula. isEscrowAvailable compares the answer as a word,
so a dirty answer is false instead of a revert. approve accepts a first
word of 1, as SafeERC20 does, and copies only that word. The docs say that
Gmail dots and plus tags are kept and that publishedHandleOf does not
revert. CI lints with forge lint -D notes, and the Eden fork step fails
instead of skipping when its RPC is missing.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
@SupremaLex
SupremaLex marked this pull request as ready for review October 5, 2026 09:50
@SupremaLex
SupremaLex requested a review from xgreenx October 5, 2026 09:50
Main dropped the infrastructure smoke job; the workflow summary names
the Solidity checks without it.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://feat-solidity-lib.previews.lib.id, https://feat-solidity-lib-libid.grounded-systems.workers.dev (commit 6be51e6)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://19205aba.previews.lib.id, https://19205aba-libid.grounded-systems.workers.dev 6be51e6 2026-10-05T10:03:26.829Z Visit the dashboard ↗

libid-contracts 0.17.0 gives each environment its own factory, so the
canonical addresses differ between production and testnets. LibID now
carries Ethereum mainnet's, and LibIDTestnet, generated from it by
script/testnet.sh, carries Sepolia's and Eden's with the same
functions: an integrator switches by importing it as LibID. CI checks
the generated file, builds both with solc 0.8.20, checks both address
tables against their factories, and forks mainnet and Sepolia instead
of Eden.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
pay and payToken returned a node from LibID's own copy of the node
formula, while the escrow books the deposit at the registry's
handleNodeOfHash. Both now come from the registry.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…n failure

testnet.sh renamed only ILibIDRegistry and ILibIDEscrow, so a new
file-level declaration would clash when LibID and LibIDTestnet are
imported together. It now renames every file-level ILibID* name and
stops on any other file-level name or line it does not handle.

It also truncated src/LibIDTestnet.sol before perl ran. It now writes
to a temporary file and moves it into place once formatted.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
LibID's addresses are constants, so a local chain where libID is
deployed elsewhere cannot run a contract that uses it. LibIDTestBase
puts the real IdentityRegistry and HandleEscrow at LibID's or
LibIDTestnet's addresses and binds handles through a stand-in platform
verifier. LibID's own tests now set up through it.

It imports libID-contracts, so the README gives the remappings and the
IR setting it needs. Checked from a scratch Foundry project that
imports it through the documented remappings.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The README said a just-bound GitHub or X proof reads 5 to 65 minutes
old, and a test treated a 4-minute maxAge as always too short. The
verifier sets observedAt to createdAt less 300 s and accepts createdAt
up to 300 s ahead of the block, so the age can be 0. The same holds for
Google: exp may be up to 2 hours ahead, which also gives 0.

The README now gives 0 as the floor and the 5-minute and 1-hour ages
as what in-step clocks give. The tests bind at both ends of each
verifier's window, from CeremonyProfile's constants.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Covers payToken with refundTo set to the paying contract, then
LibID.refund: a plain token, a token that takes a fee on the way into
the escrow, and one that takes a fee on the way out.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…d name

The fork tests asserted that "nobody-has-this-handle-xyz" is unbound on
live chains, which anyone can change by binding that GitHub name. They
now read text the registry refuses for GitHub ("not a handle"), which
nobody can ever hold.

They also only read. Each fork now pays a GitHub handle that is new to
the run, in ETH and in a token, checks the escrow books it at the
registry's node, and refunds it. Mainnet runs through LibID, Sepolia
through LibIDTestnet. Everything happens on the local fork.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The README lists Eden testnet for LibIDTestnet, but no test ran there.
Eden now runs the same fork tests as Sepolia, and CI gives it a public
RPC under the same LIBID_REQUIRE_FORK rule.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The sol job ran on every pull request and depends on public RPCs. The
changes job now has a sol output, true for pushes to main and for pull
requests that touch sol/, .gitmodules or ci.yml, and the job skips
otherwise.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The libID contracts compile through IR without the optimizer, and a
profile-wide optimizer setting would reach the integrator's own
contracts. Checked from a scratch Foundry project.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
pay and payToken took the node from LibID's registry, while the escrow
books the deposit at the node its own registry gives. They now revert
LibIDUnavailable unless isEscrowAvailable, which checks the two are the
same. refund needs no registry and still works.

The NatSpec of pay and payToken now lists the escrow's reverts.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
Forge writes it to pin each dependency's revision, matching the
submodule commits.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
…wn verifier

From the second review of LibIDTestBase:

- bindHandle(holder, platformId, id, handle) dates the proof as one made
  now with in-step clocks would be: 5 minutes old on GitHub and X, an
  hour on Google, from CeremonyProfile's constants. It moves the clock
  a second first, so binds in one block are each newer, and past the
  proof's age when a test starts near zero.
- deployLibIDAt uses a libID deployment already at the addresses, as on
  a fork or after an earlier call, and reverts with a message for other
  code there. Stand-in verifiers go under version 65535, beside any
  live ones; rules are set only for a platform that has none.
- Every function stops the test's prank or startPrank and restores it
  afterwards, and refuses to run during a broadcast.
- LibIDStandInVerifier, kept here, replaces libID-contracts' test
  fixture StubPlatformVerifier.
- Private helpers carry a libid prefix, the proxy slot comes from
  ERC1967Utils, and LibIDTest uses the base's registry and escrow.

New tests cover deployLibID, deployLibIDTestnet, both bindHandle forms,
a second deploy, foreign code and pranks. The fork tests now bind a
handle through the base on each live deployment and pay its holder, and
read an unheld handle that the registry accepts, so _binding's success
path runs against live contracts.

The README's remappings now apply only under lib/libid/, and it says
that tests build the integrator's contracts a second time, through IR.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
integrator/ is a Foundry project that uses LibID and LibIDTestBase with
exactly the README's remappings and foundry.toml lines, on solc 0.8.24,
the oldest the base supports. It maps its own @OpenZeppelin elsewhere,
to show the context-specific remappings keep libID's apart.
script/integrator.sh runs it with this repository at lib/libid, and
the sol job runs that script.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The fork tests read and pay through the live deployments, which can
change without a commit here. A nightly schedule runs the sol job
alone: changes reports no workspaces and no image, and ts and dco skip.
Nightly runs have their own concurrency group, so one never cancels a
push's run of the same commit.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>
The Sepolia and Eden fork suites differ only in their RPC variable, so
an abstract TestnetForkTest carries the consumer and deploy entry point.
The fork suites check an unheld handle through one helper, and both
not-set-up tests run the same full list of reads and gates, which now
also covers bindingOf, requireHolder and publishedHandleOf on a platform
with no rules. Consumer implements IConsumer, so the mainnet suite needs
no cast, and the ETH-refusing holder is libID-contracts' RejectEther.

Assisted-by: Claude Opus 5.5
Signed-off-by: SupremaLex <georglutsenko@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant