diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2854a01..acf3473 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'pnpm' - name: Install dependencies diff --git a/.gitignore b/.gitignore index fa73992..b0aea58 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ managed/ tsconfig.tsbuildinfo logs/ +*.sqlite **/midnight-level-db/ **/*-midnight/ @@ -18,4 +19,4 @@ logs/ .eslintcache # Prettier -.prettiercache \ No newline at end of file +.prettiercache diff --git a/VERSIONS.md b/VERSIONS.md index 1bae143..f0f2ab2 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -1,29 +1,37 @@ # Component Versions -2026-04-24 +2026-07-23 ## Versions in Use -- Compact compiler: 0.30.0 -- Indexer: 4.0.0 -- Proof server: 8.0.2 -- Midnight node: 0.22.0 +- Compact devtools: 0.5.1 +- Compact compiler: 0.31.1 +- Compact language: 0.23.0 +- Compact runtime: 0.16.0 +- Compact JS: 2.5.1 +- Midnight.js: 4.1.1 +- On-chain runtime: 3.0.0 +- Indexer (standalone devnet): 4.2.1 +- Proof server: 8.1.0 +- Midnight node (local devnet): 0.22.5 - Node.js: 24.13.1 ## Project dependencies ````json "dependencies": { - "@midnight-ntwrk/compact-js": "2.5.0", - "@midnight-ntwrk/compact-runtime": "0.15.0", + "@midnight-ntwrk/compact-js": "2.5.1", + "@midnight-ntwrk/compact-runtime": "0.16.0", "@midnight-ntwrk/ledger-v8": "8.0.3", - "@midnight-ntwrk/midnight-js-contracts": "4.0.4", - "@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.0.4", - "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4", - "@midnight-ntwrk/midnight-js-level-private-state-provider": "4.0.4", - "@midnight-ntwrk/midnight-js-network-id": "4.0.4", - "@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.0.4", - "@midnight-ntwrk/midnight-js-types": "4.0.4", + "@midnight-ntwrk/midnight-js-contracts": "4.1.1", + "@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "4.1.1", + "@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.1.1", + "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1", + "@midnight-ntwrk/midnight-js-level-private-state-provider": "4.1.1", + "@midnight-ntwrk/midnight-js-network-id": "4.1.1", + "@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.1.1", + "@midnight-ntwrk/midnight-js-types": "4.1.1", + "@midnight-ntwrk/onchain-runtime-v3": "3.0.0", "@midnight-ntwrk/wallet-sdk-address-format": "3.1.0-rc.0", "@midnight-ntwrk/wallet-sdk-capabilities": "3.2.0-rc.0", "@midnight-ntwrk/wallet-sdk-abstractions": "2.0.0", diff --git a/apps/cli/package.json b/apps/cli/package.json index 1cbbea6..41de09b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,9 +15,9 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@midnight-ntwrk/compact-runtime": "0.15.0", + "@midnight-ntwrk/compact-runtime": "0.16.0", "@midnight-ntwrk/ledger-v8": "8.0.3", - "@midnight-ntwrk/midnight-js-contracts": "4.0.4", + "@midnight-ntwrk/midnight-js-contracts": "4.1.1", "@midnight-ntwrk/wallet-sdk-address-format": "3.1.0-rc.0", "@midnight-ntwrk/zswap": "4.0.0", "@midnight-sentinel/api": "workspace:*", diff --git a/apps/cli/src/cli/index.ts b/apps/cli/src/cli/index.ts index 9e4712d..6df634d 100644 --- a/apps/cli/src/cli/index.ts +++ b/apps/cli/src/cli/index.ts @@ -1,7 +1,20 @@ import { SentinelContract } from '@midnight-sentinel/api'; +import { sponsorshipAllowlistHash } from '@midnight-sentinel/api/sponsorship'; +import { + createHttpMidnightRegistrationProvider, + enrollmentSigningBytes, + type EnrollmentPayload, + type SignedEnrollment, +} from '@midnight-sentinel/api/sponsorship/eligibility'; +import { + createMidnightSponsorSponsorshipApi, + dustPublicKeyToBytes, + nativeNightSponsorshipConfig, +} from '@midnight-sentinel/api/sponsorship/midnight'; import { configureProviders } from '@midnight-sentinel/contract/providers'; import { getBalancesAndAddresses, + getNetworkId, printBalances, type WalletContext, } from '@midnight-sentinel/wallet'; @@ -12,54 +25,73 @@ import { circuitMenu, contractMenu } from './menus.js'; async function handleCircuits( contract: SentinelContract, _walletDetails: { seed: string; privateStateStoreName: string }, - walletCtx: WalletContext, - rli: Interface + _walletCtx: WalletContext, + rli: Interface, + config: Config ) { while (true) { const choice = await rli.question(circuitMenu); switch (choice) { case '1': - try { - const key = walletCtx.shieldedSecretKeys.coinPublicKey; - const amount = await rli.question('Enter the amount you would like to delegate: '); - await contract.delegate(key, BigInt(amount)); - } catch (e) { - console.log('Error delegating: ', e); - } + await contract.getCurrentState(); break; case '2': try { - await contract.redeemRewards(); + await contract.setSponsorshipEnabled(false); } catch (e) { - console.log('Error redeeming rewards: ', e); + console.log('Error pausing sponsorship: ', e); } - return; + break; case '3': try { - await contract.withdraw(); + await contract.setSponsorshipEnabled(true); } catch (e) { - console.log('Error wthdrawing: ', e); + console.log('Error resuming sponsorship: ', e); } break; case '4': try { - const amount = await rli.question( - 'Enter the amount you would like to deposit as rewards: ' - ); - // TODO: wire up to wallet - await contract.depositRewards( - BigInt(amount), - new Uint8Array(32).fill(0), - new Uint8Array(32).fill(0) - ); - } catch (e) { - console.log('Error depositing rewards: ', e); + const serviceUrl = + (await rli.question(`Eligibility service URL [${config.eligibilityService}]: `)) || + config.eligibilityService; + const raw = await rli.question('Signed enrollment JSON: '); + const enrollment = JSON.parse(raw) as SignedEnrollment; + const response = await fetch(`${serviceUrl.replace(/\/$/, '')}/v1/enrollments`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(enrollment), + }); + console.log(await response.text()); + if (!response.ok) { + throw new Error(`Enrollment service returned HTTP ${response.status}`); + } + } catch (error) { + console.error('Enrollment failed:', error); } - return; + break; case '5': - await contract.getCurrentState(); + try { + const identity = (await rli.question('Delegator identity (32-byte hex): ')).replace( + /^0x/, + '' + ); + await contract.removeDelegator(Uint8Array.from(Buffer.from(identity, 'hex'))); + } catch (error) { + console.error('Removal failed:', error); + } break; case '6': + try { + const authority = (await rli.question('New operator authority (32-byte hex): ')).replace( + /^0x/, + '' + ); + await contract.rotateEligibilityOperator(Uint8Array.from(Buffer.from(authority, 'hex'))); + } catch (error) { + console.error('Rotation failed:', error); + } + break; + case '7': console.log('Exiting...'); return; default: @@ -87,7 +119,35 @@ export async function runCli( config, walletDetails.privateStateStoreName ); - contract = await SentinelContract.deploy(providers); + const sponsorShare = BigInt((await rli.question('Sponsor NIGHT share [1]: ')) || '1'); + const delegatorShare = BigInt((await rli.question('Delegator NIGHT share [1]: ')) || '1'); + const minimumRegisteredNight = BigInt( + (await rli.question('Minimum registered NIGHT [1]: ')) || '1' + ); + const operatorHex = ( + await rli.question('Eligibility operator authority key (32-byte hex): ') + ) + .trim() + .replace(/^0x/, ''); + if (!/^[0-9a-fA-F]{64}$/.test(operatorHex)) { + throw new Error('Eligibility operator key must be 32-byte hex'); + } + const targetAddress = ( + await rli.question('Initial allowed target contract address: ') + ).trim(); + const targetEntryPoint = (await rli.question('Initial allowed target circuit: ')).trim(); + const policyHash = sponsorshipAllowlistHash([ + { address: targetAddress, entryPoint: targetEntryPoint }, + ]); + contract = await SentinelContract.deploy( + providers, + nativeNightSponsorshipConfig(walletCtx, policyHash, { + sponsorShare, + delegatorShare, + minimumRegisteredNight, + initialEligibilityOperator: Uint8Array.from(Buffer.from(operatorHex, 'hex')), + }) + ); console.log( `[Contract Address]: ${contract.deployedContract?.deployTxData.public.contractAddress}` @@ -113,8 +173,50 @@ export async function runCli( break; case '3': try { - const raw = await rli.question('Enter the raw transaction recipe: '); - await SentinelContract.zswapSponsor(walletCtx, raw); + const sentinelAddress = ( + await rli.question('Sentinel sponsorship contract address: ') + ).trim(); + const targetAddress = (await rli.question('Allowed target contract address: ')).trim(); + const targetEntryPoint = (await rli.question('Allowed target circuit: ')).trim(); + const maxFee = BigInt(await rli.question('Maximum DUST fee: ')); + const sponsorDustAddress = (await rli.question('Campaign sponsor DUST address: ')).trim(); + const eligibilityService = + (await rli.question(`Eligibility service URL [${config.eligibilityService}]: `)) || + config.eligibilityService; + const raw = (await rli.question('Prepared transaction (hex): ')).trim(); + const allowedTargets = [{ address: targetAddress, entryPoint: targetEntryPoint }]; + const providers = await configureProviders( + walletCtx, + config, + walletDetails.privateStateStoreName + ); + const sponsorshipApi = createMidnightSponsorSponsorshipApi({ + policy: { + sentinelAddress, + sponsorId: dustPublicKeyToBytes(walletCtx.dustSecretKey.publicKey), + sponsorDustAddress, + registrationProvider: createHttpMidnightRegistrationProvider(eligibilityService), + policyHash: sponsorshipAllowlistHash(allowedTargets), + allowedTargets, + minTtlMs: 30_000, + maxTtlMs: 65 * 60 * 1_000, + maxFee, + }, + sentinelProviders: providers, + sponsor: walletCtx, + }); + const result = await sponsorshipApi.sponsorAndSubmit({ + transaction: Uint8Array.from(Buffer.from(raw, 'hex')), + }); + console.log( + JSON.stringify({ + txId: result.txId, + status: result.status, + feeEstimate: result.feeEstimate.toString(), + targetAddress: result.targetAddress, + targetEntryPoint: result.targetEntryPoint, + }) + ); } catch (e) { console.log('Error sponsoring DUST: ', e); } @@ -138,6 +240,38 @@ export async function runCli( break; } case '6': + try { + const sentinelAddress = (await rli.question('Sentinel contract address: ')).trim(); + const sponsorDustAddress = (await rli.question('Campaign sponsor DUST address: ')).trim(); + const nonce = (await rli.question('Enrollment nonce [1]: ')) || '1'; + const expiresInMinutes = Number( + (await rli.question('Expires in minutes [60]: ')) || '60' + ); + if (!Number.isFinite(expiresInMinutes) || expiresInMinutes <= 0) { + throw new Error('Expiry must be a positive number of minutes'); + } + const payload: EnrollmentPayload = { + version: 1, + network: getNetworkId(), + sentinelAddress, + sponsorDustAddress, + nightRewardAddress: walletCtx.unshieldedKeystore.getBech32Address().toString(), + nightVerificationKey: walletCtx.unshieldedKeystore.getPublicKey(), + shieldedCoinPublicKey: walletCtx.shieldedSecretKeys.coinPublicKey, + shieldedEncryptionPublicKey: walletCtx.shieldedSecretKeys.encryptionPublicKey, + nonce: BigInt(nonce).toString(), + expiresAt: new Date(Date.now() + expiresInMinutes * 60_000).toISOString(), + }; + const enrollment: SignedEnrollment = { + payload, + signature: walletCtx.unshieldedKeystore.signData(enrollmentSigningBytes(payload)), + }; + console.log(JSON.stringify(enrollment)); + } catch (error) { + console.error('Could not create enrollment:', error); + } + break; + case '7': console.log('Exiting...'); return; default: @@ -145,6 +279,8 @@ export async function runCli( continue; } - if (contract) await handleCircuits(contract, walletDetails, walletCtx, rli); + if (contract) { + await handleCircuits(contract, walletDetails, walletCtx, rli, config); + } } } diff --git a/apps/cli/src/cli/menus.ts b/apps/cli/src/cli/menus.ts index a20bbca..da23dfb 100644 --- a/apps/cli/src/cli/menus.ts +++ b/apps/cli/src/cli/menus.ts @@ -4,20 +4,22 @@ export const contractMenu: string = ` ${DIVIDER} [1] Deploy a new contract [2] Join an existing contract - [3] (Admin) Submit transaction sponsoring DUST + [3] (Sponsor) Inspect, add only DUST, and submit [4] Start ZSwap to request DUST sponsorship [5] Get balances - [6] Exit + [6] Create signed delegator enrollment + [7] Exit ${DIVIDER} `; export const circuitMenu: string = ` ${DIVIDER} - [1] Delegate NIGHT - [2] Redeem rewards - [3] (Admin) Withdraw NIGHTs - [4] (Admin) Deposit rewards - [5] Get contract state - [6] Exit + [1] Get sponsorship state + [2] (Owner) Pause sponsorship + [3] (Owner) Resume sponsorship + [4] (Operator) Verify and enroll signed request + [5] (Operator) Remove delegator + [6] (Owner) Rotate eligibility operator + [7] Exit ${DIVIDER} `; diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 4c5a98b..eb4915e 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -9,6 +9,7 @@ export interface Config { readonly indexerWS: string; readonly node: string; readonly proofServer: string; + readonly eligibilityService: string; } export const currentDir = path.resolve(new URL(import.meta.url).pathname, '..'); @@ -30,4 +31,5 @@ export class StandaloneConfig implements Config { indexerWS = 'ws://127.0.0.1:8088/api/v3/graphql/ws'; node = 'http://127.0.0.1:9944'; proofServer = 'http://127.0.0.1:6300'; + eligibilityService = 'http://127.0.0.1:8089'; } diff --git a/apps/cli/src/delegation-flow-example.ts b/apps/cli/src/delegation-flow-example.ts index e3560f4..16328b7 100644 --- a/apps/cli/src/delegation-flow-example.ts +++ b/apps/cli/src/delegation-flow-example.ts @@ -17,6 +17,8 @@ */ import { SentinelContract } from '@midnight-sentinel/api'; +import { sponsorshipAllowlistHash } from '@midnight-sentinel/api/sponsorship'; +import { nativeNightSponsorshipConfig } from '@midnight-sentinel/api/sponsorship/midnight'; import { configureProviders } from '@midnight-sentinel/contract/providers'; import { buildWallet, @@ -127,7 +129,12 @@ const main = async () => { console.log('\n=== 3. Admin deploys SentinelContract ==='); const providers = await configureProviders(admin, config, 'delegation-contract'); - const contract = await SentinelContract.deploy(providers); + const contract = await SentinelContract.deploy( + providers, + nativeNightSponsorshipConfig(admin, sponsorshipAllowlistHash([]), { + initialEligibilityOperator: new Uint8Array(32), + }) + ); console.log( ' ✓ Contract deployed at:', contract.deployedContract?.deployTxData.public.contractAddress diff --git a/apps/cli/src/zswap-sponsor-example.ts b/apps/cli/src/zswap-sponsor-example.ts index 8c34286..eb7d969 100644 --- a/apps/cli/src/zswap-sponsor-example.ts +++ b/apps/cli/src/zswap-sponsor-example.ts @@ -15,6 +15,8 @@ */ import { SentinelContract } from '@midnight-sentinel/api'; +import { sponsorshipAllowlistHash } from '@midnight-sentinel/api/sponsorship'; +import { nativeNightSponsorshipConfig } from '@midnight-sentinel/api/sponsorship/midnight'; import { configureProviders } from '@midnight-sentinel/contract/providers'; import { buildWallet, @@ -99,7 +101,12 @@ const main = async () => { const providers = await configureProviders(ctxA, config, 'zswap-sponsor-contract'); console.log(' Deploying Sentinel contract...'); - const contract = await SentinelContract.deploy(providers); + const contract = await SentinelContract.deploy( + providers, + nativeNightSponsorshipConfig(ctxC, sponsorshipAllowlistHash([]), { + initialEligibilityOperator: new Uint8Array(32), + }) + ); console.log(' ✓ Contract deployed'); await sleep(10_000); diff --git a/apps/sponsor-service/README.md b/apps/sponsor-service/README.md new file mode 100644 index 0000000..d655931 --- /dev/null +++ b/apps/sponsor-service/README.md @@ -0,0 +1,90 @@ +# Sentinel Sponsor Eligibility Service + +The service accepts signed Midnight enrollment requests, reconstructs current +registered NIGHT from finalized indexer data, and serializes eligibility queue +mutations for one Sentinel deployment. + +For a running local devnet (node, indexer, and proof server), the complete +setup is one command: + +```sh +pnpm --filter @midnight-sentinel/sponsor-service setup:devnet +``` + +It uses the known funded devnet wallets, generates a distinct circuit operator +secret, deploys an `interact` target and a new Sentinel campaign, derives the +sponsor DUST address, and writes `apps/sponsor-service/.env` with mode `0600`. +The service loads this file automatically. Start it with: + +```sh +pnpm --filter @midnight-sentinel/sponsor-service dev +``` + +The setup command builds full-ZK Sentinel artifacts from `packages/contract` +and the composite target fixture from +`packages/protocol-verification`. The resulting `.env` is local-devnet +configuration and must not be reused for a public network. It will not replace +an existing environment unless you explicitly append `-- --force`. + +For manual or non-devnet setup, the required environment variables are: + +- `SERVICE_ADMIN_TOKEN` — at least 24 characters. +- `SERVICE_OPERATOR_SEED` — the 32-byte transaction wallet seed. +- `SERVICE_OPERATOR_SECRET` — the 32-byte circuit authority secret. Keep this + separate from the wallet seed and never publish it. +- `SERVICE_PRIVATE_STATE_STORE` — encrypted private-state store name used by + the service. +- `SENTINEL_ADDRESS` — deployed Sentinel contract address. +- `SPONSOR_DUST_ADDRESS` — campaign sponsor DUST address. +- `EXPECTED_OPERATOR_AUTHORITY` — public authority stored in the contract. + +Indexer, node, proof server, database, host, port, network, and revalidation +interval have local-devnet defaults. + +Generate a circuit secret and the corresponding public deployment/rotation +authority with: + +```sh +pnpm --filter @midnight-sentinel/sponsor-service operator:bootstrap +``` + +Use `operatorAuthority` when deploying Sentinel or rotating its operator. Set +the matching `operatorSecret` as `SERVICE_OPERATOR_SECRET`, configure the +remaining environment variables, and start the service. In the CLI, option 6 +creates a signed delegator enrollment; after joining Sentinel, circuit option 4 +submits it to this service. Sponsor submission now queries this service during +its stale-delegator preflight. + +## Live devnet end-to-end test + +With the node, indexer, and proof server already running and the full-ZK +Sentinel and composite-target artifacts built, run: + +```sh +pnpm --filter @midnight-sentinel/sponsor-service test:e2e:devnet +``` + +The command recompiles and copies the full-ZK Sentinel and target artifacts +before starting so generated contract bindings, ZKIR, and proving keys cannot +silently drift out of sync. + +This is an opt-in system test and does not use mocked wallets, providers, +registrations, contracts, proofs, or HTTP endpoints. It snapshots the existing +devnet state, creates fresh isolated user wallets, funds them from the known +devnet genesis wallet, submits real sponsor-directed NIGHT registrations, +deploys a target and Sentinel, starts the real eligibility service on an +ephemeral port, and exercises: + +- empty-queue fail-closed behavior; +- invalid, expired, wrong-campaign, unregistered, replayed, and below-minimum + enrollments; +- three real indexer-backed enrollments; +- deterministic `A -> B -> C -> A` rewards; +- both `SucceedEntirely` and `FailFallible`; +- exact sponsor and delegator wallet reward deltas; +- authenticated queue removal and automatic finalized stale-entry removal. + +The test leaves submitted devnet transactions and contracts in chain history. +Wallets, the HTTP server, and the temporary SQLite database are closed even +after failure. A sanitized JSON report is retained in the printed temporary +directory; wallet and operator secrets are never included. diff --git a/apps/sponsor-service/package.json b/apps/sponsor-service/package.json new file mode 100644 index 0000000..ad204c2 --- /dev/null +++ b/apps/sponsor-service/package.json @@ -0,0 +1,42 @@ +{ + "name": "@midnight-sentinel/sponsor-service", + "type": "module", + "scripts": { + "clean": "rm -rf dist .turbo node_modules", + "build": "tsc --project tsconfig.build.json", + "check": "tsc --project tsconfig.build.json --noEmit", + "dev": "tsx src/index.ts", + "operator:bootstrap": "tsx src/operator-bootstrap.ts", + "setup:devnet": "pnpm --dir ../../packages/contract build && pnpm --dir ../../packages/protocol-verification build:sponsor-fixtures && pnpm --dir ../../packages/api build && tsx src/setup-devnet.ts", + "start": "node dist/index.js", + "test": "tsx --test test/*.test.ts", + "test:e2e:devnet": "pnpm --dir ../../packages/contract build && pnpm --dir ../../packages/protocol-verification build:sponsor-fixtures && pnpm --dir ../../packages/api build && tsx src/verification/devnet-e2e.ts", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint ." + }, + "dependencies": { + "@midnight-ntwrk/ledger-v8": "8.0.3", + "@midnight-ntwrk/midnight-js-contracts": "4.1.1", + "@midnight-ntwrk/wallet-sdk-address-format": "3.1.0-rc.0", + "@midnight-sentinel/api": "workspace:*", + "@midnight-sentinel/contract": "workspace:*", + "@midnight-sentinel/wallet": "workspace:*", + "fastify": "^5.6.2", + "graphql-ws": "^6.0.6", + "rxjs": "^7.8.2", + "ws": "^8.18.3", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@midnight-sentinel/protocol-verification": "workspace:*", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=24" + }, + "engineStrict": true +} diff --git a/apps/sponsor-service/src/config.ts b/apps/sponsor-service/src/config.ts new file mode 100644 index 0000000..d683658 --- /dev/null +++ b/apps/sponsor-service/src/config.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import path from 'node:path'; + +const schema = z.object({ + SERVICE_HOST: z.string().default('127.0.0.1'), + SERVICE_PORT: z.coerce.number().int().min(1).max(65_535).default(8089), + SERVICE_DB_PATH: z.string().default('./sponsor-service.sqlite'), + SERVICE_ADMIN_TOKEN: z.string().min(24), + SERVICE_OPERATOR_SEED: z.string().regex(/^[0-9a-fA-F]{64}$/), + SERVICE_OPERATOR_SECRET: z.string().regex(/^[0-9a-fA-F]{64}$/), + SERVICE_PRIVATE_STATE_STORE: z.string().min(1).default('eligibility-operator'), + SERVICE_REVALIDATE_MS: z.coerce.number().int().min(1_000).default(15_000), + SERVICE_LOG_DIR: z.string().default('./logs/sponsor-service'), + SERVICE_ZK_CONFIG_PATH: z.string().default('../../packages/contract/dist/managed/sentinel'), + MIDNIGHT_NETWORK: z.string().default('undeployed'), + MIDNIGHT_INDEXER_HTTP: z.string().url().default('http://127.0.0.1:8088/api/v4/graphql'), + MIDNIGHT_INDEXER_WS: z.string().url().default('ws://127.0.0.1:8088/api/v4/graphql/ws'), + MIDNIGHT_NODE: z.string().url().default('http://127.0.0.1:9944'), + MIDNIGHT_PROOF_SERVER: z.string().url().default('http://127.0.0.1:6300'), + SENTINEL_ADDRESS: z.string().regex(/^[0-9a-fA-F]{64}$/), + SPONSOR_DUST_ADDRESS: z.string().min(1), + EXPECTED_OPERATOR_AUTHORITY: z.string().regex(/^(0x)?[0-9a-fA-F]{64}$/), +}); + +export type ServiceConfig = ReturnType; + +export const loadConfig = (environment: NodeJS.ProcessEnv = process.env) => { + const value = schema.parse(environment); + return { + host: value.SERVICE_HOST, + port: value.SERVICE_PORT, + dbPath: value.SERVICE_DB_PATH, + adminToken: value.SERVICE_ADMIN_TOKEN, + operatorSeed: value.SERVICE_OPERATOR_SEED.toLowerCase(), + operatorSecret: value.SERVICE_OPERATOR_SECRET.toLowerCase(), + privateStateStoreName: value.SERVICE_PRIVATE_STATE_STORE, + revalidateMs: value.SERVICE_REVALIDATE_MS, + logDir: path.resolve(value.SERVICE_LOG_DIR), + zkConfigPath: path.resolve(value.SERVICE_ZK_CONFIG_PATH), + network: value.MIDNIGHT_NETWORK, + networkId: value.MIDNIGHT_NETWORK, + indexer: value.MIDNIGHT_INDEXER_HTTP, + indexerWS: value.MIDNIGHT_INDEXER_WS, + node: value.MIDNIGHT_NODE, + proofServer: value.MIDNIGHT_PROOF_SERVER, + sentinelAddress: value.SENTINEL_ADDRESS.toLowerCase(), + sponsorDustAddress: value.SPONSOR_DUST_ADDRESS, + expectedOperatorAuthority: value.EXPECTED_OPERATOR_AUTHORITY.replace(/^0x/, '').toLowerCase(), + }; +}; diff --git a/apps/sponsor-service/src/database.ts b/apps/sponsor-service/src/database.ts new file mode 100644 index 0000000..4d8d00a --- /dev/null +++ b/apps/sponsor-service/src/database.ts @@ -0,0 +1,372 @@ +import { DatabaseSync } from 'node:sqlite'; +import type { + DustGenerationStatus, + SignedEnrollment, +} from '@midnight-sentinel/api/sponsorship/eligibility'; + +export type JobStatus = 'pending' | 'scanning' | 'submitting' | 'active' | 'ineligible' | 'failed'; + +export interface StoredEnrollment { + identity: string; + address: string; + verificationKey: string; + payload: SignedEnrollment; + nonce: bigint; + status: JobStatus | 'unknown'; +} + +export interface StoredJob { + id: string; + identity: string; + status: JobStatus; + errorCode?: string; + errorMessage?: string; +} + +type SqlRow = Record; + +export class EligibilityDatabase { + readonly sqlite: DatabaseSync; + + constructor(path: string) { + this.sqlite = new DatabaseSync(path); + this.sqlite.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;'); + this.migrate(); + } + + private migrate() { + const version = ( + this.sqlite.prepare('PRAGMA user_version').get() as { + user_version: number; + } + ).user_version; + if (version > 1) { + throw new Error(`Unsupported eligibility database version ${version}`); + } + if (version === 1) return; + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS enrollments ( + identity TEXT PRIMARY KEY, + address TEXT NOT NULL UNIQUE, + verification_key TEXT NOT NULL, + signed_enrollment TEXT NOT NULL, + highest_nonce TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT, + night_balance TEXT NOT NULL DEFAULT '0', + finalized_block TEXT NOT NULL DEFAULT '0', + synchronized INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + identity TEXT NOT NULL, + status TEXT NOT NULL, + error_code TEXT, + error_message TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(identity) REFERENCES enrollments(identity) + ); + CREATE TABLE IF NOT EXISTS utxos ( + address TEXT NOT NULL, + utxo_key TEXT NOT NULL, + token_type TEXT NOT NULL, + value TEXT NOT NULL, + registered INTEGER NOT NULL, + dust_key TEXT, + PRIMARY KEY(address, utxo_key) + ); + CREATE TABLE IF NOT EXISTS cursors ( + address TEXT PRIMARY KEY, + transaction_id INTEGER NOT NULL, + finalized_block TEXT NOT NULL, + synchronized INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + PRAGMA user_version = 1; + `); + } + + close() { + this.sqlite.close(); + } + + transaction(action: () => T): T { + this.sqlite.exec('BEGIN IMMEDIATE'); + try { + const result = action(); + this.sqlite.exec('COMMIT'); + return result; + } catch (error) { + this.sqlite.exec('ROLLBACK'); + throw error; + } + } + + putEnrollment(identity: string, enrollment: SignedEnrollment, nonce: bigint) { + const current = this.getEnrollment(identity); + if (current && nonce <= current.nonce) { + throw new Error('ENROLLMENT_REPLAYED'); + } + const now = new Date().toISOString(); + this.sqlite + .prepare( + `INSERT INTO enrollments ( + identity, address, verification_key, signed_enrollment, highest_nonce, + status, updated_at + ) VALUES (?, ?, ?, ?, ?, 'pending', ?) + ON CONFLICT(identity) DO UPDATE SET + address = excluded.address, + verification_key = excluded.verification_key, + signed_enrollment = excluded.signed_enrollment, + highest_nonce = excluded.highest_nonce, + status = 'pending', + reason = NULL, + synchronized = 0, + updated_at = excluded.updated_at` + ) + .run( + identity, + enrollment.payload.nightRewardAddress, + enrollment.payload.nightVerificationKey, + JSON.stringify(enrollment), + nonce.toString(), + now + ); + } + + getEnrollment(identity: string): StoredEnrollment | undefined { + const row = this.sqlite + .prepare('SELECT * FROM enrollments WHERE identity = ?') + .get(identity) as SqlRow | undefined; + return row ? this.mapEnrollment(row) : undefined; + } + + getEnrollmentByAddress(address: string): StoredEnrollment | undefined { + const row = this.sqlite.prepare('SELECT * FROM enrollments WHERE address = ?').get(address) as + | SqlRow + | undefined; + return row ? this.mapEnrollment(row) : undefined; + } + + listEnrollments(): StoredEnrollment[] { + return (this.sqlite.prepare('SELECT * FROM enrollments').all() as SqlRow[]).map((row) => + this.mapEnrollment(row) + ); + } + + private mapEnrollment(row: SqlRow): StoredEnrollment { + return { + identity: String(row.identity), + address: String(row.address), + verificationKey: String(row.verification_key), + payload: JSON.parse(String(row.signed_enrollment)) as SignedEnrollment, + nonce: BigInt(String(row.highest_nonce)), + status: String(row.status) as StoredEnrollment['status'], + }; + } + + createJob(id: string, identity: string) { + const now = new Date().toISOString(); + this.sqlite + .prepare( + `INSERT INTO jobs ( + id, identity, status, created_at, updated_at + ) VALUES (?, ?, 'pending', ?, ?)` + ) + .run(id, identity, now, now); + } + + getJob(id: string): StoredJob | undefined { + const row = this.sqlite.prepare('SELECT * FROM jobs WHERE id = ?').get(id) as + | SqlRow + | undefined; + if (!row) return undefined; + return { + id: String(row.id), + identity: String(row.identity), + status: String(row.status) as JobStatus, + errorCode: row.error_code ? String(row.error_code) : undefined, + errorMessage: row.error_message ? String(row.error_message) : undefined, + }; + } + + listUnfinishedJobs(): StoredJob[] { + return ( + this.sqlite + .prepare( + `SELECT * FROM jobs WHERE status IN ( + 'pending', 'scanning', 'submitting' + ) ORDER BY created_at` + ) + .all() as SqlRow[] + ).map((row) => ({ + id: String(row.id), + identity: String(row.identity), + status: String(row.status) as JobStatus, + errorCode: row.error_code ? String(row.error_code) : undefined, + errorMessage: row.error_message ? String(row.error_message) : undefined, + })); + } + + setJobStatus(id: string, status: JobStatus, errorCode?: string, errorMessage?: string) { + this.sqlite + .prepare( + `UPDATE jobs SET status = ?, error_code = ?, error_message = ?, + updated_at = ? WHERE id = ?` + ) + .run(status, errorCode ?? null, errorMessage ?? null, new Date().toISOString(), id); + } + + incrementJobAttempts(id: string): number { + this.sqlite + .prepare( + `UPDATE jobs SET attempts = attempts + 1, status = 'pending', + updated_at = ? WHERE id = ?` + ) + .run(new Date().toISOString(), id); + const row = this.sqlite.prepare('SELECT attempts FROM jobs WHERE id = ?').get(id) as + | SqlRow + | undefined; + return Number(row?.attempts ?? 0); + } + + setEnrollmentStatus( + identity: string, + status: StoredEnrollment['status'], + result?: DustGenerationStatus, + reason?: string + ) { + this.sqlite + .prepare( + `UPDATE enrollments SET status = ?, reason = ?, night_balance = ?, + finalized_block = ?, synchronized = ?, updated_at = ? WHERE identity = ?` + ) + .run( + status, + reason ?? null, + result?.nightBalance.toString() ?? '0', + result?.finalizedBlock.toString() ?? '0', + result?.synchronized ? 1 : 0, + new Date().toISOString(), + identity + ); + } + + getStatus(address: string): DustGenerationStatus | undefined { + const row = this.sqlite + .prepare( + `SELECT address, night_balance, finalized_block, synchronized, status + FROM enrollments WHERE address = ?` + ) + .get(address) as SqlRow | undefined; + if (!row) return undefined; + const registered = String(row.status) === 'active'; + return { + nightRewardAddress: String(row.address), + registered, + nightBalance: BigInt(String(row.night_balance)), + finalizedBlock: BigInt(String(row.finalized_block)), + synchronized: Number(row.synchronized) === 1, + }; + } + + getCursor(address: string) { + const row = this.sqlite.prepare('SELECT * FROM cursors WHERE address = ?').get(address) as + | SqlRow + | undefined; + return row + ? { + transactionId: Number(row.transaction_id), + finalizedBlock: BigInt(String(row.finalized_block)), + synchronized: Number(row.synchronized) === 1, + } + : undefined; + } + + setCursor(address: string, transactionId: number, finalizedBlock: bigint) { + this.sqlite + .prepare( + `INSERT INTO cursors ( + address, transaction_id, finalized_block, synchronized + ) VALUES (?, ?, ?, 1) + ON CONFLICT(address) DO UPDATE SET + transaction_id = excluded.transaction_id, + finalized_block = excluded.finalized_block, + synchronized = 1` + ) + .run(address, transactionId, finalizedBlock.toString()); + } + + markCursorUnsynchronized(address: string) { + this.sqlite + .prepare( + `INSERT INTO cursors ( + address, transaction_id, finalized_block, synchronized + ) VALUES (?, 0, '0', 0) + ON CONFLICT(address) DO UPDATE SET synchronized = 0` + ) + .run(address); + } + + applyUtxoChanges( + address: string, + spentKeys: readonly string[], + created: readonly { + key: string; + tokenType: string; + value: bigint; + registered: boolean; + dustKey?: string; + }[] + ) { + const remove = this.sqlite.prepare('DELETE FROM utxos WHERE address = ? AND utxo_key = ?'); + for (const key of spentKeys) remove.run(address, key); + const insert = this.sqlite.prepare( + `INSERT OR REPLACE INTO utxos ( + address, utxo_key, token_type, value, registered, dust_key + ) VALUES (?, ?, ?, ?, ?, ?)` + ); + for (const utxo of created) { + insert.run( + address, + utxo.key, + utxo.tokenType, + utxo.value.toString(), + utxo.registered ? 1 : 0, + utxo.dustKey ?? null + ); + } + } + + qualifyingBalance(address: string, tokenType: string, dustKey: string): bigint { + const rows = this.sqlite + .prepare( + `SELECT value FROM utxos WHERE address = ? AND token_type = ? + AND registered = 1 AND dust_key = ?` + ) + .all(address, tokenType, dustKey) as SqlRow[]; + return rows.reduce((total, row) => total + BigInt(String(row.value)), 0n); + } + + getMetadata(key: string): string | undefined { + const row = this.sqlite.prepare('SELECT value FROM metadata WHERE key = ?').get(key) as + | SqlRow + | undefined; + return row ? String(row.value) : undefined; + } + + setMetadata(key: string, value: string) { + this.sqlite + .prepare( + `INSERT INTO metadata (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ) + .run(key, value); + } +} diff --git a/apps/sponsor-service/src/index.ts b/apps/sponsor-service/src/index.ts new file mode 100644 index 0000000..a58e8e8 --- /dev/null +++ b/apps/sponsor-service/src/index.ts @@ -0,0 +1,88 @@ +import { SentinelContract } from '@midnight-sentinel/api'; +import { dustPublicKeyToBytes } from '@midnight-sentinel/api/sponsorship/midnight'; +import { createMidnightEnrollmentVerifier } from '@midnight-sentinel/api/sponsorship/eligibility'; +import { + createPrivateState, + deriveSentinelAuthority, + sentinelContractPrivateStateKey, +} from '@midnight-sentinel/contract'; +import { configureProviders } from '@midnight-sentinel/contract/providers'; +import { buildWallet } from '@midnight-sentinel/wallet'; +import { DustAddress, MidnightBech32m } from '@midnight-ntwrk/wallet-sdk-address-format'; +import { loadConfig } from './config.js'; +import { EligibilityDatabase } from './database.js'; +import { MidnightIndexerScanner } from './indexer.js'; +import { ContractQueueOperator } from './operator.js'; +import { buildServer } from './server.js'; +import { EligibilityService } from './service.js'; +import { loadServiceEnv } from './load-env.js'; + +loadServiceEnv(); +const equalHex = (bytes: Uint8Array, expected: string) => + Buffer.from(bytes).toString('hex') === expected.replace(/^0x/, '').toLowerCase(); + +const config = loadConfig(); +const database = new EligibilityDatabase(config.dbPath); +const wallet = await buildWallet(config, config.operatorSeed); +const providers = await configureProviders(wallet, config, config.privateStateStoreName); +const operatorSecret = Uint8Array.from(Buffer.from(config.operatorSecret, 'hex')); +if (!equalHex(deriveSentinelAuthority(operatorSecret), config.expectedOperatorAuthority)) { + throw new Error('Configured operator secret does not derive the expected operator authority'); +} +providers.privateStateProvider.setContractAddress(config.sentinelAddress); +await providers.privateStateProvider.set( + sentinelContractPrivateStateKey, + createPrivateState(operatorSecret) +); +const contract = await SentinelContract.join(providers, config.sentinelAddress); +const campaignState = await contract.readState(); + +if (!equalHex(campaignState.eligibilityOperator, config.expectedOperatorAuthority)) { + throw new Error('Configured eligibility operator does not match the Sentinel campaign'); +} + +const sponsorDustKey = DustAddress.codec.decode( + config.network, + MidnightBech32m.parse(config.sponsorDustAddress) +).data; +if ( + Buffer.from(campaignState.sponsorshipSponsorId).compare( + Buffer.from(dustPublicKeyToBytes(sponsorDustKey)) + ) !== 0 +) { + throw new Error('Configured sponsor DUST address does not match the campaign'); +} + +const scanner = new MidnightIndexerScanner( + config.indexer, + config.indexerWS, + database, + sponsorDustKey.toString() +); +const service = new EligibilityService( + database, + scanner, + new ContractQueueOperator(contract), + { + network: config.network, + sentinelAddress: config.sentinelAddress, + sponsorDustAddress: config.sponsorDustAddress, + minimumRegisteredNight: campaignState.sponsorshipMinimumRegisteredNight, + }, + createMidnightEnrollmentVerifier(config.network), + config.revalidateMs +); +const server = buildServer(config, service); + +service.start(); +await server.listen({ host: config.host, port: config.port }); + +const shutdown = async () => { + await service.stop(); + await server.close(); + await wallet.wallet.stop(); + database.close(); +}; + +process.once('SIGINT', () => void shutdown()); +process.once('SIGTERM', () => void shutdown()); diff --git a/apps/sponsor-service/src/indexer.ts b/apps/sponsor-service/src/indexer.ts new file mode 100644 index 0000000..7cb278f --- /dev/null +++ b/apps/sponsor-service/src/indexer.ts @@ -0,0 +1,260 @@ +import { + Binding, + Proof, + SignatureEnabled, + Transaction, + unshieldedToken, +} from '@midnight-ntwrk/ledger-v8'; +import type { DustGenerationStatus } from '@midnight-sentinel/api/sponsorship/eligibility'; +import { createClient, type Client } from 'graphql-ws'; +import WebSocket from 'ws'; +import { EligibilityDatabase } from './database.js'; + +const normalizeHex = (value: string) => value.toLowerCase().replace(/^0x/, ''); +const utxoKey = (utxo: IndexerUtxo) => `${normalizeHex(utxo.intentHash)}:${utxo.outputIndex}`; + +interface IndexerUtxo { + tokenType: string; + value: string; + outputIndex: number; + intentHash: string; + registeredForDustGeneration: boolean; +} + +interface TransactionEvent { + type: 'UnshieldedTransaction'; + transaction: { + id: number; + raw: string; + block: { height: number }; + }; + createdUtxos: IndexerUtxo[]; + spentUtxos: IndexerUtxo[]; +} + +interface ProgressEvent { + type: 'UnshieldedTransactionsProgress'; + highestTransactionId: number; +} + +type StreamEvent = TransactionEvent | ProgressEvent; + +const subscription = ` + subscription SentinelUnshieldedTransactions( + $address: UnshieldedAddress!, + $transactionId: Int + ) { + unshieldedTransactions(address: $address, transactionId: $transactionId) { + ... on UnshieldedTransaction { + type: __typename + transaction { + id + raw + block { height } + } + createdUtxos { + tokenType + value + outputIndex + intentHash + registeredForDustGeneration + } + spentUtxos { + tokenType + value + outputIndex + intentHash + registeredForDustGeneration + } + } + ... on UnshieldedTransactionsProgress { + type: __typename + highestTransactionId + } + } + } +`; + +const latestBlockQuery = `query SentinelLatestBlock { block { height } }`; + +const registrationTarget = (raw: string, verificationKey: string): string | undefined => { + const tx = Transaction.deserialize( + 'signature', + 'proof', + 'binding', + Uint8Array.from(Buffer.from(normalizeHex(raw), 'hex')) + ); + for (const intent of tx.intents?.values() ?? []) { + const registration = intent.dustActions?.registrations.find( + (value) => value.nightKey === verificationKey + ); + if (registration) { + return registration.dustAddress?.toString(); + } + } + return undefined; +}; + +export class MidnightIndexerScanner { + private readonly client: Client; + + constructor( + private readonly httpUrl: string, + wsUrl: string, + private readonly database: EligibilityDatabase, + private readonly sponsorDustKey: string + ) { + this.client = createClient({ + url: wsUrl, + webSocketImpl: WebSocket, + lazy: true, + retryAttempts: 5, + }); + } + + async latestFinalizedBlock(): Promise { + const response = await fetch(this.httpUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: latestBlockQuery }), + }); + if (!response.ok) { + throw new Error(`Indexer returned HTTP ${response.status}`); + } + const payload = (await response.json()) as { + data?: { block?: { height: number } }; + errors?: { message: string }[]; + }; + if (!payload.data?.block) { + throw new Error(payload.errors?.[0]?.message ?? 'Indexer returned no block'); + } + return BigInt(payload.data.block.height); + } + + async sync(input: { + address: string; + verificationKey: string; + sponsorDustAddress: string; + }): Promise { + this.database.markCursorUnsynchronized(input.address); + const cursor = this.database.getCursor(input.address)?.transactionId; + const progress = await new Promise((resolve, reject) => { + let settled = false; + let progressSeen = false; + let highestTransactionId = cursor ?? 0; + let settleTimer: NodeJS.Timeout | undefined; + const settleAfterBufferedEvents = (dispose: () => void) => { + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settled = true; + dispose(); + resolve(highestTransactionId); + }, 1_000); + }; + const dispose = this.client.subscribe<{ + unshieldedTransactions: StreamEvent; + }>( + { + query: subscription, + variables: { + address: input.address, + transactionId: cursor, + }, + }, + { + next: ({ data, errors }) => { + if (errors?.length) { + settled = true; + dispose(); + reject(new Error(errors[0]?.message ?? 'Indexer subscription failed')); + return; + } + const event = data?.unshieldedTransactions; + if (!event) return; + if (event.type === 'UnshieldedTransactionsProgress') { + // The indexer may emit the progress watermark immediately before + // the final buffered transaction at that same watermark. Keep + // the subscription open until the address stream is quiet so + // that transaction is applied before persisting the cursor. + progressSeen = true; + highestTransactionId = Math.max(highestTransactionId, event.highestTransactionId); + settleAfterBufferedEvents(dispose); + return; + } + try { + this.applyTransaction(input.address, input.verificationKey, event); + highestTransactionId = Math.max(highestTransactionId, event.transaction.id); + if (progressSeen) settleAfterBufferedEvents(dispose); + } catch (error) { + settled = true; + if (settleTimer) clearTimeout(settleTimer); + dispose(); + reject(error); + } + }, + error: (error) => { + if (!settled) { + settled = true; + if (settleTimer) clearTimeout(settleTimer); + reject(error); + } + }, + complete: () => { + if (!settled) { + settled = true; + if (settleTimer) clearTimeout(settleTimer); + if (progressSeen) resolve(highestTransactionId); + else reject(new Error('Indexer subscription completed before progress')); + } + }, + } + ); + }); + const finalizedBlock = await this.latestFinalizedBlock(); + this.database.setCursor(input.address, progress, finalizedBlock); + const nightBalance = this.database.qualifyingBalance( + input.address, + unshieldedToken().raw, + this.sponsorDustKey + ); + return { + nightRewardAddress: input.address, + dustAddress: nightBalance > 0n ? input.sponsorDustAddress : undefined, + registered: nightBalance > 0n, + nightBalance, + finalizedBlock, + synchronized: true, + }; + } + + private applyTransaction(address: string, verificationKey: string, event: TransactionEvent) { + // Registration updates preserve the original NIGHT UTXO identity. The + // createdUtxo.intentHash therefore points to the funding transaction, not + // the registration intent. Correlate by the address-scoped transaction and + // its matching NIGHT verification key instead of joining intent hashes. + const dustKey = registrationTarget(event.transaction.raw, verificationKey); + this.database.transaction(() => { + this.database.applyUtxoChanges( + address, + event.spentUtxos.map(utxoKey), + event.createdUtxos.map((utxo) => ({ + key: utxoKey(utxo), + tokenType: normalizeHex(utxo.tokenType), + value: BigInt(utxo.value), + registered: utxo.registeredForDustGeneration, + dustKey: utxo.registeredForDustGeneration ? dustKey : undefined, + })) + ); + }); + } + + async dispose() { + await Promise.resolve(this.client.dispose()).catch(() => undefined); + } +} + +export const decodeDustAddressKey = async (network: string, address: string): Promise => { + const { DustAddress, MidnightBech32m } = + await import('@midnight-ntwrk/wallet-sdk-address-format'); + return DustAddress.codec.decode(network, MidnightBech32m.parse(address)).data.toString(); +}; diff --git a/apps/sponsor-service/src/load-env.ts b/apps/sponsor-service/src/load-env.ts new file mode 100644 index 0000000..ab01636 --- /dev/null +++ b/apps/sponsor-service/src/load-env.ts @@ -0,0 +1,9 @@ +import { loadEnvFile } from 'node:process'; + +export const loadServiceEnv = () => { + try { + loadEnvFile(new URL('../.env', import.meta.url)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +}; diff --git a/apps/sponsor-service/src/operator-bootstrap.ts b/apps/sponsor-service/src/operator-bootstrap.ts new file mode 100644 index 0000000..34e9143 --- /dev/null +++ b/apps/sponsor-service/src/operator-bootstrap.ts @@ -0,0 +1,19 @@ +import { randomBytes } from 'node:crypto'; +import { deriveSentinelAuthority } from '@midnight-sentinel/contract'; + +const supplied = process.argv + .find((argument) => argument.startsWith('--secret=')) + ?.slice('--secret='.length); +if (supplied && !/^[0-9a-fA-F]{64}$/.test(supplied)) { + throw new Error('--secret must contain exactly 32 bytes of hexadecimal'); +} +const secret = supplied + ? Uint8Array.from(Buffer.from(supplied, 'hex')) + : Uint8Array.from(randomBytes(32)); + +console.log( + JSON.stringify({ + operatorSecret: Buffer.from(secret).toString('hex'), + operatorAuthority: Buffer.from(deriveSentinelAuthority(secret)).toString('hex'), + }) +); diff --git a/apps/sponsor-service/src/operator.ts b/apps/sponsor-service/src/operator.ts new file mode 100644 index 0000000..5ca226e --- /dev/null +++ b/apps/sponsor-service/src/operator.ts @@ -0,0 +1,25 @@ +import type { EligibilityQueueOperator } from '@midnight-sentinel/api/sponsorship/eligibility'; +import type { SentinelContract } from '@midnight-sentinel/api'; + +export class ContractQueueOperator implements EligibilityQueueOperator { + constructor(private readonly contract: SentinelContract) {} + + async lookup(identity: Uint8Array) { + const state = await this.contract.readState(); + if (!state.delegatorPositions.member(identity)) return undefined; + const slot = state.delegatorSlots.lookup(state.delegatorPositions.lookup(identity)); + return { enrollmentNonce: slot.enrollmentNonce }; + } + + add(input: Parameters[0]) { + return this.contract.addDelegator(input); + } + + update(input: Parameters[0]) { + return this.contract.updateDelegator(input); + } + + remove(identity: Uint8Array) { + return this.contract.removeDelegator(identity); + } +} diff --git a/apps/sponsor-service/src/server.ts b/apps/sponsor-service/src/server.ts new file mode 100644 index 0000000..0fa576c --- /dev/null +++ b/apps/sponsor-service/src/server.ts @@ -0,0 +1,117 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { SignedEnrollment } from '@midnight-sentinel/api/sponsorship/eligibility'; +import Fastify from 'fastify'; +import { z } from 'zod'; +import type { ServiceConfig } from './config.js'; +import type { EligibilityService } from './service.js'; + +const enrollmentSchema = z.object({ + payload: z.object({ + version: z.literal(1), + network: z.string(), + sentinelAddress: z.string(), + sponsorDustAddress: z.string(), + nightRewardAddress: z.string(), + nightVerificationKey: z.string(), + shieldedCoinPublicKey: z.string(), + shieldedEncryptionPublicKey: z.string(), + nonce: z.string(), + expiresAt: z.string(), + }), + signature: z.string(), +}); + +const authorized = (provided: string | undefined, expected: string) => { + const prefix = 'Bearer '; + if (!provided?.startsWith(prefix)) return false; + const actual = Buffer.from(provided.slice(prefix.length)); + const wanted = Buffer.from(expected); + return actual.length === wanted.length && timingSafeEqual(actual, wanted); +}; + +export const buildServer = (config: ServiceConfig, service: EligibilityService) => { + const app = Fastify({ + logger: true, + bodyLimit: 64 * 1024, + }); + const rate = new Map(); + + app.get('/healthz', async () => ({ ok: true })); + app.get('/readyz', async (_request, reply) => { + try { + await service.database.sqlite.prepare('SELECT 1').get(); + return { ready: true }; + } catch { + return reply.code(503).send({ ready: false }); + } + }); + + app.post('/v1/enrollments', async (request, reply) => { + const key = request.ip; + const now = Date.now(); + const window = rate.get(key); + const current = !window || window.reset <= now ? { count: 0, reset: now + 60_000 } : window; + current.count += 1; + rate.set(key, current); + if (current.count > 10) { + return reply.code(429).send({ code: 'RATE_LIMITED' }); + } + try { + const enrollment = enrollmentSchema.parse(request.body) as SignedEnrollment; + const submitted = service.submit(enrollment); + return reply.code(202).send({ + ...submitted, + statusUrl: `/v1/jobs/${submitted.jobId}`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = message.includes('REPLAYED') ? 'ENROLLMENT_REPLAYED' : 'ENROLLMENT_INVALID'; + return reply.code(400).send({ code, message }); + } + }); + + app.get<{ Params: { jobId: string } }>('/v1/jobs/:jobId', async (request, reply) => { + const job = service.getJob(request.params.jobId); + return job ?? reply.code(404).send({ code: 'JOB_NOT_FOUND' }); + }); + + app.get<{ Params: { address: string } }>('/v1/eligibility/:address', async (request, reply) => { + const status = service.getStatus(request.params.address); + if (!status) { + return reply.code(404).send({ code: 'ENROLLMENT_NOT_FOUND' }); + } + return { + ...status, + nightBalance: status.nightBalance.toString(), + finalizedBlock: status.finalizedBlock.toString(), + }; + }); + + app.post<{ Body: { identity?: string } }>('/v1/admin/revalidate', async (request, reply) => { + if (!authorized(request.headers.authorization, config.adminToken)) { + return reply.code(401).send({ code: 'UNAUTHORIZED' }); + } + const identity = request.body?.identity; + if (identity && !/^[0-9a-fA-F]{64}$/.test(identity)) { + return reply.code(400).send({ code: 'INVALID_IDENTITY' }); + } + void service.revalidate(identity?.toLowerCase()); + return reply.code(202).send({ accepted: true }); + }); + + app.delete<{ Params: { identity: string } }>( + '/v1/admin/delegators/:identity', + async (request, reply) => { + if (!authorized(request.headers.authorization, config.adminToken)) { + return reply.code(401).send({ code: 'UNAUTHORIZED' }); + } + if (!/^[0-9a-fA-F]{64}$/.test(request.params.identity)) { + return reply.code(400).send({ code: 'INVALID_IDENTITY' }); + } + await service.remove(request.params.identity.toLowerCase()); + return reply.code(204).send(); + } + ); + + return app; +}; diff --git a/apps/sponsor-service/src/service.ts b/apps/sponsor-service/src/service.ts new file mode 100644 index 0000000..096fe44 --- /dev/null +++ b/apps/sponsor-service/src/service.ts @@ -0,0 +1,256 @@ +import { randomUUID } from 'node:crypto'; +import { + assertEligible, + EligibilityError, + enrollDelegator, + verifyEnrollment, + type EligibilityCampaign, + type EnrollmentSignatureVerifier, + type SignedEnrollment, +} from '@midnight-sentinel/api/sponsorship/eligibility'; +import { EligibilityDatabase } from './database.js'; +import { MidnightIndexerScanner } from './indexer.js'; +import { ContractQueueOperator } from './operator.js'; + +const identityHex = (value: Uint8Array) => Buffer.from(value).toString('hex'); +const identityBytes = (value: string) => + Uint8Array.from(Buffer.from(value.replace(/^0x/, ''), 'hex')); + +export class EligibilityService { + private mutationTail: Promise = Promise.resolve(); + private timer?: NodeJS.Timeout; + private readonly background = new Set>(); + private readonly retryTimers = new Set(); + private revalidationInFlight = false; + private running = false; + + constructor( + readonly database: EligibilityDatabase, + private readonly scanner: MidnightIndexerScanner, + private readonly operator: ContractQueueOperator, + private readonly campaign: EligibilityCampaign, + private readonly verifier: EnrollmentSignatureVerifier, + private readonly revalidateMs: number + ) {} + + start() { + if (this.running) return; + this.running = true; + this.timer = setInterval(() => { + this.runBackground(this.revalidateIfBlockAdvanced()); + }, this.revalidateMs); + this.timer.unref(); + for (const job of this.database.listUnfinishedJobs()) { + this.serialize(async () => this.process(job.id, job.identity)); + } + this.runBackground(this.revalidateIfBlockAdvanced()); + } + + async stop() { + this.running = false; + if (this.timer) clearInterval(this.timer); + for (const retry of this.retryTimers) clearTimeout(retry); + this.retryTimers.clear(); + await Promise.allSettled([...this.background]); + await this.mutationTail.catch(() => undefined); + await this.scanner.dispose(); + } + + submit(enrollment: SignedEnrollment) { + const verified = this.verify(enrollment); + const identity = identityHex(verified.identity); + const jobId = randomUUID(); + this.database.transaction(() => { + this.database.putEnrollment(identity, enrollment, verified.nonce); + this.database.createJob(jobId, identity); + }); + this.serialize(async () => this.process(jobId, identity)); + return { jobId, identity }; + } + + getJob(id: string) { + return this.database.getJob(id); + } + + getStatus(address: string) { + const status = this.database.getStatus(address); + return status && status.registered + ? { ...status, dustAddress: this.campaign.sponsorDustAddress } + : status; + } + + private verify(enrollment: SignedEnrollment) { + // Use the shared canonical verifier without duplicating its rules. + return verifyEnrollment(enrollment, this.campaign, this.verifier); + } + + private serialize(action: () => Promise) { + this.mutationTail = this.mutationTail.then(action, action); + return this.mutationTail; + } + + private async process(jobId: string, identity: string) { + const enrollment = this.database.getEnrollment(identity); + if (!enrollment) return; + try { + this.database.setJobStatus(jobId, 'scanning'); + const status = await this.scanner.sync({ + address: enrollment.address, + verificationKey: enrollment.verificationKey, + sponsorDustAddress: this.campaign.sponsorDustAddress, + }); + assertEligible( + status, + this.campaign.sponsorDustAddress, + this.campaign.minimumRegisteredNight + ); + this.database.setJobStatus(jobId, 'submitting'); + const current = await this.operator.lookup(identityBytes(identity)); + if (current?.enrollmentNonce !== enrollment.nonce) { + await enrollDelegator({ + enrollment: enrollment.payload, + campaign: this.campaign, + verifier: this.verifier, + registrationProvider: { getStatus: async () => status }, + verificationBlock: status.finalizedBlock, + operator: this.operator, + }); + } + this.database.setEnrollmentStatus(identity, 'active', status); + this.database.setJobStatus(jobId, 'active'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = errorCode(error); + if (code === 'DELEGATOR_STALE') { + await this.removeIfPresent(identity); + this.database.setEnrollmentStatus(identity, 'ineligible', undefined, message); + this.database.setJobStatus(jobId, 'ineligible', code, message); + } else { + this.database.setEnrollmentStatus(identity, 'unknown', undefined, message); + const attempts = this.database.incrementJobAttempts(jobId); + if (attempts >= 5) { + this.database.setJobStatus(jobId, 'failed', code, message); + } else { + const retryMs = Math.min(2 ** (attempts - 1) * 1_000, 30_000); + const retry = setTimeout(() => { + this.retryTimers.delete(retry); + if (this.running) this.serialize(async () => this.process(jobId, identity)); + }, retryMs); + this.retryTimers.add(retry); + retry.unref(); + } + } + } + } + + async revalidate(identity?: string) { + return this.serialize(async () => { + const enrollments = identity + ? [this.database.getEnrollment(identity)].filter((value) => value !== undefined) + : this.database + .listEnrollments() + .filter((value) => value.status === 'active' || value.status === 'unknown'); + for (const enrollment of enrollments) { + try { + const status = await this.scanner.sync({ + address: enrollment.address, + verificationKey: enrollment.verificationKey, + sponsorDustAddress: this.campaign.sponsorDustAddress, + }); + assertEligible( + status, + this.campaign.sponsorDustAddress, + this.campaign.minimumRegisteredNight + ); + const queued = await this.operator.lookup(identityBytes(enrollment.identity)); + if (queued?.enrollmentNonce === enrollment.nonce) { + this.database.setEnrollmentStatus(enrollment.identity, 'active', status); + } else { + this.database.setEnrollmentStatus( + enrollment.identity, + 'unknown', + status, + 'QUEUE_ENTRY_NOT_CONFIRMED' + ); + } + } catch (error) { + const code = errorCode(error); + if (code !== 'DELEGATOR_STALE') { + this.database.setEnrollmentStatus( + enrollment.identity, + 'unknown', + undefined, + error instanceof Error ? error.message : String(error) + ); + continue; + } + await this.removeIfPresent(enrollment.identity); + this.database.setEnrollmentStatus( + enrollment.identity, + 'ineligible', + undefined, + error instanceof Error ? error.message : String(error) + ); + } + } + }); + } + + async remove(identity: string) { + return this.serialize(async () => { + await this.removeIfPresent(identity); + this.database.setEnrollmentStatus(identity, 'ineligible', undefined, 'REMOVED'); + }); + } + + private async removeIfPresent(identity: string) { + if (await this.operator.lookup(identityBytes(identity))) { + await this.operator.remove(identityBytes(identity)); + } + } + + private async revalidateIfBlockAdvanced() { + if (this.revalidationInFlight) return; + this.revalidationInFlight = true; + try { + if (!this.running) return; + const height = await this.scanner.latestFinalizedBlock(); + if (!this.running) return; + const previous = BigInt(this.database.getMetadata('last_revalidated_block') ?? '-1'); + if (height <= previous) return; + await this.revalidate(); + this.database.setMetadata('last_revalidated_block', height.toString()); + } catch { + // Unknown indexer state must fail sponsorship closed but must never be + // interpreted as finalized proof that a delegator became invalid. + for (const enrollment of this.database + .listEnrollments() + .filter((value) => value.status === 'active' || value.status === 'unknown')) { + this.database.setEnrollmentStatus( + enrollment.identity, + 'unknown', + undefined, + 'ELIGIBILITY_QUERY_FAILED' + ); + } + } finally { + this.revalidationInFlight = false; + } + } + + private runBackground(action: Promise) { + this.background.add(action); + void action.finally(() => this.background.delete(action)); + } +} + +const errorCode = (error: unknown) => + error instanceof EligibilityError + ? error.code + : error instanceof Error && error.message === 'ENROLLMENT_REPLAYED' + ? 'ENROLLMENT_REPLAYED' + : error instanceof Error && + (error.message.includes('submitting scoped transaction') || + error.message.includes('Proof Server')) + ? 'OPERATOR_SUBMISSION_FAILED' + : 'ELIGIBILITY_QUERY_FAILED'; diff --git a/apps/sponsor-service/src/setup-devnet-config.ts b/apps/sponsor-service/src/setup-devnet-config.ts new file mode 100644 index 0000000..249a03a --- /dev/null +++ b/apps/sponsor-service/src/setup-devnet-config.ts @@ -0,0 +1,33 @@ +export interface DevnetEnvironment { + adminToken: string; + operatorSeed: string; + operatorSecret: string; + operatorAuthority: string; + sentinelAddress: string; + sponsorDustAddress: string; +} + +export const renderDevnetEnvironment = (value: DevnetEnvironment): string => + [ + '# Generated by pnpm --filter @midnight-sentinel/sponsor-service setup:devnet', + '# Local devnet only. Do not reuse these credentials on another network.', + 'SERVICE_HOST=127.0.0.1', + 'SERVICE_PORT=8089', + 'SERVICE_DB_PATH=./sponsor-service.sqlite', + `SERVICE_ADMIN_TOKEN=${value.adminToken}`, + `SERVICE_OPERATOR_SEED=${value.operatorSeed}`, + `SERVICE_OPERATOR_SECRET=${value.operatorSecret}`, + `SERVICE_PRIVATE_STATE_STORE=eligibility-operator-${value.sentinelAddress.slice(0, 12)}`, + 'SERVICE_REVALIDATE_MS=15000', + 'SERVICE_LOG_DIR=./logs/sponsor-service', + 'SERVICE_ZK_CONFIG_PATH=../../packages/contract/dist/managed/sentinel', + 'MIDNIGHT_NETWORK=undeployed', + 'MIDNIGHT_INDEXER_HTTP=http://127.0.0.1:8088/api/v4/graphql', + 'MIDNIGHT_INDEXER_WS=ws://127.0.0.1:8088/api/v4/graphql/ws', + 'MIDNIGHT_NODE=http://127.0.0.1:9944', + 'MIDNIGHT_PROOF_SERVER=http://127.0.0.1:6300', + `SENTINEL_ADDRESS=${value.sentinelAddress}`, + `SPONSOR_DUST_ADDRESS=${value.sponsorDustAddress}`, + `EXPECTED_OPERATOR_AUTHORITY=${value.operatorAuthority}`, + '', + ].join('\n'); diff --git a/apps/sponsor-service/src/setup-devnet.ts b/apps/sponsor-service/src/setup-devnet.ts new file mode 100644 index 0000000..32518d0 --- /dev/null +++ b/apps/sponsor-service/src/setup-devnet.ts @@ -0,0 +1,158 @@ +import { randomBytes } from 'node:crypto'; +import { access, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { deployContract } from '@midnight-ntwrk/midnight-js-contracts'; +import { SentinelContract } from '@midnight-sentinel/api'; +import { nativeNightSponsorshipConfig } from '@midnight-sentinel/api/sponsorship/midnight'; +import { sponsorshipAllowlistHash } from '@midnight-sentinel/api/sponsorship'; +import { deriveSentinelAuthority } from '@midnight-sentinel/contract'; +import { configureProviders } from '@midnight-sentinel/contract/providers'; +import { + CompositeTargetCompiledContract, + type CompositeTargetContractType, +} from '@midnight-sentinel/protocol-verification/composite-sponsorship'; +import { + buildWallet, + getBalancesAndAddresses, + type WalletContext, +} from '@midnight-sentinel/wallet'; +import { renderDevnetEnvironment } from './setup-devnet-config.js'; + +const GENESIS_DEPLOYER_SEED = `${'0'.repeat(63)}1`; +const GENESIS_OPERATOR_SEED = `${'0'.repeat(63)}2`; +const GENESIS_SPONSOR_SEED = `${'0'.repeat(63)}3`; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const repositoryDirectory = path.resolve(packageDirectory, '../..'); +const sentinelZkPath = path.join(repositoryDirectory, 'packages/contract/dist/managed/sentinel'); +const targetZkPath = path.join( + repositoryDirectory, + 'packages/protocol-verification/dist/managed/composite-target' +); +const envPath = path.join(packageDirectory, '.env'); +const replaceEnvironment = process.argv.includes('--force'); + +const network = { + networkId: 'undeployed', + indexer: 'http://127.0.0.1:8088/api/v4/graphql', + indexerWS: 'ws://127.0.0.1:8088/api/v4/graphql/ws', + node: 'http://127.0.0.1:9944', + proofServer: 'http://127.0.0.1:6300', + privateStateStoreName: 'setup', + logDir: path.join(packageDirectory, 'logs/setup'), + zkConfigPath: sentinelZkPath, + eligibilityService: 'http://127.0.0.1:8089', +}; + +const requireArtifacts = async () => { + try { + await Promise.all([ + access(path.join(sentinelZkPath, 'keys/purchaseSponsorship.prover')), + access(path.join(targetZkPath, 'keys/interact.prover')), + ]); + } catch { + throw new Error( + 'Full-ZK artifacts are missing. Build the Sentinel and composite target before setup.' + ); + } +}; + +const requireWritableEnvironment = async () => { + if (replaceEnvironment) return; + try { + await access(envPath); + } catch { + return; + } + throw new Error(`Refusing to replace ${envPath}. Re-run with --force to replace it.`); +}; + +const hex = (value: Uint8Array) => Buffer.from(value).toString('hex'); + +const stopWallets = async (wallets: WalletContext[]) => { + await Promise.allSettled(wallets.map(({ wallet }) => wallet.stop())); +}; + +const main = async () => { + await requireArtifacts(); + await requireWritableEnvironment(); + const wallets: WalletContext[] = []; + try { + console.log('Connecting funded local-devnet wallets...'); + const [deployer, sponsor] = await Promise.all([ + buildWallet(network, GENESIS_DEPLOYER_SEED), + buildWallet(network, GENESIS_SPONSOR_SEED), + ]); + wallets.push(deployer, sponsor); + + console.log('Deploying sponsorship target...'); + const targetProviders = await configureProviders( + deployer, + network, + `setup-target-${Date.now()}`, + targetZkPath + ); + const target = await deployContract(targetProviders, { + compiledContract: CompositeTargetCompiledContract, + }); + const targetAddress = target.deployTxData.public.contractAddress; + const targetEntryPoint = 'interact'; + + const operatorSecret = Uint8Array.from(randomBytes(32)); + const operatorAuthority = deriveSentinelAuthority(operatorSecret); + const policyHash = sponsorshipAllowlistHash([ + { address: targetAddress, entryPoint: targetEntryPoint }, + ]); + + console.log('Deploying Sentinel campaign...'); + const sentinelProviders = await configureProviders( + deployer, + network, + `setup-sentinel-${Date.now()}`, + sentinelZkPath + ); + const sentinel = await SentinelContract.deploy( + sentinelProviders, + nativeNightSponsorshipConfig(sponsor, policyHash, { + sponsorShare: 1n, + delegatorShare: 1n, + minimumRegisteredNight: 1n, + initialEligibilityOperator: operatorAuthority, + }) + ); + const sentinelAddress = sentinel.deployedContract!.deployTxData.public.contractAddress; + const { addresses } = await getBalancesAndAddresses(sponsor.wallet, GENESIS_SPONSOR_SEED); + + await writeFile( + envPath, + renderDevnetEnvironment({ + adminToken: randomBytes(32).toString('hex'), + operatorSeed: GENESIS_OPERATOR_SEED, + operatorSecret: hex(operatorSecret), + operatorAuthority: hex(operatorAuthority), + sentinelAddress, + sponsorDustAddress: addresses.dust, + }), + { mode: 0o600 } + ); + + console.log( + JSON.stringify({ + setup: 'confirmed', + envFile: envPath, + sentinelAddress, + sponsorDustAddress: addresses.dust, + target: { address: targetAddress, entryPoint: targetEntryPoint }, + fixedPrice: '2', + sponsorShare: '1', + delegatorShare: '1', + next: 'pnpm --filter @midnight-sentinel/sponsor-service dev', + }) + ); + } finally { + await stopWallets(wallets); + } +}; + +await main(); diff --git a/apps/sponsor-service/src/verification/devnet-e2e.ts b/apps/sponsor-service/src/verification/devnet-e2e.ts new file mode 100644 index 0000000..c8419a4 --- /dev/null +++ b/apps/sponsor-service/src/verification/devnet-e2e.ts @@ -0,0 +1,945 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { access, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ContractCall, PreProof, shieldedToken, unshieldedToken } from '@midnight-ntwrk/ledger-v8'; +import { createUnprovenCallTx, deployContract } from '@midnight-ntwrk/midnight-js-contracts'; +import { + DustAddress, + MidnightBech32m, + UnshieldedAddress, +} from '@midnight-ntwrk/wallet-sdk-address-format'; +import { SentinelContract } from '@midnight-sentinel/api'; +import { + createMidnightEnrollmentVerifier, + createHttpMidnightRegistrationProvider, + enrollmentIdentity, + enrollmentSigningBytes, + type EnrollmentPayload, + type SignedEnrollment, +} from '@midnight-sentinel/api/sponsorship/eligibility'; +import { + createMidnightBeneficiarySponsorshipApi, + createMidnightSponsorSponsorshipApi, + createMidnightSponsorshipTarget, + dustPublicKeyToBytes, + nativeNightSponsorshipConfig, +} from '@midnight-sentinel/api/sponsorship/midnight'; +import { + sponsorshipAllowlistHash, + type SponsorshipPolicy, +} from '@midnight-sentinel/api/sponsorship'; +import { + createPrivateState, + deriveSentinelAuthority, + ledger as sentinelLedger, + sentinelContractPrivateStateKey, +} from '@midnight-sentinel/contract'; +import { configureProviders } from '@midnight-sentinel/contract/providers'; +import { + CompositeTargetCompiledContract, + compositeTargetLedger, + type CompositeTargetContractType, +} from '@midnight-sentinel/protocol-verification/composite-sponsorship'; +import { + buildUnfundedWallet, + buildWallet, + getBalancesAndAddresses, + type WalletContext, +} from '@midnight-sentinel/wallet'; +import * as Rx from 'rxjs'; +import { loadConfig } from '../config.js'; +import { EligibilityDatabase } from '../database.js'; +import { MidnightIndexerScanner } from '../indexer.js'; +import { ContractQueueOperator } from '../operator.js'; +import { buildServer } from '../server.js'; +import { EligibilityService } from '../service.js'; + +const GENESIS_DEPLOYER_SEED = `${'0'.repeat(63)}1`; +const GENESIS_OPERATOR_SEED = `${'0'.repeat(63)}2`; +const GENESIS_SPONSOR_SEED = `${'0'.repeat(63)}3`; +const NETWORK = 'undeployed'; +const INDEXER = 'http://127.0.0.1:8088/api/v4/graphql'; +const INDEXER_WS = 'ws://127.0.0.1:8088/api/v4/graphql/ws'; +const NODE = 'http://127.0.0.1:9944'; +const PROOF_SERVER = 'http://127.0.0.1:6300'; +const SHARE = 1n; +const PRICE = 2n; +// Registration is self-funded from DUST generated by the NIGHT being +// registered. Tiny ledger values are useful for contract simulation but do not +// generate a real devnet registration fee in a practical amount of time. +const MINIMUM = 10_000_000_000_000n; +const TTL = () => new Date(Date.now() + 30 * 60_000); +const TIMEOUT = 8 * 60_000; + +const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const repositoryDirectory = path.resolve(packageDirectory, '../..'); +const sentinelZkPath = path.join(repositoryDirectory, 'packages/contract/dist/managed/sentinel'); +const targetZkPath = path.join( + repositoryDirectory, + 'packages/protocol-verification/dist/managed/composite-target' +); + +const config = { + networkId: NETWORK, + indexer: INDEXER, + indexerWS: INDEXER_WS, + node: NODE, + proofServer: PROOF_SERVER, + privateStateStoreName: 'devnet-e2e', + logDir: path.join(packageDirectory, 'logs/devnet-e2e'), + zkConfigPath: sentinelZkPath, + eligibilityService: '', +}; + +type SyncedState = Awaited>; +type ScenarioResult = { name: string; status: 'passed' | 'failed'; detail?: unknown }; + +class TerminalWaitError extends Error {} + +const report: { + runId: string; + startedAt: string; + verdict: string; + stage: string; + baseline?: unknown; + funding?: unknown; + contracts?: unknown; + final?: unknown; + scenarios: ScenarioResult[]; + transactions: Record; + finishedAt?: string; + error?: string; +} = { + runId: randomBytes(8).toString('hex'), + startedAt: new Date().toISOString(), + verdict: 'inconclusive', + stage: 'preflight', + scenarios: [], + transactions: {}, +}; + +const withTimeout = async (label: string, promise: Promise): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out`)), TIMEOUT); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +}; + +const syncedState = (ctx: WalletContext) => + withTimeout( + 'wallet synchronization', + Rx.firstValueFrom(ctx.wallet.state().pipe(Rx.filter((state) => state.isSynced))) + ); + +const waitForState = ( + label: string, + ctx: WalletContext, + predicate: (state: SyncedState) => T | false +) => + withTimeout( + label, + Rx.firstValueFrom( + ctx.wallet.state().pipe( + Rx.filter((state) => state.isSynced), + Rx.map(predicate), + Rx.filter((value): value is T => value !== false) + ) + ) + ); + +const waitFor = async ( + label: string, + action: () => Promise, + timeout = TIMEOUT +): Promise => { + const started = Date.now(); + let lastProgress = started; + let lastError: unknown; + while (Date.now() - started < timeout) { + try { + const value = await action(); + if (value !== false) return value; + } catch (error) { + if (error instanceof TerminalWaitError) throw error; + lastError = error; + } + if (Date.now() - lastProgress >= 15_000) { + console.log( + JSON.stringify({ + waiting: label, + elapsedSeconds: Math.floor((Date.now() - started) / 1_000), + }) + ); + lastProgress = Date.now(); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + throw new Error(`${label} timed out${lastError ? `: ${String(lastError)}` : ''}`); +}; + +const scenario = async (name: string, action: () => Promise) => { + try { + const detail = await action(); + report.scenarios.push({ name, status: 'passed', detail }); + console.log(JSON.stringify({ scenario: name, status: 'passed' })); + return detail; + } catch (error) { + report.scenarios.push({ + name, + status: 'failed', + detail: error instanceof Error ? error.message : String(error), + }); + throw error; + } +}; + +const graphql = async (query: string) => { + const response = await fetch(INDEXER, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query }), + }); + assert(response.ok, `indexer returned HTTP ${response.status}`); + const result = (await response.json()) as { data?: unknown; errors?: unknown }; + assert(!result.errors, `indexer GraphQL error: ${JSON.stringify(result.errors)}`); + return result.data; +}; + +const nodeRpc = async (method: string) => { + const response = await fetch(NODE, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: [] }), + }); + assert(response.ok, `node returned HTTP ${response.status}`); + const result = (await response.json()) as { result?: unknown; error?: unknown }; + assert(!result.error, `node RPC error: ${JSON.stringify(result.error)}`); + return result.result; +}; + +const preflight = async () => { + await Promise.all([ + access(path.join(sentinelZkPath, 'keys/purchaseSponsorship.prover')), + access(path.join(targetZkPath, 'keys/interact.prover')), + ]); + const [health, version, block, proof] = await Promise.all([ + nodeRpc('system_health'), + nodeRpc('system_version'), + graphql('{ block { height } }'), + fetch(`${PROOF_SERVER}/health`).catch(() => fetch(PROOF_SERVER)), + ]); + assert(proof.ok, `proof server returned HTTP ${proof.status}`); + return { network: NETWORK, nodeVersion: version, nodeHealth: health, indexer: block }; +}; + +const addresses = (ctx: WalletContext, seed: string) => getBalancesAndAddresses(ctx.wallet, seed); + +const walletSnapshot = async (ctx: WalletContext, seed: string) => { + const value = await addresses(ctx, seed); + return { + addresses: value.addresses, + balances: { + dust: value.balances.dust.toString(), + shieldedNight: (value.balances.shielded[shieldedToken().raw] ?? 0n).toString(), + unshieldedNight: (value.balances.unshielded[unshieldedToken().raw] ?? 0n).toString(), + }, + }; +}; + +const decodeUnshielded = (address: string) => + UnshieldedAddress.codec.decode(NETWORK, MidnightBech32m.parse(address)); + +const decodeDust = (address: string) => + DustAddress.codec.decode(NETWORK, MidnightBech32m.parse(address)); + +const submitRecipe = async ( + wallet: WalletContext, + recipe: Awaited> +) => { + const signed = await wallet.wallet.signRecipe(recipe, (payload) => + wallet.unshieldedKeystore.signData(payload) + ); + const finalized = await wallet.wallet.finalizeRecipe(signed); + return wallet.wallet.submitTransaction(finalized); +}; + +const fundUnshielded = async ( + funder: WalletContext, + recipients: Array<{ address: string; amount: bigint }> +) => { + const recipe = await funder.wallet.transferTransaction( + [ + { + type: 'unshielded', + outputs: recipients.map(({ address, amount }) => ({ + type: unshieldedToken().raw, + amount, + receiverAddress: decodeUnshielded(address), + })), + }, + ], + { + shieldedSecretKeys: funder.shieldedSecretKeys, + dustSecretKey: funder.dustSecretKey, + }, + { ttl: TTL() } + ); + return submitRecipe(funder, recipe); +}; + +const fundShielded = async (funder: WalletContext, recipient: string, amounts: bigint[]) => { + const state = await syncedState(funder); + void state; + const { ShieldedAddress } = await import('@midnight-ntwrk/wallet-sdk-address-format'); + const decoded = ShieldedAddress.codec.decode(NETWORK, MidnightBech32m.parse(recipient)); + const recipe = await funder.wallet.transferTransaction( + [ + { + type: 'shielded', + outputs: amounts.map((amount) => ({ + type: shieldedToken().raw, + amount, + receiverAddress: decoded, + })), + }, + ], + { + shieldedSecretKeys: funder.shieldedSecretKeys, + dustSecretKey: funder.dustSecretKey, + }, + { ttl: TTL() } + ); + return submitRecipe(funder, recipe); +}; + +const registerWithSponsor = async (delegator: WalletContext, sponsorDustAddress: string) => { + const state = await syncedState(delegator); + const coins = state.unshielded.availableCoins.filter( + (coin) => coin.meta?.registeredForDustGeneration !== true + ); + assert(coins.length > 0, 'delegator has no unregistered NIGHT UTXOs'); + const readiness = await waitFor('registration DUST generation', async () => { + const estimate = await delegator.wallet.estimateRegistration(coins); + const generated = estimate.dustGenerationEstimations.reduce( + (total, value) => total + value.dust.generatedNow, + 0n + ); + return generated >= estimate.fee ? { fee: estimate.fee, generated } : false; + }); + console.log( + JSON.stringify({ + registrationReady: true, + fee: readiness.fee.toString(), + generatedDust: readiness.generated.toString(), + }) + ); + const recipe = await delegator.wallet.registerNightUtxosForDustGeneration( + coins, + delegator.unshieldedKeystore.getPublicKey(), + (payload) => delegator.unshieldedKeystore.signData(payload), + decodeDust(sponsorDustAddress) + ); + const finalized = await delegator.wallet.finalizeRecipe(recipe); + return delegator.wallet.submitTransaction(finalized); +}; + +const deregisterNight = async (delegator: WalletContext) => { + const state = await syncedState(delegator); + const coins = state.unshielded.availableCoins.filter( + (coin) => coin.meta?.registeredForDustGeneration === true + ); + assert(coins.length > 0, 'delegator has no registered NIGHT UTXOs'); + const recipe = await delegator.wallet.deregisterFromDustGeneration( + coins, + delegator.unshieldedKeystore.getPublicKey(), + (payload) => delegator.unshieldedKeystore.signData(payload) + ); + const finalized = await delegator.wallet.finalizeRecipe(recipe); + return delegator.wallet.submitTransaction(finalized); +}; + +const makeEnrollment = async ( + wallet: WalletContext, + seed: string, + sentinelAddress: string, + sponsorDustAddress: string, + nonce = 1n, + expiresAt = new Date(Date.now() + 60 * 60_000) +): Promise => { + const { addresses: walletAddresses } = await addresses(wallet, seed); + const payload: EnrollmentPayload = { + version: 1, + network: NETWORK, + sentinelAddress, + sponsorDustAddress, + nightRewardAddress: walletAddresses.unshielded, + nightVerificationKey: wallet.unshieldedKeystore.getPublicKey(), + shieldedCoinPublicKey: wallet.shieldedSecretKeys.coinPublicKey, + shieldedEncryptionPublicKey: wallet.shieldedSecretKeys.encryptionPublicKey, + nonce: nonce.toString(), + expiresAt: expiresAt.toISOString(), + }; + return { + payload, + signature: wallet.unshieldedKeystore.signData(enrollmentSigningBytes(payload)), + }; +}; + +const postEnrollment = async (baseUrl: string, enrollment: SignedEnrollment) => + fetch(`${baseUrl}/v1/enrollments`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(enrollment), + }); + +const waitForJob = async (baseUrl: string, id: string, expected: 'active' | 'ineligible') => + waitFor(`eligibility job ${id}`, async () => { + const response = await fetch(`${baseUrl}/v1/jobs/${id}`); + if (!response.ok) return false; + const job = (await response.json()) as { status: string; errorCode?: string }; + if (job.status === 'failed') { + throw new TerminalWaitError(`eligibility job failed: ${job.errorCode}`); + } + if (job.status === 'ineligible' && expected !== 'ineligible') { + throw new TerminalWaitError(`eligibility job became ineligible: ${job.errorCode}`); + } + return job.status === expected ? job : false; + }); + +const bytes32 = (value: number) => new Uint8Array(32).fill(value); + +const main = async () => { + const wallets: WalletContext[] = []; + let database: EligibilityDatabase | undefined; + let service: EligibilityService | undefined; + let server: Awaited> | undefined; + const runDirectory = await mkdtemp(path.join(tmpdir(), `sentinel-e2e-${report.runId}-`)); + const reportPath = path.join(runDirectory, 'report.json'); + + try { + report.baseline = await preflight(); + report.stage = 'wallet-initialization'; + + const seeds = { + beneficiary: randomBytes(32).toString('hex'), + eligible: Array.from({ length: 3 }, () => randomBytes(32).toString('hex')), + unregistered: randomBytes(32).toString('hex'), + belowMinimum: randomBytes(32).toString('hex'), + }; + const [deployer, sponsor, operator, beneficiary, ...delegators] = await Promise.all([ + buildWallet(config, GENESIS_DEPLOYER_SEED), + buildWallet(config, GENESIS_SPONSOR_SEED), + buildWallet(config, GENESIS_OPERATOR_SEED), + buildUnfundedWallet(config, seeds.beneficiary), + ...seeds.eligible.map((seed) => buildUnfundedWallet(config, seed)), + buildUnfundedWallet(config, seeds.unregistered), + buildUnfundedWallet(config, seeds.belowMinimum), + ]); + wallets.push(deployer, sponsor, operator, beneficiary, ...delegators); + const eligible = delegators.slice(0, 3); + const unregistered = delegators[3]!; + const belowMinimum = delegators[4]!; + report.baseline = { + ...(report.baseline as object), + wallets: { + deployer: await walletSnapshot(deployer, GENESIS_DEPLOYER_SEED), + sponsor: await walletSnapshot(sponsor, GENESIS_SPONSOR_SEED), + operator: await walletSnapshot(operator, GENESIS_OPERATOR_SEED), + beneficiary: await walletSnapshot(beneficiary, seeds.beneficiary), + eligible: await Promise.all( + eligible.map((wallet, index) => walletSnapshot(wallet, seeds.eligible[index]!)) + ), + unregistered: await walletSnapshot(unregistered, seeds.unregistered), + belowMinimum: await walletSnapshot(belowMinimum, seeds.belowMinimum), + }, + }; + + const delegatorAddresses = await Promise.all( + eligible.map((wallet, index) => addresses(wallet, seeds.eligible[index]!)) + ); + const belowAddress = await addresses(belowMinimum, seeds.belowMinimum); + const requiredUnshieldedFunding = MINIMUM * 2n * BigInt(eligible.length) + MINIMUM - 1n; + const fundingCandidates = await Promise.all( + [ + { name: 'deployer', wallet: deployer }, + { name: 'sponsor', wallet: sponsor }, + { name: 'operator', wallet: operator }, + ].map(async (candidate) => ({ + ...candidate, + balance: + (await syncedState(candidate.wallet)).unshielded.balances[unshieldedToken().raw] ?? 0n, + })) + ); + const nightFunder = fundingCandidates + .sort((left, right) => + left.balance > right.balance ? -1 : left.balance < right.balance ? 1 : 0 + ) + .find(({ balance }) => balance >= requiredUnshieldedFunding); + assert( + nightFunder, + `devnet genesis wallets lack the ${requiredUnshieldedFunding} unshielded NIGHT required by the E2E run; reset the devnet` + ); + report.funding = { + source: nightFunder.name, + available: nightFunder.balance.toString(), + required: requiredUnshieldedFunding.toString(), + }; + report.stage = 'unshielded-funding'; + report.transactions.fundUnshielded = await fundUnshielded(nightFunder.wallet, [ + ...delegatorAddresses.map(({ addresses: value }) => ({ + address: value.unshielded, + amount: MINIMUM * 2n, + })), + { address: belowAddress.addresses.unshielded, amount: MINIMUM - 1n }, + ]); + await Promise.all([ + ...eligible.map((wallet) => + waitForState('delegator NIGHT funding', wallet, (state) => + (state.unshielded.balances[unshieldedToken().raw] ?? 0n) >= MINIMUM ? state : false + ) + ), + waitForState('below-minimum NIGHT funding', belowMinimum, (state) => + (state.unshielded.balances[unshieldedToken().raw] ?? 0n) === MINIMUM - 1n ? state : false + ), + ]); + + const sponsorAddresses = await addresses(sponsor, GENESIS_SPONSOR_SEED); + report.stage = 'dust-registration'; + const registrationWallets = [...eligible, belowMinimum]; + for (let index = 0; index < registrationWallets.length; index += 1) { + report.stage = `dust-registration-${index + 1}`; + report.transactions[`registration${index + 1}`] = await registerWithSponsor( + registrationWallets[index]!, + sponsorAddresses.addresses.dust + ); + } + + report.stage = 'contract-deployment'; + const deployTargetProviders = await configureProviders( + deployer, + config, + path.join(runDirectory, 'target-deploy'), + targetZkPath + ); + const targetDeployment = await deployContract( + deployTargetProviders, + { compiledContract: CompositeTargetCompiledContract } + ); + const targetAddress = targetDeployment.deployTxData.public.contractAddress; + const allowedTargets = [{ address: targetAddress, entryPoint: 'interact' }]; + const policyHash = sponsorshipAllowlistHash(allowedTargets); + const operatorSecret = Uint8Array.from(randomBytes(32)); + const operatorAuthority = deriveSentinelAuthority(operatorSecret); + const sentinelDeployProviders = await configureProviders( + deployer, + config, + path.join(runDirectory, 'sentinel-deploy'), + sentinelZkPath + ); + const deployedSentinel = await SentinelContract.deploy( + sentinelDeployProviders, + nativeNightSponsorshipConfig(sponsor, policyHash, { + sponsorShare: SHARE, + delegatorShare: SHARE, + minimumRegisteredNight: MINIMUM, + initialEligibilityOperator: operatorAuthority, + }) + ); + const sentinelAddress = deployedSentinel.deployedContract!.deployTxData.public.contractAddress; + report.contracts = { sentinelAddress, targetAddress }; + + const initialState = await deployedSentinel.readState(); + assert.equal(initialState.delegatorCount, 0n); + assert.equal(initialState.rewardCursor, 0n); + assert.equal(initialState.sponsorshipPurchases, 0n); + assert.equal(initialState.sponsorshipFixedPrice, PRICE); + assert.equal(initialState.sponsorshipMinimumRegisteredNight, MINIMUM); + + const operatorProviders = await configureProviders( + operator, + config, + path.join(runDirectory, 'operator'), + sentinelZkPath + ); + operatorProviders.privateStateProvider.setContractAddress(sentinelAddress); + await operatorProviders.privateStateProvider.set( + sentinelContractPrivateStateKey, + createPrivateState(operatorSecret) + ); + const operatorContract = await SentinelContract.join(operatorProviders, sentinelAddress); + + const serviceConfig = loadConfig({ + SERVICE_HOST: '127.0.0.1', + SERVICE_PORT: '8089', + SERVICE_DB_PATH: path.join(runDirectory, 'eligibility.sqlite'), + SERVICE_ADMIN_TOKEN: randomBytes(32).toString('hex'), + SERVICE_OPERATOR_SEED: GENESIS_OPERATOR_SEED, + SERVICE_OPERATOR_SECRET: Buffer.from(operatorSecret).toString('hex'), + SERVICE_PRIVATE_STATE_STORE: path.join(runDirectory, 'operator-service'), + SERVICE_REVALIDATE_MS: '1000', + SERVICE_LOG_DIR: path.join(runDirectory, 'logs'), + SERVICE_ZK_CONFIG_PATH: sentinelZkPath, + MIDNIGHT_NETWORK: NETWORK, + MIDNIGHT_INDEXER_HTTP: INDEXER, + MIDNIGHT_INDEXER_WS: INDEXER_WS, + MIDNIGHT_NODE: NODE, + MIDNIGHT_PROOF_SERVER: PROOF_SERVER, + SENTINEL_ADDRESS: sentinelAddress, + SPONSOR_DUST_ADDRESS: sponsorAddresses.addresses.dust, + EXPECTED_OPERATOR_AUTHORITY: Buffer.from(operatorAuthority).toString('hex'), + }); + database = new EligibilityDatabase(serviceConfig.dbPath); + const sponsorDustKey = decodeDust(sponsorAddresses.addresses.dust).data; + const scanner = new MidnightIndexerScanner( + INDEXER, + INDEXER_WS, + database, + sponsorDustKey.toString() + ); + service = new EligibilityService( + database, + scanner, + new ContractQueueOperator(operatorContract), + { + network: NETWORK, + sentinelAddress, + sponsorDustAddress: sponsorAddresses.addresses.dust, + minimumRegisteredNight: MINIMUM, + }, + createMidnightEnrollmentVerifier(NETWORK), + 1_000 + ); + server = buildServer(serviceConfig, service); + service.start(); + report.stage = 'service-startup'; + const baseUrl = await server.listen({ host: '127.0.0.1', port: 0 }); + const ready = await fetch(`${baseUrl}/readyz`); + assert.equal(ready.status, 200); + + report.stage = 'beneficiary-funding'; + const beneficiaryAddresses = await addresses(beneficiary, seeds.beneficiary); + report.transactions.fundBeneficiary = await fundShielded( + deployer, + beneficiaryAddresses.addresses.shielded, + Array.from({ length: 6 }, () => PRICE) + ); + await waitForState('beneficiary shielded funding', beneficiary, (state) => + (state.shielded.balances[shieldedToken().raw] ?? 0n) >= PRICE * 6n ? state : false + ); + + const beneficiarySentinelProviders = await configureProviders( + beneficiary, + config, + path.join(runDirectory, 'beneficiary-sentinel'), + sentinelZkPath + ); + await SentinelContract.join(beneficiarySentinelProviders, sentinelAddress); + const beneficiaryTargetProviders = await configureProviders( + beneficiary, + config, + path.join(runDirectory, 'beneficiary-target'), + targetZkPath + ); + const sponsorProviders = await configureProviders( + sponsor, + config, + path.join(runDirectory, 'sponsor'), + sentinelZkPath + ); + + const beneficiaryApi = createMidnightBeneficiarySponsorshipApi({ + sentinelAddress, + sentinelProviders: beneficiarySentinelProviders, + beneficiary, + proofServer: PROOF_SERVER, + }); + const policy: SponsorshipPolicy = { + sentinelAddress, + sponsorId: dustPublicKeyToBytes(sponsor.dustSecretKey.publicKey), + sponsorDustAddress: sponsorAddresses.addresses.dust, + registrationProvider: createHttpMidnightRegistrationProvider(baseUrl), + policyHash, + allowedTargets, + minTtlMs: 0, + maxTtlMs: 65 * 60_000, + maxFee: 1_000_000_000_000_000_000n, + }; + const sponsorApi = createMidnightSponsorSponsorshipApi({ + policy, + sentinelProviders: sponsorProviders, + sponsor, + }); + + const targetCall = (expiry: bigint) => + createUnprovenCallTx(beneficiaryTargetProviders, { + compiledContract: CompositeTargetCompiledContract, + contractAddress: targetAddress, + circuitId: 'interact', + args: [expiry], + }); + + report.stage = 'scenarios'; + await scenario('empty queue rejects sponsorship without state changes', async () => { + const before = await deployedSentinel.readState(); + const call = await targetCall(BigInt(Math.floor(Date.now() / 1000) + 900)); + const calls = [...(call.private.unprovenTx.intents?.values() ?? [])] + .flatMap((intent) => intent.actions) + .filter((action): action is ContractCall => action instanceof ContractCall); + assert.equal(calls.length, 1); + await assert.rejects( + beneficiaryApi.prepare({ + target: createMidnightSponsorshipTarget({ + targetCall: call, + zkConfigProvider: beneficiaryTargetProviders.zkConfigProvider, + }), + expiresAt: TTL(), + purchaseId: bytes32(0x10), + }), + (error: Error & { code?: string }) => + error.code === 'NO_ELIGIBLE_DELEGATOR' || error.message.includes('NO_ELIGIBLE_DELEGATOR') + ); + const after = await deployedSentinel.readState(); + assert.equal(after.sponsorshipPurchases, before.sponsorshipPurchases); + assert.equal(after.rewardCursor, before.rewardCursor); + return { purchases: after.sponsorshipPurchases.toString() }; + }); + + await scenario('HTTP enrollment defenses', async () => { + const valid = await makeEnrollment( + unregistered, + seeds.unregistered, + sentinelAddress, + sponsorAddresses.addresses.dust + ); + const invalidSignature = await postEnrollment(baseUrl, { + ...valid, + signature: valid.signature.replace(/^../, valid.signature.startsWith('00') ? '01' : '00'), + }); + assert.equal(invalidSignature.status, 400); + const expired = await makeEnrollment( + unregistered, + seeds.unregistered, + sentinelAddress, + sponsorAddresses.addresses.dust, + 2n, + new Date(Date.now() - 1_000) + ); + assert.equal((await postEnrollment(baseUrl, expired)).status, 400); + const wrongCampaign = await makeEnrollment( + unregistered, + seeds.unregistered, + 'ff'.repeat(32), + sponsorAddresses.addresses.dust, + 3n + ); + assert.equal((await postEnrollment(baseUrl, wrongCampaign)).status, 400); + const accepted = await postEnrollment(baseUrl, valid); + assert.equal(accepted.status, 202); + const { jobId } = (await accepted.json()) as { jobId: string }; + await waitForJob(baseUrl, jobId, 'ineligible'); + const unauthorized = await fetch(`${baseUrl}/v1/admin/revalidate`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer wrong' }, + body: '{}', + }); + assert.equal(unauthorized.status, 401); + return { invalidSignature: 400, expired: 400, wrongCampaign: 400, unregistered: 202 }; + }); + + const activeEnrollments: SignedEnrollment[] = []; + await scenario('real indexer enrollment activates three delegators', async () => { + for (let index = 0; index < eligible.length; index += 1) { + const enrollment = await makeEnrollment( + eligible[index]!, + seeds.eligible[index]!, + sentinelAddress, + sponsorAddresses.addresses.dust + ); + activeEnrollments.push(enrollment); + const response = await postEnrollment(baseUrl, enrollment); + assert.equal(response.status, 202); + const { jobId } = (await response.json()) as { jobId: string }; + await waitForJob(baseUrl, jobId, 'active'); + } + const state = await waitFor('three on-chain delegators', async () => { + const current = await deployedSentinel.readState(); + return current.delegatorCount === 3n ? current : false; + }); + const replay = await postEnrollment(baseUrl, activeEnrollments[0]!); + assert.equal(replay.status, 400); + return { delegatorCount: state.delegatorCount.toString(), replayStatus: replay.status }; + }); + + await scenario('below-minimum registration is rejected asynchronously', async () => { + const enrollment = await makeEnrollment( + belowMinimum, + seeds.belowMinimum, + sentinelAddress, + sponsorAddresses.addresses.dust + ); + const response = await postEnrollment(baseUrl, enrollment); + assert.equal(response.status, 202); + const { jobId } = (await response.json()) as { jobId: string }; + const job = await waitForJob(baseUrl, jobId, 'ineligible'); + assert.equal((await deployedSentinel.readState()).delegatorCount, 3n); + return job; + }); + + const beforeSponsor = (await syncedState(sponsor)).shielded.balances[shieldedToken().raw] ?? 0n; + const beforeDelegators = await Promise.all( + eligible.map( + async (wallet) => (await syncedState(wallet)).shielded.balances[shieldedToken().raw] ?? 0n + ) + ); + + const purchase = async (index: number, failFallible: boolean) => { + const expiry = BigInt(Math.floor(Date.now() / 1000) + (failFallible ? 5 : 900)); + const call = await targetCall(expiry); + const prepared = await beneficiaryApi.prepare({ + target: createMidnightSponsorshipTarget({ + targetCall: call, + zkConfigProvider: beneficiaryTargetProviders.zkConfigProvider, + }), + expiresAt: TTL(), + purchaseId: bytes32(0x40 + index), + }); + if (failFallible) { + await waitFor('target expiry', async () => + BigInt(Math.floor(Date.now() / 1000)) > expiry ? true : false + ); + } + const submitted = await sponsorApi.sponsorAndSubmit({ + transaction: prepared.transaction, + }); + report.transactions[`purchase${index}`] = submitted.txId; + return submitted; + }; + + await scenario( + 'real rewards rotate A-B-C-A across successful and fallible execution', + async () => { + const submissions = []; + submissions.push(await purchase(0, false)); + submissions.push(await purchase(1, true)); + submissions.push(await purchase(2, false)); + submissions.push(await purchase(3, false)); + assert.equal(submissions[0]!.status, 'SucceedEntirely'); + assert.equal(submissions[1]!.status, 'FailFallible'); + + const state = await waitFor('four sponsorship receipts', async () => { + const current = await deployedSentinel.readState(); + return current.sponsorshipPurchases === 4n ? current : false; + }); + assert.equal(state.sponsorshipReceipts.size(), 4n); + assert.equal(state.rewardCursor, 1n); + + await waitForState('sponsor rewards', sponsor, (walletState) => + (walletState.shielded.balances[shieldedToken().raw] ?? 0n) === beforeSponsor + 3n + ? walletState + : false + ); + const expected = [2n, 1n, 1n]; + await Promise.all( + eligible.map((wallet, index) => + waitForState(`delegator ${index + 1} reward`, wallet, (walletState) => + (walletState.shielded.balances[shieldedToken().raw] ?? 0n) === + beforeDelegators[index]! + expected[index]! + ? walletState + : false + ) + ) + ); + const targetState = await waitFor('target execution state', async () => { + const publicState = + await beneficiaryTargetProviders.publicDataProvider.queryContractState(targetAddress); + if (!publicState) return false; + const current = compositeTargetLedger(publicState.data); + return current.guaranteedExecutions === 4n ? current : false; + }); + assert.equal(targetState.fallibleExecutions, 3n); + return { + statuses: submissions.map(({ status }) => status), + rotation: activeEnrollments.map((enrollment) => + Buffer.from(enrollmentIdentity(enrollment.payload.nightRewardAddress)).toString('hex') + ), + cursor: state.rewardCursor.toString(), + }; + } + ); + + await scenario('authenticated service removal compacts the live queue', async () => { + const identity = Buffer.from( + enrollmentIdentity(activeEnrollments[1]!.payload.nightRewardAddress) + ).toString('hex'); + const removed = await fetch(`${baseUrl}/v1/admin/delegators/${identity}`, { + method: 'DELETE', + headers: { authorization: `Bearer ${serviceConfig.adminToken}` }, + }); + assert.equal(removed.status, 204); + const state = await waitFor('delegator removal', async () => { + const current = await deployedSentinel.readState(); + return current.delegatorCount === 2n ? current : false; + }); + assert(!state.delegatorPositions.member(Uint8Array.from(Buffer.from(identity, 'hex')))); + return { + delegatorCount: state.delegatorCount.toString(), + cursor: state.rewardCursor.toString(), + }; + }); + + await scenario('finalized deregistration causes automatic stale-entry removal', async () => { + report.transactions.deregisterSelected = await deregisterNight(eligible[2]!); + const staleIdentity = enrollmentIdentity(activeEnrollments[2]!.payload.nightRewardAddress); + const state = await waitFor('stale delegator removal', async () => { + const current = await deployedSentinel.readState(); + return current.delegatorCount === 1n ? current : false; + }); + assert(!state.delegatorPositions.member(staleIdentity)); + return { + removedIdentity: Buffer.from(staleIdentity).toString('hex'), + delegatorCount: state.delegatorCount.toString(), + }; + }); + + const finalPublic = + await beneficiarySentinelProviders.publicDataProvider.queryContractState(sentinelAddress); + assert(finalPublic); + const finalState = sentinelLedger(finalPublic.data); + report.final = { + finalizedBlock: await graphql('{ block { height } }'), + delegatorCount: finalState.delegatorCount.toString(), + rewardCursor: finalState.rewardCursor.toString(), + sponsorshipPurchases: finalState.sponsorshipPurchases.toString(), + receiptCount: finalState.sponsorshipReceipts.size().toString(), + }; + report.verdict = 'confirmed'; + report.stage = 'complete'; + report.finishedAt = new Date().toISOString(); + await writeFile(reportPath, JSON.stringify(report, null, 2), { mode: 0o600 }); + console.log(JSON.stringify({ devnetE2E: 'confirmed', reportPath, report })); + } catch (error) { + report.verdict = report.baseline === undefined ? 'inconclusive-devnet-unavailable' : 'failed'; + report.finishedAt = new Date().toISOString(); + report.error = error instanceof Error ? (error.stack ?? error.message) : String(error); + await writeFile(reportPath, JSON.stringify(report, null, 2), { mode: 0o600 }); + console.error(JSON.stringify({ devnetE2E: report.verdict, reportPath, error: report.error })); + process.exitCode = 1; + } finally { + if (service) await service.stop(); + if (server) await server.close(); + database?.close(); + await Promise.allSettled(wallets.map(({ wallet }) => wallet.stop())); + } +}; + +await main(); diff --git a/apps/sponsor-service/test/database.test.ts b/apps/sponsor-service/test/database.test.ts new file mode 100644 index 0000000..3398b6d --- /dev/null +++ b/apps/sponsor-service/test/database.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; +import type { SignedEnrollment } from '@midnight-sentinel/api/sponsorship/eligibility'; +import { EligibilityDatabase } from '../src/database.js'; + +const databases: EligibilityDatabase[] = []; +afterEach(() => { + for (const database of databases.splice(0)) database.close(); +}); + +const enrollment = (nonce: string): SignedEnrollment => ({ + payload: { + version: 1, + network: 'undeployed', + sentinelAddress: '11'.repeat(32), + sponsorDustAddress: 'dust_undeployed1test', + nightRewardAddress: 'addr_undeployed1test', + nightVerificationKey: 'aa'.repeat(35), + shieldedCoinPublicKey: '22'.repeat(32), + shieldedEncryptionPublicKey: '33'.repeat(32), + nonce, + expiresAt: '2099-01-01T00:00:00.000Z', + }, + signature: '44'.repeat(67), +}); + +describe('EligibilityDatabase', () => { + it('retains the highest enrollment nonce as a tombstone', () => { + const database = new EligibilityDatabase(':memory:'); + databases.push(database); + const identity = '55'.repeat(32); + database.putEnrollment(identity, enrollment('2'), 2n); + assert.throws( + () => database.putEnrollment(identity, enrollment('1'), 1n), + /ENROLLMENT_REPLAYED/ + ); + assert.equal(database.getEnrollment(identity)?.nonce, 2n); + }); + + it('sums only registered NIGHT assigned to the sponsor', () => { + const database = new EligibilityDatabase(':memory:'); + databases.push(database); + database.applyUtxoChanges( + 'address', + [], + [ + { + key: 'a:0', + tokenType: 'night', + value: 4n, + registered: true, + dustKey: 'sponsor', + }, + { + key: 'b:0', + tokenType: 'night', + value: 8n, + registered: true, + dustKey: 'other', + }, + { + key: 'c:0', + tokenType: 'night', + value: 16n, + registered: false, + dustKey: 'sponsor', + }, + ] + ); + assert.equal(database.qualifyingBalance('address', 'night', 'sponsor'), 4n); + database.applyUtxoChanges('address', ['a:0'], []); + assert.equal(database.qualifyingBalance('address', 'night', 'sponsor'), 0n); + }); +}); diff --git a/apps/sponsor-service/test/enrollment.test.ts b/apps/sponsor-service/test/enrollment.test.ts new file mode 100644 index 0000000..664b4be --- /dev/null +++ b/apps/sponsor-service/test/enrollment.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + addressFromKey, + sampleSigningKey, + signData, + signatureVerifyingKey, +} from '@midnight-ntwrk/ledger-v8'; +import { MidnightBech32m, UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format'; +import { + createHttpMidnightRegistrationProvider, + createMidnightEnrollmentVerifier, + enrollmentSigningBytes, + verifyEnrollment, + type EnrollmentPayload, +} from '@midnight-sentinel/api/sponsorship/eligibility'; + +const signedEnrollment = () => { + const signingKey = sampleSigningKey(); + const verificationKey = signatureVerifyingKey(signingKey); + const address = MidnightBech32m.encode( + 'undeployed', + new UnshieldedAddress(Buffer.from(addressFromKey(verificationKey), 'hex')) + ).toString(); + const payload: EnrollmentPayload = { + version: 1, + network: 'undeployed', + sentinelAddress: '11'.repeat(32), + sponsorDustAddress: 'mn_dust_undeployed1test', + nightRewardAddress: address, + nightVerificationKey: verificationKey, + shieldedCoinPublicKey: '22'.repeat(32), + shieldedEncryptionPublicKey: '33'.repeat(32), + nonce: '1', + expiresAt: '2099-01-01T00:00:00.000Z', + }; + return { + payload, + signature: signData(signingKey, enrollmentSigningBytes(payload)), + }; +}; + +describe('Midnight enrollment verification', () => { + it('binds the signed verification key to the unshielded address', () => { + const enrollment = signedEnrollment(); + const verified = verifyEnrollment( + enrollment, + { + network: 'undeployed', + sentinelAddress: enrollment.payload.sentinelAddress, + sponsorDustAddress: enrollment.payload.sponsorDustAddress, + }, + createMidnightEnrollmentVerifier('undeployed') + ); + assert.equal(verified.nightRewardAddress, enrollment.payload.nightRewardAddress); + + const other = signedEnrollment(); + assert.throws(() => + verifyEnrollment( + { + ...enrollment, + payload: { + ...enrollment.payload, + nightRewardAddress: other.payload.nightRewardAddress, + }, + }, + { + network: 'undeployed', + sentinelAddress: enrollment.payload.sentinelAddress, + sponsorDustAddress: enrollment.payload.sponsorDustAddress, + }, + createMidnightEnrollmentVerifier('undeployed') + ) + ); + }); + + it('decodes synchronized HTTP status into bigint values', async () => { + const provider = createHttpMidnightRegistrationProvider( + 'http://eligibility.test/', + async () => + new Response( + JSON.stringify({ + nightRewardAddress: 'address', + dustAddress: 'dust', + registered: true, + nightBalance: '42', + finalizedBlock: '7', + synchronized: true, + }), + { status: 200 } + ) + ); + const status = await provider.getStatus('address'); + assert.equal(status.nightBalance, 42n); + assert.equal(status.finalizedBlock, 7n); + }); +}); diff --git a/apps/sponsor-service/test/service.test.ts b/apps/sponsor-service/test/service.test.ts new file mode 100644 index 0000000..ee6e015 --- /dev/null +++ b/apps/sponsor-service/test/service.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + addressFromKey, + sampleSigningKey, + signData, + signatureVerifyingKey, +} from '@midnight-ntwrk/ledger-v8'; +import { MidnightBech32m, UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format'; +import { + createMidnightEnrollmentVerifier, + enrollmentSigningBytes, + type EnrollmentPayload, +} from '@midnight-sentinel/api/sponsorship/eligibility'; +import { EligibilityDatabase } from '../src/database.js'; +import type { MidnightIndexerScanner } from '../src/indexer.js'; +import type { ContractQueueOperator } from '../src/operator.js'; +import { EligibilityService } from '../src/service.js'; + +const waitFor = async (condition: () => boolean) => { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('Timed out'); +}; + +describe('EligibilityService', () => { + it('enrolls from finalized status and removes after finalized invalidation', async () => { + const signingKey = sampleSigningKey(); + const verificationKey = signatureVerifyingKey(signingKey); + const address = MidnightBech32m.encode( + 'undeployed', + new UnshieldedAddress(Buffer.from(addressFromKey(verificationKey), 'hex')) + ).toString(); + const payload: EnrollmentPayload = { + version: 1, + network: 'undeployed', + sentinelAddress: '11'.repeat(32), + sponsorDustAddress: 'dust', + nightRewardAddress: address, + nightVerificationKey: verificationKey, + shieldedCoinPublicKey: '22'.repeat(32), + shieldedEncryptionPublicKey: '33'.repeat(32), + nonce: '1', + expiresAt: '2099-01-01T00:00:00.000Z', + }; + const enrollment = { + payload, + signature: signData(signingKey, enrollmentSigningBytes(payload)), + }; + let balance = 2n; + const scanner = { + latestFinalizedBlock: async () => 10n, + sync: async () => ({ + nightRewardAddress: address, + dustAddress: balance > 0n ? 'dust' : undefined, + registered: balance > 0n, + nightBalance: balance, + finalizedBlock: 10n, + synchronized: true, + }), + } as unknown as MidnightIndexerScanner; + let currentNonce: bigint | undefined; + let removed = false; + const operator = { + lookup: async () => + currentNonce === undefined ? undefined : { enrollmentNonce: currentNonce }, + add: async (input: { enrollmentNonce: bigint }) => { + currentNonce = input.enrollmentNonce; + }, + update: async (input: { enrollmentNonce: bigint }) => { + currentNonce = input.enrollmentNonce; + }, + remove: async () => { + currentNonce = undefined; + removed = true; + }, + } as unknown as ContractQueueOperator; + const database = new EligibilityDatabase(':memory:'); + const service = new EligibilityService( + database, + scanner, + operator, + { + network: 'undeployed', + sentinelAddress: payload.sentinelAddress, + sponsorDustAddress: 'dust', + minimumRegisteredNight: 1n, + }, + createMidnightEnrollmentVerifier('undeployed'), + 60_000 + ); + + const { jobId } = service.submit(enrollment); + await waitFor(() => service.getJob(jobId)?.status === 'active'); + assert.equal(service.getStatus(address)?.nightBalance, 2n); + + balance = 0n; + await service.revalidate(); + assert.equal(removed, true); + assert.equal(service.getStatus(address)?.registered, false); + database.close(); + }); +}); diff --git a/apps/sponsor-service/test/setup-devnet-config.test.ts b/apps/sponsor-service/test/setup-devnet-config.test.ts new file mode 100644 index 0000000..3dd4d11 --- /dev/null +++ b/apps/sponsor-service/test/setup-devnet-config.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderDevnetEnvironment } from '../src/setup-devnet-config.js'; + +test('renders every generated service identity and campaign value', () => { + const rendered = renderDevnetEnvironment({ + adminToken: 'a'.repeat(64), + operatorSeed: '1'.repeat(64), + operatorSecret: '2'.repeat(64), + operatorAuthority: '3'.repeat(64), + sentinelAddress: '4'.repeat(64), + sponsorDustAddress: 'dust_undeployed1example', + }); + + assert.match(rendered, /SERVICE_ADMIN_TOKEN=a{64}/); + assert.match(rendered, /SERVICE_OPERATOR_SEED=1{64}/); + assert.match(rendered, /SERVICE_OPERATOR_SECRET=2{64}/); + assert.match(rendered, /EXPECTED_OPERATOR_AUTHORITY=3{64}/); + assert.match(rendered, /SENTINEL_ADDRESS=4{64}/); + assert.match(rendered, /SPONSOR_DUST_ADDRESS=dust_undeployed1example/); +}); diff --git a/apps/sponsor-service/tsconfig.build.json b/apps/sponsor-service/tsconfig.build.json new file mode 100644 index 0000000..723ccd9 --- /dev/null +++ b/apps/sponsor-service/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": false, + "incremental": false + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/apps/sponsor-service/tsconfig.json b/apps/sponsor-service/tsconfig.json new file mode 100644 index 0000000..af9a827 --- /dev/null +++ b/apps/sponsor-service/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "noEmit": false, + "isolatedModules": false + } +} diff --git a/apps/ui/package.json b/apps/ui/package.json index beb2467..011aa11 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@midnight-ntwrk/dapp-connector-api": "4.0.1", - "@midnight-ntwrk/onchain-runtime-v2": "^2.0.1", + "@midnight-ntwrk/onchain-runtime-v3": "3.0.0", "@midnight-sentinel/api": "workspace:*", "@midnight-sentinel/contract": "workspace:*", "@radix-ui/react-label": "^2.1.8", diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 28675c6..0daee3b 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -6,7 +6,6 @@ import { import { Toaster } from "@/components/ui/sonner"; import { WalletSidebar } from "@/components/wallet-sidebar"; import { useWallet } from "@/contexts/wallet"; -import { rulesSchema } from "@/lib/schemas"; import { DeployView } from "@/views/Deploy"; import { JoinView } from "@/views/Join"; import { SelectView } from "@/views/Select"; @@ -14,10 +13,24 @@ import { SentinelContract, type SentinelDerivedState } from '@midnight-sentinel/ import { initializeProviders } from '@midnight-sentinel/api/browser'; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { z } from "zod"; import { ContractView } from "./views/Contract"; type ViewState = "select" | "deploy" | "join" | "contract"; +const hex32 = z.string().regex(/^(?:0x)?[0-9a-fA-F]{64}$/).transform((value) => { + const hex = value.startsWith("0x") ? value.slice(2) : value; + return Uint8Array.from({ length: 32 }, (_, index) => + Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16) + ); +}); +const sponsorshipConfigSchema = z.object({ + sponsorId: hex32, + acceptedColor: hex32, + fixedPrice: z.union([z.string(), z.number()]).transform((value) => BigInt(value)), + policyHash: hex32, +}); + function App() { const { wallet, error } = useWallet(); const [view, setView] = useState("select"); @@ -42,7 +55,7 @@ function App() { if (!deployRulesJson.trim()) return null; try { const parsed = JSON.parse(deployRulesJson); - return rulesSchema.safeParse(parsed); + return sponsorshipConfigSchema.safeParse(parsed); } catch { return null; } @@ -61,7 +74,7 @@ function App() { const providers = await initializeProviders(wallet.api); const contract = await SentinelContract.deploy( providers, - { secretKey: new Uint8Array(32).fill(0) } + parsedRules.data ); setActiveContract(contract); setView("contract"); @@ -88,8 +101,7 @@ function App() { const providers = await initializeProviders(wallet.api); const contract = await SentinelContract.join( providers, - joinAddress, - { secretKey: new Uint8Array(32).fill(0) } + joinAddress ); setActiveContract(contract); diff --git a/apps/ui/src/components/rules.tsx b/apps/ui/src/components/rules.tsx index df6240d..4f1896c 100644 --- a/apps/ui/src/components/rules.tsx +++ b/apps/ui/src/components/rules.tsx @@ -1,23 +1,10 @@ -import { SentinelContract, toHex } from '@midnight-sentinel/api'; -import type { Ledger } from '@midnight-sentinel/contract'; - -export const Rules = ({ rules }: { rules: Ledger['rules'] }) => { - if (rules.isEmpty()) { - return ( -
-        No rules found
-      
- ); - } - - return ( -
-      {[...rules].map(([owner, ownerRules], idx) => (
-        
-
Owner: {toHex(owner.bytes)}
-
Rules: {SentinelContract.prettyRules(ownerRules)}
-
- ))} -
- ); -}; +/** + * Legacy placeholder retained for UI source compatibility. Sentinel v1 no + * longer exposes the earlier rules ledger; sponsorship policy is configured + * through the immutable campaign fields instead. + */ +export const Rules = () => ( +
+    Rules moved to the sponsorship campaign policy.
+  
+); diff --git a/apps/ui/src/views/Contract.tsx b/apps/ui/src/views/Contract.tsx index 0ea9b12..be1214d 100644 --- a/apps/ui/src/views/Contract.tsx +++ b/apps/ui/src/views/Contract.tsx @@ -1,5 +1,3 @@ -import { RuleActions } from "@/components/rule-actions"; -import { Rules } from "@/components/rules"; import { Button } from "@/components/ui/button"; import type { SentinelContract, SentinelDerivedState } from "@midnight-sentinel/api"; import { ArrowLeft } from "lucide-react"; @@ -32,14 +30,16 @@ export function ContractView({ {contractState ? (
-

Admin

-

{contractState.adminString}

+

Owner

+

{contractState.owner}

-

Rules

- - +

Sponsorship campaign

+

Enabled: {contractState.sponsorshipEnabled ? "yes" : "no"}

+

Price: {contractState.sponsorshipFixedPrice.toString()}

+

Revenue: {contractState.sponsorshipRevenue.toString()}

+

Purchases: {contractState.sponsorshipPurchases.toString()}

) : ( diff --git a/apps/ui/src/views/Deploy.tsx b/apps/ui/src/views/Deploy.tsx index 340c2b1..5cca8e0 100644 --- a/apps/ui/src/views/Deploy.tsx +++ b/apps/ui/src/views/Deploy.tsx @@ -29,15 +29,15 @@ export function DeployView({

Deploy Contract

- Provide the initial rules for your Sentinel contract. + Provide the immutable sponsorship campaign configuration.

- +