Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
20e9956
feat(sol): add LibID, a Solidity library for using libID from contracts
SupremaLex Oct 1, 2026
16496f5
fix(sol): check the registry alone for reads, and add refund
SupremaLex Oct 1, 2026
2957201
fix(sol): count proof age as the verifiers date proofs, and harden pa…
SupremaLex Oct 1, 2026
7256b8d
docs(sol): say the built-in addresses never change
SupremaLex Oct 1, 2026
ea76258
fix(sol): give proof ages at bind time, and close the second review
SupremaLex Oct 1, 2026
6be51e6
chore: merge main into feat/solidity-lib
SupremaLex Oct 5, 2026
2ed7b36
feat(sol)!: production addresses in LibID, testnet ones in LibIDTestnet
SupremaLex Oct 5, 2026
cba335a
fix(sol): return the node the registry gives for a payment
SupremaLex Oct 5, 2026
f1271c6
fix(sol): rename every file-level ILibID name and keep the old file o…
SupremaLex Oct 5, 2026
4eebb7f
feat(sol): add LibIDTestBase for testing contracts that use LibID
SupremaLex Oct 5, 2026
1505708
fix(sol): a GitHub or X proof can read 0 minutes old when bound
SupremaLex Oct 5, 2026
1f03bbb
test(sol): refund tokens a contract escrowed with payToken
SupremaLex Oct 5, 2026
11b36a0
test(sol): pay and refund on the forks, and stop relying on an unboun…
SupremaLex Oct 5, 2026
7e1c342
test(sol): run LibIDTestnet against an Eden testnet fork
SupremaLex Oct 5, 2026
ce9ef01
ci: run the Solidity job only when a pull request can affect it
SupremaLex Oct 5, 2026
4a1d25f
docs(sol): drop the optimizer from LibIDTestBase's foundry.toml lines
SupremaLex Oct 5, 2026
a5a8f8b
fix(sol): pay only through an escrow bound to LibID's registry
SupremaLex Oct 5, 2026
35f872a
build(sol): commit foundry.lock
SupremaLex Oct 5, 2026
1de4dac
feat(sol): LibIDTestBase works on forks, inside pranks and with its o…
SupremaLex Oct 5, 2026
2bd3137
test(sol): build the README's integrator setup in CI
SupremaLex Oct 5, 2026
f92ef7f
ci: run the Solidity job nightly, for live drift
SupremaLex Oct 5, 2026
2655937
test(sol): share the fork suites' and not-set-up checks
SupremaLex Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 83 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: CI

# TypeScript checks, workspace browser tests, CCDP image and DCO.
# TypeScript and Solidity checks, workspace browser tests, CCDP image and DCO.
#
# Every third-party action is pinned by commit SHA, with the tag in a
# comment, so a moved tag cannot change what executes.
Expand All @@ -9,20 +9,26 @@ on:
pull_request:
push:
branches: [main]
# Nightly, for the Solidity fork tests alone: they catch a change on the
# live chains that no commit made.
schedule:
- cron: "23 4 * * *"

# A pull request's newer run cancels its older one. Each push to main has its own
# group, so every main commit completes its checks; ccdp-publish serializes the
# publications themselves.
# publications themselves. A nightly run has a group of its own, so it never
# cancels a push's run of the same commit.
concurrency:
group: ci-${{ github.head_ref || github.sha }}
group: ci-${{ github.event_name }}-${{ github.head_ref || github.sha }}
cancel-in-progress: true

jobs:
# ---------------------------------------------------------------------------
# Changes: which browser workspaces a pull request can affect, and whether it
# can affect the CCDP image, so their jobs skip otherwise. Pushes to main run
# everything: a release promotes each main commit's own image. Plain git over
# the PR's range, as in DCO.
# can affect the CCDP image or the Solidity library, so their jobs skip
# otherwise. Pushes to main run everything: a release promotes each main
# commit's own image. The nightly run is the Solidity job only. Plain git
# over the PR's range, as in DCO.
# ---------------------------------------------------------------------------
changes:
name: Changes
Expand All @@ -31,6 +37,7 @@ jobs:
outputs:
workspaces: ${{ steps.paths.outputs.workspaces }}
image: ${{ steps.paths.outputs.image }}
sol: ${{ steps.paths.outputs.sol }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -45,9 +52,12 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "schedule" ]; then
{ echo 'workspaces=[]'; echo "image=false"; echo "sol=true"; } >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$EVENT_NAME" != "pull_request" ]; then
echo 'workspaces=["popup","ceremony","dev"]' >> "$GITHUB_OUTPUT"
echo "image=true" >> "$GITHUB_OUTPUT"
{ echo 'workspaces=["popup","ceremony","dev"]'; echo "image=true"; echo "sol=true"; } >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(git diff --name-only "$BASE_SHA...$HEAD_SHA")
Expand All @@ -62,16 +72,20 @@ jobs:
if changed "$ceremony|^ts/apps/dev/"; then workspaces+=(dev); fi
image=false
if changed "$ceremony"; then image=true; fi
# The Solidity library builds on none of the TypeScript setup.
sol=false
if grep -qE '^(sol/|\.gitmodules$|\.github/workflows/ci\.yml$)' <<<"$files"; then sol=true; fi
json=$(jq -cn '$ARGS.positional' --args "${workspaces[@]}")
echo "Browser workspaces: $json; CCDP image: $image"
{ echo "workspaces=$json"; echo "image=$image"; } >> "$GITHUB_OUTPUT"
echo "Browser workspaces: $json; CCDP image: $image; Solidity: $sol"
{ echo "workspaces=$json"; echo "image=$image"; echo "sol=$sol"; } >> "$GITHUB_OUTPUT"

