EVM contracts that bind a GitHub repository to a wallet through a GitHub Actions OIDC proof, and let whoever holds that binding launch a token market for the repository and collect its trading fees.
A repository's key is a RIK, an ERC-721 whose token id is the repository's numeric GitHub id. Holding it conveys control of the repository's market and its royalties. Because the RIK is transferable, these rights can be transferred together as one asset.
| Contract | What it is |
|---|---|
src/RIK.sol |
Repository Identity Key. Verifies a GitHub Actions OIDC proof and allows a key for each repository id to be minted only once. Provides on-chain metadata. Its owner controls the attestation source, making the owner the system's most privileged role. |
src/RIKLauncher.sol |
Creates the one market a repository is allowed, on behalf of its key holder, through the Doppler Airlock. |
src/RIKRoyaltySplitter.sol |
Accrues that market's fees and pays them to whoever currently holds the key. |
src/GithubOidcVerifier.sol, src/IJwtVerifier.sol and src/JsonClaim.sol are verbatim copies of
the identity repository's deployed sources. This project does not deploy a
verifier; it points RIK at the live one, which is where GitHub's rotating signing keys are already
mirrored. They are vendored here so the test suite can run a verifier locally.
Open an issue here, titled with the repository and the wallet that should hold its key:
octocat/Hello-World 0x1111111111111111111111111111111111111111
Registration does not require a commit to the repository, a local installation, or ETH from the claimant. A workflow in this repository verifies the claimant's control of the repository, requests a signed proof from GitHub, and uses a relayer to submit the transaction. The result is posted as a comment on the issue.
Attestation happens in this repository, so the OIDC token's repository_id names this project and
actor_id names whoever opened the issue. Neither says anything about the repository being claimed.
Every claim in an Actions OIDC token is set by GitHub except one: aud is an arbitrary string
chosen by the workflow. It is therefore the only workflow-controlled value passed to the contract
and encodes the complete claim: "<wallet>:<repositoryId>:<ownerId>". The other four checks verify
the source of that value: repository_id and job_workflow_ref pin the attestation source,
event_name pins the issues trigger, and actor_id names the account being credited. Omitting any
of the five checks reintroduces an impersonation risk; each check has a corresponding negative test.
The contract cannot determine whether an account controls a repository. The workflow performs this
check in three tiers: repository ownership (public data, automatic), GitHub App confirmation of
admin access (one Metadata: read permission, and the only tier that supports private
repositories), or an administrator-provided one-time challenge topic (topics require admin
access and can be removed immediately after verification). ATTESTATION.md records
the rationale for these tiers, the alternatives considered, and the model's costs.
register is permissionless: the proof names its own beneficiary through aud, so a relayer can
pay the gas without being able to redirect the key.
| Unit, fuzz and adversarial tests | forge test |
| Stateful invariants | conservation, solvency, exclusivity, registration-is-once |
| Long soak | FOUNDRY_PROFILE=deep forge test |
| Linter | forge lint --deny warnings |
| Static analysis | slither ., at zero findings |
| Operator path | ./smoke-test.sh against a local anvil |
The splitter transfers accrued fees to recipients. Its central invariant is that outstanding buckets plus amounts already paid equal the total amount credited, under any interleaving of collections, transfers, payouts and owner sweeps. SECURITY.md records the threat model, what is assumed honest, and the rationale for each accepted static-analysis finding.
forge fmt --check
forge lint --deny warnings
forge build --sizes
forge test -vvv
forge test --gas-report
# Run extended fuzzing and invariant campaigns.
FOUNDRY_PROFILE=deep forge test
# Static analysis. Builds without test artifacts, so clean afterwards.
slither . && forge clean
# Operator path, against a local anvil started in another terminal.
anvil --silent
./smoke-test.shThe CLI is Ruby 3.2, standard library only, no bundler:
./bin/market doctor # tooling, chain, recorded deployment
./bin/market deploy --rpc-url … --verifier … --airlock … --splitter-owner …
./bin/market status # verify the deployment against its own wiring
./bin/market configure # push variables and secrets through gh
./bin/market rik of octocat/Hello-World # resolve a slug, then show its key
./bin/market rik show 1296269
./bin/market market show 1296269
./bin/market royalty show 1296269 <token>
./bin/market royalty collect <asset> # permissionless: push fees into a repository's bucket
./bin/market royalty claim 1296269 <token> # as the current key holderThe signing key is read from MARKET_PRIVATE_KEY or PRIVATE_KEY, or prompted for without echo. It
is never written to .market.yml.
MARKET_RPC_URL and MARKET_CHAIN_ID override the recorded endpoint and expected chain. They allow
testnet use without modifying the file that records the live deployment. --rpc-url and
--chain-id provide the same overrides for an individual command.
The chain id acts as a validation constraint rather than a configuration setting because the endpoint determines the chain. Declaring it causes commands to fail when the endpoint uses a different network. Deployment and other state-changing commands perform this validation before broadcasting; read-only commands do not.
export MARKET_RPC_URL=https://sepolia.base.org
export MARKET_CHAIN_ID=84532
./bin/market doctor # names the network: "Base Sepolia (84532)"
./bin/market deploy --verifier … --airlock … --rik-owner … --splitter-owner …Base Sepolia needs its own verifier. market never deploys one, and the identity instance it
normally points at is on Base Mainnet, so a testnet rehearsal has to stand one up first and mirror
GitHub's signing keys into it:
forge create --rpc-url "$MARKET_RPC_URL" --private-key "$PRIVATE_KEY" --broadcast \
src/GithubOidcVerifier.sol:GithubOidcVerifier --constructor-args <owner>
# then sync GitHub's JWKS into it — see the identity repository's sync-github-keys.shmarket deploy checks that the verifier address contains code and rejects the deployment if it does
not.
RIKLauncher.launch forwards CreateParams to the Doppler Airlock unchanged except for
integrator, which it overwrites with the splitter. The caller supplies everything else, and two
fields decide whether the repository will ever be paid.
poolInitializerData is an ABI-encoded InitData for the chosen initializer:
struct Curve { int24 tickLower; int24 tickUpper; uint16 numPositions; uint256 shares; }
struct BeneficiaryData { address beneficiary; uint96 shares; }
struct InitData {
uint24 fee;
int24 tickSpacing;
int24 farTick;
Curve[] curves;
BeneficiaryData[] beneficiaries;
address dopplerHook;
bytes onInitializationDopplerHookCalldata;
bytes graduationDopplerHookCalldata;
}The beneficiaries field determines fee allocation. Doppler fixes it when the pool is created and
does not provide a way to add an entry afterwards, so a market launched without the splitter in the
list would never accrue fees for the repository. RIKLauncher therefore reads
getShares(poolId, splitter) after creation and reverts with SplitterNotBeneficiary if it is zero.
Doppler's own rules, enforced by storeBeneficiaries:
- addresses strictly ascending and unique, each with
shares > 0 - shares denominated in WAD, summing to exactly
1e18 airlock.owner()must be included with at least1e18 / 20(5%)- an empty array stores no beneficiaries at all, which this launcher rejects
A minimal list therefore has two entries—the Doppler protocol owner and the splitter—sorted by address:
address splitter = address(launcher.splitter());
address protocolOwner = Airlock(airlock).owner();
BeneficiaryData[] memory beneficiaries = new BeneficiaryData[](2);
// Sort ascending by address before encoding; the order is validated on-chain.
(beneficiaries[0], beneficiaries[1]) = protocolOwner < splitter
? (BeneficiaryData(protocolOwner, 0.05e18), BeneficiaryData(splitter, 0.95e18))
: (BeneficiaryData(splitter, 0.95e18), BeneficiaryData(protocolOwner, 0.05e18));
params.poolInitializer = 0xbdf938149aC6a781f94FAa0Ed45E6a0e984c6544; // DopplerHookInitializer
params.poolInitializerData = abi.encode(InitData({ ..., beneficiaries: beneficiaries, ... }));
params.numeraire = <an ERC20>; // not address(0)numeraire must be an ERC20. Fees are released as ERC20 transfers, and the splitter cannot hold
native value, so launch rejects address(0) with NativeNumeraireUnsupported.
For a V4 initializer, Airlock.create returns the asset address in its pool slot because
Uniswap V4 pools do not have individual addresses. The pool is identified by the PoolKey that the
initializer stores against the asset, which is what the splitter reads back when collecting.
The --airlock option specifies the Doppler Airlock address. This address is immutable in both the
launcher and the splitter, so an incorrect value cannot be corrected after deployment. An incorrect
address may not cause market launches to revert, but it prevents fees from reaching a location where
the splitter can collect them.
| Network | Airlock |
|---|---|
| Base Mainnet (8453) | 0x660eAaEdEBc968f8f3694354FA8EC0b4c5Ba8D12 |
| Base Sepolia (84532) | 0x3411306Ce66c9469BFF1535BA955503c4Bde1C6e |
The pool initializer is chosen per launch through CreateParams.poolInitializer, not configured
here. On Base Mainnet the two that expose the fee interface this project collects through are
DopplerHookInitializer at 0xbdf938149ac6a781f94faa0ed45e6a0e984c6544 and
RehypeDopplerHookInitializer at 0xbd54a9e1d2249185a27af097abaa930631ec45c5. A launch must
register the splitter as a fee beneficiary in poolInitializerData; RIKLauncher rejects one that
does not, because beneficiaries are fixed when the pool is created.
Source: Doppler's contract addresses. Verify the addresses before use because Doppler may redeploy these contracts and this table may become outdated:
cast code <airlock> --rpc-url "$MARKET_RPC_URL" # must not be 0x
cast call <airlock> "getIntegratorFees(address,address)(uint256)" \
0x0000000000000000000000000000000000000001 <token> --rpc-url "$MARKET_RPC_URL"The second call verifies the interface required by RIKRoyaltySplitter and detects an address that
contains contract code but exposes an incompatible ABI.
Read AGENTS.md before making changes. It records important design constraints, including why the event pin exists, why the key is transferable but can be registered only once, why the splitter never accepts a repository id from its caller, and which files are vendored and must not be edited here.