Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0ff9988
feat(dashboard): verify a bond owner with ZKPassport
hmzakhalid Oct 28, 2026
13aa714
fix(dashboard): keep the proof and check it as the contracts do
hmzakhalid Oct 28, 2026
c608feb
Merge branch 'feat/identity-zkpassport-verifier' into feat/identity-web
hmzakhalid Oct 30, 2026
38df12b
docs(dashboard): explain the RPC history the identity panel needs
hmzakhalid Oct 30, 2026
cafecdf
fix(dashboard): read only recent blocks before the identity upgrade
hmzakhalid Oct 31, 2026
f52ab84
fix(dashboard): drop stale identity reads and check block overrides
hmzakhalid Oct 31, 2026
a82304a
Merge branch 'feat/identity-zkpassport-verifier' into feat/identity-web
hmzakhalid Nov 2, 2026
dd7c859
Merge branch 'feat/identity-zkpassport-verifier' into feat/identity-web
hmzakhalid Nov 2, 2026
3395c47
Merge branch 'feat/identity-zkpassport-verifier' into feat/identity-web
hmzakhalid Nov 2, 2026
21d7fff
feat(dashboard): register without policy log history
hmzakhalid Nov 2, 2026
3d29683
feat(dashboard): count a newly verified owner in the current window
hmzakhalid Nov 2, 2026
f083fce
test(dashboard): cover the registry fallback when the policy log read…
hmzakhalid Nov 2, 2026
9e018ec
Merge branch 'feat/identity-node-filter' into feat/identity-web
hmzakhalid Nov 2, 2026
34062ee
Merge branch 'feat/identity-node-filter' into feat/identity-web
hmzakhalid Nov 3, 2026
fa8d246
fix(dashboard): read the identity policy from storage
hmzakhalid Nov 3, 2026
228bd40
fix(dashboard): check the Safe call, the proof binding, and the verifier
hmzakhalid Nov 3, 2026
d632f4c
Merge branch 'feat/identity-node-filter' into feat/identity-web
hmzakhalid Nov 3, 2026
867da72
docs(dashboard): say where the panel reads the identity policy
hmzakhalid Nov 3, 2026
7947df1
Merge branch 'feat/identity-node-filter' into feat/identity-web
hmzakhalid Nov 3, 2026
968b0a5
Merge branch 'feat/identity-node-filter' into feat/identity-web
hmzakhalid Nov 3, 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
26 changes: 26 additions & 0 deletions packages/interfold-dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ rewards** sends `claimRewards(e3Ids)` for the completed E3s of the connected rec
`claimHonestNodeReward` for each failed E3. Each transaction waits for the previous one. E3s that an
earlier refund manager recorded are not listed. The amounts use the `FEE_TOKEN_*` display settings.

**Bond-owner identity** (`src/lib/identity.ts`) shows when the operator has a bond owner. Governance
can require each bond owner to prove one identity document with ZKPassport (`OwnerIdentityRegistry`
and `ZKPassportIdentityVerifier`). The page:

1. Reads the policy from the bonding registry's storage (it has no view). Then it reads the
verifier's fixed settings (domain, scope, ZKPassport version, identifier type, FaceMatch, proof
validity, maximum validity) and the owner's credential (`identityOf`, `isVerifiedAt`).
2. Creates a ZKPassport request with `@zkpassport/sdk` in `compressed-evm` mode. The document must
be valid until a chosen date, and the proof binds the bond owner and the chain. The SDK and the
QR code load only when the owner asks for a proof.
3. Builds the evidence, `abi.encode(ProofVerificationParams, validUntil)`. `validUntil` is the
expiry bound that the proof committed to, not the typed date.
4. Registers it. An owner wallet sends `register(evidence)`. A Safe downloads a Safe Transaction
Builder batch and executes it before the proof's validity ends.

The page warns when the proof's ZKPassport version differs from the verifier's, when the proof is
too old, when a renewal would shorten the credential, and when the credential ends inside the
current 30-day capacity window. Before a deployment (no policy yet), it makes a proof with the
planned settings (`PREVIEW_SERVICE`) and offers only the download. The download holds the proofs,
the verifier parameters, and the evidence, so a fork test can replay it.