# ---------------------------------------------------------------------------
# TypeScript: build, tests, complete source/test/build-script type checking,
# lint and formatting.
# ---------------------------------------------------------------------------
ts:
name: TypeScript
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
Expand Down Expand Up @@ -107,6 +121,64 @@ jobs:
- name: Lint and formatting
run: pnpm -C ts lint && pnpm -C ts fmt:check

# ---------------------------------------------------------------------------
# Solidity: LibID, tested against the real libID contracts placed at their
# canonical addresses. The fork tests run against Ethereum mainnet, Sepolia
# and Eden testnet through public RPCs, on local forks.
# ---------------------------------------------------------------------------
sol:
name: Solidity
needs: [changes]
if: needs.changes.outputs.sol == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: sol
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive

- uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1
with:
version: v1.7.1

- name: LibIDTestnet is generated from LibID
run: ./script/testnet.sh && git diff --exit-code -- src/LibIDTestnet.sol

- name: Formatting
run: forge fmt --check

- name: Lint
run: forge lint -D notes

# The oldest compiler the README promises, on the legacy pipeline,
# building LibID.sol alone as an integrator's project would.
- name: LibID and LibIDTestnet compile with solc 0.8.20
env:
FOUNDRY_VIA_IR: "false"
FOUNDRY_OUT: ${{ runner.temp }}/libid-0.8.20/out
FOUNDRY_CACHE_PATH: ${{ runner.temp }}/libid-0.8.20/cache
run: forge build src/LibID.sol src/LibIDTestnet.sol --use 0.8.20 --evm-version paris

- name: Tests
run: forge test -vv

# LIBID_REQUIRE_FORK turns a missing RPC into a failure, not a skip.
- name: Mainnet, Sepolia and Eden fork tests
env:
ETH_RPC_URL: https://ethereum-rpc.publicnode.com
SEPOLIA_RPC_URL: https://ethereum-sepolia-rpc.publicnode.com
EDEN_RPC_URL: https://ev-reth-eden-testnet.binarybuilders.services:8545
LIBID_REQUIRE_FORK: "true"
run: forge test -vv --match-contract 'ForkTest$'

# The README's setup for LibIDTestBase, in a project of its own on the
# oldest compiler the base supports.
- name: Integrator project
run: ./script/integrator.sh

# ---------------------------------------------------------------------------
# Ceremony coverage badge: on pushes to main, publish the unit-test line
# coverage the TypeScript job measured as a shields.io endpoint on the orphan
Expand Down Expand Up @@ -193,6 +265,7 @@ jobs:
# ---------------------------------------------------------------------------
dco:
name: DCO
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Expand Down
6 changes: 6 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[submodule "sol/lib/forge-std"]
path = sol/lib/forge-std
url = https://github.com/foundry-rs/forge-std
[submodule "sol/lib/libID-contracts"]
path = sol/lib/libID-contracts
url = https://github.com/libid-org/libID-contracts
2 changes: 2 additions & 0 deletions sol/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
out/
cache/
221 changes: 221 additions & 0 deletions sol/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
# LibID

A Solidity library for using libID from your contracts: find who holds a
handle, let only its holder call a function, or send funds to a handle.

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

contract Guestbook {
function sign(string calldata handle, string calldata message) external {
LibID.requireHolder(LibID.GITHUB, handle, 90 days);
// ...
}

// Keeps no state, so the holder's code running inside `pay` cannot
// corrupt any. A function that does keep state needs a reentrancy guard.
function tip(string calldata handle) external payable {
LibID.pay(LibID.GITHUB, handle, msg.value, msg.sender);
}
}
```

The libID contracts have the same address on every chain of one environment,
so LibID has them built in. Within an environment these addresses never
change: contract updates are upgrades at the same address. Every function is
`internal`: it compiles into your contract, and there is nothing to deploy or
link. `LibID.sol` imports nothing.

## Testnets

`LibID` has the production addresses. `LibIDTestnet` has the same functions
with the testnet addresses. To build for a testnet, change the import and
nothing else:

```solidity
import {LibIDTestnet as LibID} from "libid/LibIDTestnet.sol";
```

| Library | Chains | IdentityRegistry | HandleEscrow |
|---|---|---|---|
| `LibID` | Ethereum mainnet | `0xbefD300aFf7D4A67fb381Afe8B3596793D3E9a83` | `0x17a244e23ef1f12071298A1862194FeA3D00bbf7` |
| `LibIDTestnet` | Sepolia, Eden testnet | `0x25F29C8C765db2f27d1e2b23987A7b0655C7D640` | `0x57355e1D1Bcf61FeC9b2E5CaD60DccCDdDC4d8e5` |

A local chain has no contract at these addresses. Use `LibIDTestBase` in
your tests, as below.

## Install

With [Foundry](https://getfoundry.sh):

```sh
forge install libid-org/libid
echo 'libid/=lib/libid/sol/src/' >> remappings.txt
```

It needs Solidity 0.8.20 or later.

## Testing your contract

`LibIDTestBase` is a base for Foundry tests. It puts the real IdentityRegistry
and HandleEscrow at the addresses LibID has built in, and binds handles
without a ceremony:

```solidity
import {LibID} from "libid/LibID.sol";
import {LibIDTestBase} from "libid/test/LibIDTestBase.sol";