The policy has no view, so the panel reads it from the bonding registry's ERC-7201 storage
(`IDENTITY_STORAGE_SLOT` in `src/lib/identity.ts`). These storage reads need no log history, so any
RPC serves them, and the page needs no setting for the identity upgrade. A contracts spec pins the
slots.

Only `VITE_BONDING_REGISTRY_ADDRESS` is configured. The ciphernode bond token, ticket wrapper,
ticket underlying, bond size, ticket price, minimum tickets, and exit delay are all read back from
the registry, so the guide follows the deployment instead of a hardcoded token list.
Expand Down
5 changes: 4 additions & 1 deletion packages/interfold-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "tsx --test src/Inspector.test.tsx src/Operator.test.tsx",
"test": "tsx --test src/Inspector.test.tsx src/Operator.test.tsx src/lib/identity.test.ts src/lib/networks.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@interfold/contracts": "workspace:*",
"@zkpassport/sdk": "0.19.2",
"qrcode": "1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"viem": "^2.21.0"
},
"devDependencies": {
"@types/qrcode": "1.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
Expand Down
244 changes: 242 additions & 2 deletions packages/interfold-dashboard/src/Operator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import type { Address } from 'viem'
import { CommitteeRewardsView, ManagePosition, positionActions, ticketBurnAmount } from './Operator'
import { CONTRACTS, E3Stage, FEE_TOKEN } from './lib/chain'
import { CommitteeRewardsView, IdentityView, ManagePosition, positionActions, ticketBurnAmount, type IdentityService } from './Operator'
import { CHAIN, CONTRACTS, E3Stage, FEE_TOKEN } from './lib/chain'
import type { BondingConfig, OperatorStatus } from './lib/bonding'
import { claimPlan, summarizeRewards, type CommitteeSeat, type SeatStatus } from './lib/rewards'
import {
boundChainName,
buildProofBundle,
dateInput,
identityStatusText,
parseDateInput,
type OwnerAccount,
type VerifierConfig,
} from './lib/identity'
import { pad, toHex, type Hex } from 'viem'

const OPERATOR = '0x1111111111111111111111111111111111111111' as Address
const OWNER = '0x2222222222222222222222222222222222222222' as Address
Expand Down Expand Up @@ -168,3 +178,233 @@ test('the claim covers the claimable seats of the connected recipient, and the s
assert.match(renderToStaticMarkup(<CommitteeRewardsView {...props} statuses={null} wallet={OWNER} />), /Reading rewards/)
assert.match(renderToStaticMarkup(<CommitteeRewardsView {...props} seats={[]} wallet={OWNER} />), /No committee has selected/)
})

// ─── Bond-owner identity ───────────────────────────────────────────────────

const DAY = 86_400n
const NOW = 1_790_000_000n
const word = (value: bigint): Hex => pad(toHex(value), { size: 32 })
const VERSION = word(0x14n << 232n)

const service: IdentityService = {
domain: 'dashboard.theinterfold.com',
scope: 'interfold-bond-owner',
faceMatch: true,
nullifierType: 0,
proofValidity: 7n * DAY,
maxValidity: 365n * DAY,
}

const verifier: VerifierConfig = {
address: OTHER,
domain: service.domain,
scope: service.scope,
version: VERSION,
nullifierType: 0,
oprfKeyHash: word(0n),
faceMatchRequired: true,
proofValidity: service.proofValidity,
maxValidity: service.maxValidity,
}

const policy = { registry: PROPOSED, required: true, timepoint: NOW - 10n * DAY }

function account(overrides: Partial<OwnerAccount> = {}): OwnerAccount {
return { now: NOW, isContract: false, identity: { identityId: word(0n), expiresAt: 0n, verifiedNow: false }, ...overrides }
}