contract GuestbookTest is LibIDTestBase {
function setUp() public {
deployLibID(); // or deployLibIDTestnet() for LibIDTestnet
}

function test_theHolderSigns() public {
address alice = makeAddr("alice");
bindHandle(alice, LibID.GITHUB, "1001", "octocat");
// ...
}
}
```

- `bindHandle(holder, platformId, id, handle)` binds and publishes the handle
as a proof made now would: it reads 5 minutes old on GitHub and X and an
hour old on Google, as with the notary's or Google's clock on the block's.
It moves the clock forward a second first, so binds in one block do not
refuse each other as stale.
- `bindHandle(holder, platformId, id, handle, observedAt, publish)` takes the
proof time, to test proof ages. It does not move the clock.
- Where libID is already deployed, as on a fork, `deployLibID` uses that
deployment and adds the stand-in verifiers to it. Code there that is not
libID makes it revert.
- The functions work inside your `vm.prank` or `vm.startPrank`, and leave it
in place.
- The platform verifiers are stand-ins, `LibIDStandInVerifier`, that check
nothing. Everything else is the real code.

Unlike `LibID.sol`, the base imports the libID contracts. They come from
the `lib/libID-contracts` submodule of this repository and its own
submodules; if `lib/libid/sol/lib/libID-contracts` is empty, run
`git submodule update --init --recursive` in `lib/libid`. The base needs
Solidity 0.8.24 or later and these lines in `remappings.txt`. They apply only
to files under `lib/libid/`, so your own OpenZeppelin is untouched:

```text
lib/libid/:libid-contracts/=lib/libid/sol/lib/libID-contracts/solidity/contracts/
lib/libid/:@openzeppelin/contracts/=lib/libid/sol/lib/libID-contracts/solidity/lib/openzeppelin-contracts/contracts/
lib/libid/:@openzeppelin/contracts-upgradeable/=lib/libid/sol/lib/libID-contracts/solidity/lib/openzeppelin-contracts-upgradeable/contracts/
```

The libID contracts compile only through IR. Add to `foundry.toml`:

```toml
additional_compiler_profiles = [{ name = "libid", via_ir = true }]
compilation_restrictions = [{ paths = "lib/libid/sol/lib/libID-contracts/**", via_ir = true }]
```

A test that imports the base then builds through IR, with every contract it
imports. Your contracts get two builds: the one you deploy, on your own
settings (`out/Guestbook.sol/Guestbook.json`), and the IR one your tests run
(`Guestbook.libid.json`). To test the build you deploy, set `via_ir = true`
for the whole project instead.

`integrator/` in this directory is such a project; `./script/integrator.sh`
builds and tests it.

## Functions

| Function | Returns |
| --- | --- |
| `resolve(platformId, handle)` | the holder of a handle, or zero |
| `resolve(platformId, handle, maxAge)` | the same, or zero if the latest proof is older than `maxAge` seconds |
| `bindingOf(platformId, handle)` | the holder and the `observedAt` of the latest proof, to apply your own rules |
| `resolveId(platformId, id)` | the holder of the identity with that id, or zero |
| `publishedHandleOf(holder, platformId)` | the handle the holder publishes, or `""` |
| `isHolder(account, platformId, handle, maxAge)` | whether `account` holds the handle with a fresh enough proof |
| `requireHolder(platformId, handle, maxAge)` | reverts `NotHolder` or `ProofTooOld` unless `msg.sender` holds it |
| `pay(platformId, handle, amount, refundTo)` | sends ETH from your contract to the handle; returns the escrow node |
| `payToken(platformId, handle, token, amount, refundTo)` | the same for an ERC-20 your contract holds |
| `refund(handleNode, token, recipient)` | takes back what your contract escrowed at that node, before it is claimed |
| `isAvailable()` | whether this chain has the IdentityRegistry, for reads and gates |
| `isEscrowAvailable()` | whether it also has the HandleEscrow, bound to that registry, for payments |

Platform ids are `LibID.GITHUB`, `LibID.X` and `LibID.GOOGLE`. Case never
matters in a handle. On GitHub and X a leading at-sign is dropped. A Google
handle is the email address exactly as Google reports it: it must not start
with an at-sign, and dots and plus tags are kept, so a dotted Gmail address
and the same address without the dots are different handles, even though
Gmail delivers both to one inbox.

Where a contract is not deployed, the functions that need it revert
`LibIDUnavailable`. A platform the chain has not set up makes the reads and
gates revert `UnknownPlatform`; `publishedHandleOf` returns `""` instead.

`resolve` with a `maxAge` returns zero for a handle nobody holds, for a proof
that is too old, and for text that is no handle. Use `bindingOf` to tell
them apart.

## Proof age

`maxAge` is counted from the proof's `observedAt`, which the platform
verifiers set a fixed allowance before the evidence: 5 minutes before the
notary signed the session on GitHub and X, and 2 hours before the expiry of
Google's sign-in token. The verifiers accept evidence that is already a while
old, and evidence dated up to the allowance ahead of the block, so a proof
that was bound a moment ago can read as:

| Platform | Age of a just-bound proof | With clocks in step |
| --- | --- | --- |
| GitHub, X | 0 to 65 minutes (the session can be up to an hour old, or signed up to 5 minutes ahead of the block) | 5 minutes |
| Google | 0 to 2 hours (the token is accepted until it expires, if that is at most 2 hours ahead of the block) | 1 hour |

The last column is a fresh session, or a token Google just issued, with the
notary's or Google's clock on the block's. A `maxAge` below the top of the
range rejects some honest holders. A
`maxAge` of `type(uint256).max` accepts any age.

## Payments

`pay` and `payToken` pay the holder at once if the handle has one. Otherwise
the escrow holds the funds until someone proves the handle and claims them,
and `refundTo` can take them back until then.

- Set `refundTo` to your user, so they can call `HandleEscrow.refund`
themselves. If you set it to your contract, keep the node `pay` returns and
offer a way to call `LibID.refund` with it. Do not recompute the node from
the handle later: a change to the platform's rules can make that text reach
a different node, or none.
- Payments call out. When the handle has a holder, the escrow sends ETH
straight to it with all remaining gas: the holder's code runs, and can
revert the payment or burn the gas. A token runs its own code too. Guard the
calling function against reentrancy, update your state before you pay, and
do not pay many handles in one call where one bad holder can block the rest.
- The escrow books what arrives, so a token that takes a fee from the amount
received works. A token that charges the sender on top of the amount, a
rebasing token, and a token that can block the escrow do not.

## Development

```sh
git submodule update --init --recursive
cd sol
forge test
ETH_RPC_URL=https://ethereum-rpc.publicnode.com \
SEPOLIA_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com \
EDEN_RPC_URL=https://ev-reth-eden-testnet.binarybuilders.services:8545 \
forge test --match-contract 'ForkTest$'
```

`src/LibIDTestnet.sol` is generated: edit `src/LibID.sol`, then run
`./script/testnet.sh`. CI fails when the two differ in anything but the names
and addresses.

The tests use `LibIDTestBase`: they deploy the real libID contracts, pinned
in `lib/libID-contracts`, at the addresses LibID has built in, and bind handles with the dates the real
verifiers give, at both ends of their acceptance windows. One test checks
both libraries' addresses against their environment's factory and the CREATE3
derivation of the canonical names. The fork tests run `LibID` against
Ethereum mainnet and `LibIDTestnet` against Sepolia and Eden testnet, on
local forks: they read, and pay a handle nobody holds and refund it. Each is
skipped without its RPC URL (`ETH_RPC_URL`, `SEPOLIA_RPC_URL`,
`EDEN_RPC_URL`), unless `LIBID_REQUIRE_FORK=true`, which CI sets: then a
missing URL fails.
Loading
Loading