function bundle(version: Hex = VERSION, boundTo: Address = OWNER) {
return buildProofBundle({
success: {
proofs: [
{
name: 'compare_expiry_evm',
committedInputs: { compare_expiry_evm: { minDateTimestamp: Number(NOW + 400n * DAY), maxDateTimestamp: 0 } },
},
{
name: 'outer_evm_count_5',
proof: '0x02',
committedInputs: { bind_evm: { data: { user_address: boundTo, chain: boundChainName(CHAIN.id) } } },
},
],
result: {},
},
params: {
version,
proofVerificationData: {
vkeyHash: word(7n),
proof: '0x1234',
publicInputs: [word(1n), word(2n), word(NOW), word(0n), word(9n), word(0n)],
},
committedInputs: '0xbeef',
serviceConfig: { validityPeriodInSeconds: 604_800, domain: service.domain, scope: service.scope, devMode: false },
},
owner: OWNER,
chainId: 11_155_111,
faceMatch: true,
})
}

const identityProps = {
owner: OWNER,
policy,
verifier,
account: account(),
loadFailed: false,
service,
validUntilInput: '2027-12-01',
onValidUntil: () => {},
previewFaceMatch: true,
onPreviewFaceMatch: () => {},
request: { phase: 'idle' } as const,
canRegister: true,
busy: null,
errFor: () => null,
onReload: () => {},
onCreate: () => {},
onCancel: () => {},
onRegister: () => {},
onDownloadProof: () => {},
onDownloadSafe: () => {},
}

test('date fields round-trip in UTC and reject impossible dates', () => {
assert.equal(parseDateInput('2027-12-01'), BigInt(Date.UTC(2027, 11, 1) / 1000))
assert.equal(dateInput(parseDateInput('2027-12-01')!), '2027-12-01')
assert.equal(parseDateInput('2026-02-31'), null)
assert.equal(parseDateInput('01/12/2027'), null)
assert.equal(parseDateInput('0099-01-01'), BigInt(Date.parse('0099-01-01T00:00:00Z') / 1000))
})

test('the identity panel shows the credential and offers a request', () => {
const unverified = renderToStaticMarkup(<IdentityView {...identityProps} />)
assert.match(unverified, /Yes, since/)
assert.match(unverified, /Not verified/)
assert.match(unverified, /Verify the owner/)
assert.match(unverified, />Create ZKPassport request</)
assert.match(unverified, /face scan/)

const verified = account({ identity: { identityId: word(1n), expiresAt: NOW + 200n * DAY, verifiedNow: true } })
assert.equal(
identityStatusText(verified),
`Verified until ${new Date(Number(NOW + 200n * DAY) * 1000).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })}`,
)
const renew = renderToStaticMarkup(<IdentityView {...identityProps} account={verified} validUntilInput={dateInput(NOW + 100n * DAY)} />)
assert.match(renew, /Renew the credential/)
assert.match(renew, /A renewal cannot shorten the credential/)

// A credential that ends inside the current window does not count toward request capacity.
const short = account({ identity: { identityId: word(1n), expiresAt: NOW + 1n, verifiedNow: true } })
assert.match(renderToStaticMarkup(<IdentityView {...identityProps} account={short} />), /does not count toward request capacity/)
assert.match(
renderToStaticMarkup(<IdentityView {...identityProps} validUntilInput={dateInput(NOW + 1n)} />),
/would not count toward request/,
)
})

test('before a deployment the panel makes a proof to download', () => {
const preview = renderToStaticMarkup(
<IdentityView {...identityProps} policy={null} verifier={null} account={account({ identity: null })} />,
)
assert.match(preview, /No identity registry on/)
assert.match(preview, /planned settings/)
assert.match(preview, /Require FaceMatch/)

const ready = renderToStaticMarkup(
<IdentityView
{...identityProps}
policy={null}
verifier={null}
account={account({ identity: null })}
request={{ phase: 'ready', bundle: bundle() }}
/>,
)
assert.match(ready, /Proof ready/)
assert.match(ready, />Download the proof \(JSON\)</)
assert.doesNotMatch(ready, /Register the identity/)
})

test('a proof registers from the owner, or as a Safe transaction', () => {
const ready = { phase: 'ready', bundle: bundle() } as const
const eoa = renderToStaticMarkup(<IdentityView {...identityProps} request={ready} />)
assert.match(eoa, /<button class="btn btn--primary">Register the identity/)
assert.match(eoa, /Refresh the status/)

const otherWallet = renderToStaticMarkup(<IdentityView {...identityProps} request={ready} canRegister={false} />)
assert.match(otherWallet, /<button class="btn btn--primary" disabled="">Register the identity/)
assert.match(otherWallet, /Connect the bond-owner wallet/)

const safe = renderToStaticMarkup(<IdentityView {...identityProps} account={account({ isContract: true })} request={ready} />)
assert.match(safe, />Download the Safe transaction</)
assert.doesNotMatch(safe, /Register the identity/)
assert.match(safe, /Safe Transaction Builder/)

// A registered operator lets the owner count now: a button for a wallet, the batch for a Safe.
const verified = account({ identity: { identityId: word(1n), expiresAt: NOW + 200n * DAY, verifiedNow: true } })
const countNow = renderToStaticMarkup(<IdentityView {...identityProps} account={verified} request={ready} onRefresh={() => {}} />)
assert.match(countNow, />Count the owner now</)
assert.doesNotMatch(
renderToStaticMarkup(<IdentityView {...identityProps} request={ready} onRefresh={() => {}} />),
/Count the owner now<\/button>/,
)
const safeBatch = renderToStaticMarkup(
<IdentityView {...identityProps} account={account({ isContract: true })} request={ready} refreshInBatch />,
)
assert.match(safeBatch, /The batch also refreshes the operator/)

// The verifier accepts one ZKPassport version.
const otherVersion = { phase: 'ready', bundle: bundle(word(0x15n << 232n)) } as const
const mismatch = renderToStaticMarkup(<IdentityView {...identityProps} request={otherVersion} />)
assert.match(mismatch, /another ZKPassport version/)
assert.match(mismatch, /<button class="btn btn--primary" disabled="">Register the identity/)

// A proof older than the verifier's validity window cannot register.
const late = renderToStaticMarkup(<IdentityView {...identityProps} account={account({ now: NOW + 8n * DAY })} request={ready} />)
assert.match(late, /too old to register/)
})

test('a registry without a verifier shows the credential but takes no registration', () => {
const closed = renderToStaticMarkup(<IdentityView {...identityProps} verifier={null} registrationClosed />)
assert.match(closed, /cannot register with the identity registry/)
assert.match(closed, /Not verified/)
assert.doesNotMatch(closed, /Create ZKPassport request/)
})

test('capacity warnings apply only while the policy requires identity', () => {
const optional = { ...policy, required: false }
const short = account({ identity: { identityId: word(1n), expiresAt: NOW + 1n, verifiedNow: true } })
assert.doesNotMatch(renderToStaticMarkup(<IdentityView {...identityProps} policy={optional} account={short} />), /does not count/)
assert.doesNotMatch(
renderToStaticMarkup(<IdentityView {...identityProps} policy={optional} validUntilInput={dateInput(NOW + 1n)} />),
/would not count/,
)
// The verifier's cap counts too: a far date still ends at the maximum validity.
const capped = renderToStaticMarkup(
<IdentityView {...identityProps} service={{ ...service, maxValidity: 1n }} validUntilInput={dateInput(NOW + 900n * DAY)} />,
)
assert.match(capped, /would not count toward request/)
})

test('a proof that the verifier would reject cannot register, and an equal expiry can', () => {
const ready = (b: ReturnType<typeof bundle>) => ({ phase: 'ready', bundle: b }) as const
const register = /<button class="btn btn--primary">Register the identity/
const disabled = /<button class="btn btn--primary" disabled="">Register the identity/

// A replacement that ends exactly at the current expiry is allowed by the registry.
const proof = bundle()
const ends = BigInt(proof.proofDate) + service.maxValidity
const same = account({ identity: { identityId: word(1n), expiresAt: ends, verifiedNow: true } })
assert.match(renderToStaticMarkup(<IdentityView {...identityProps} account={same} request={ready(proof)} />), register)

// The proof itself binds another owner.
const otherOwner = bundle(VERSION, PROPOSED)
const moved = renderToStaticMarkup(<IdentityView {...identityProps} request={ready(otherOwner)} />)
assert.match(moved, /bound to another owner or network/)
assert.match(moved, disabled)

const salted = renderToStaticMarkup(
<IdentityView {...identityProps} verifier={{ ...verifier, nullifierType: 1 }} request={ready(bundle())} />,
)
assert.match(salted, /identifier type or OPRF key differs/)
assert.match(salted, disabled)
})
Loading
Loading