diff --git a/FEATURES.md b/FEATURES.md index 8cb9c96..35472d2 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -10,6 +10,48 @@ 동시에 하나의 feature만 `active` 상태로 둔다. +## DEPLOY-003 — Production ERC-3643 Asset Onboarding + +### Behavior + +- Production onboarding is separated from local Anvil/demo `toolkit-onboard` and + from core deployment. A versioned `corner-store.production-onboarding.json` + declares the exact existing ERC-3643 token, IdentityRegistry/Compliance wiring, + Corner Store registries/adapters, PII-free legal package hash, Elements, Recipes, + Manifest, RecipeBinding[], governance Safe metadata, explicit operator executor, + active venues, RFQ makers, signer delegates and read-only inventory requirements. +- The Toolkit validates the onboarding file with exact-object schemas, rejects + unknown fields, duplicate addresses/ids, unsupported codeHash keys, signer-secret + shaped fields, raw contact PII and incoherent RFQ/inventory relationships. +- `production-onboarding-plan` renders deterministic Element/Recipe/Manifest, + venue, maker, signer schedule and owner-only delayed signer execution calldata plus Safe-compatible unsigned drafts. + It uses collision-free stage IDs, partitions Safe-owner and operator-authority + drafts, includes Safe/required approval/proposal identity on Safe drafts and + explicit executor/proposal identity on operator drafts, separates governance-owner/governance-delayed/operator + authority, refuses output overwrite, and never signs, broadcasts, transfers + tokens or generates ERC-20 approvals. Inventory appears as a read-only + verification dependency before service open. +- `production-onboarding-verify` uses RPC read calls only and fails closed on + unavailable or mismatched ERC-3643 wiring, Identity Registry dependencies, + Element/Recipe registrations, Manifest hash/fields/bindings, ACTIVE Manifest + declarer/approver, governance Safe ownership of safe-owner targets, global/asset/venue pause gates, venue config, TokenPolicyRegistry/RFQAdapter operator authorization, maker approval, + signer delegate activation and inventory balance/allowance minima. Pending signer + authorization is reported but is not production-ready. +- Example onboarding JSON is syntactically valid but uses obvious placeholder + non-live addresses/hashes; issuer/legal/TA evidence remains an external trust + boundary and cannot be inferred from a token address. + +### Verification + +- `npm test --prefix services/toolkit` +- `npm test --prefix services/cli` +- `git diff --check` + +### State + +passing + + ## RFQ-005 — Production RFQ Host Hardening ### Behavior diff --git a/PROGRESS.md b/PROGRESS.md index 1e9a030..2595f6e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -15,6 +15,33 @@ source of truth로 사용한다. ## Completed +- `DEPLOY-003 — Production ERC-3643 Asset Onboarding`: production core deployment과 + local Anvil demo onboarding을 분리한 production-only asset onboarding Toolkit/CLI + surface를 추가했다. `corner-store.production-onboarding.json`은 exact schema로 + ERC-3643 token→IdentityRegistry→Compliance wiring, Identity Registry dependency, + Corner Store registry/adapter/operator addresses, legalPackageHash, Element/Recipe/ + Manifest/RecipeBinding, governance Safe metadata, explicit operator executor, + active venue, RFQ maker/signer delegate and read-only inventory requirements를 받는다. Validator는 unknown fields, + duplicate ids/addresses, unsupported codeHashes key, PII/secret shaped input, + omitted/empty venue or inventory, active RFQ venue without approved maker/signer + delegate/approved-maker inventory, RFQ config without RFQ venue를 fail-closed한다. + `production-onboarding-plan`은 deterministic calldata와 Safe-owner/operator-authority + drafts를 분리한다. Safe drafts에는 Safe/required approval/proposal identity가, + operator drafts에는 explicit executor/proposal identity가 포함되며 immutable output으로 + 쓰고 governance-owner/governance-delayed/operator authority를 분리하고 transfer/approval/ + broadcast를 생성하지 않는다. + `production-onboarding-verify`는 injected reader/JsonRpcProvider로 ERC-3643 wiring, + code hash, registry state, exact Manifest hash/fields/bindings, ACTIVE + declaredBy/approvedBy, governance Safe ownership of safe-owner targets, global/asset/venue pause gates, TokenPolicyRegistry/RFQAdapter operator executor + authorization, maker approval, active signer and inventory balance/allowance를 + read-only 검증한다. Pending signer는 + detail로 보고하지만 ready가 아니다. 검증: `npm test --prefix services/toolkit`, + `npm test --prefix services/cli`, production onboarding example validation, + `git diff --check` 통과. `scripts/check.sh`는 실행했으나 known pre-existing + Solidity formatting blocker(`script/DeployProductionCore.s.sol`, + `script/DemoScenarios.s.sol`)에서 실패했다. Anvil E2E는 기존 untracked + `deployments/` 보호를 위해 이 slice에서 실행하지 않았다. + - `DATA-002 — Provider-Neutral TA/KYC Evidence`: `services/compliance-data`에 provider-neutral TA/KYC evidence coordinator와 replaceable store port를 추가했다. 요청/결과는 subject, optional ONCHAINID identity, asset, request/evidence hashes와 diff --git a/QUALITY.md b/QUALITY.md index 3f3efdd..6d47efb 100644 --- a/QUALITY.md +++ b/QUALITY.md @@ -2,7 +2,7 @@ | Module | Grade | Reason | Required Improvement | | --- | --- | --- | --- | -| Product documentation | B | SDK/reference DEX, 4-Layer, RecipeBinding Manifest, RFQ와 roadmap이 대체로 정합함 | production RFQ/OrderBook와 법률 승인 기준 보강 | +| Product documentation | B+ | SDK/reference DEX, 4-Layer, RecipeBinding Manifest, RFQ와 production ERC-3643 onboarding runbook/CLI 흐름이 정합함 | production RFQ/OrderBook 운영 evidence와 법률 승인 기준 보강 | | Harness / agent workflow | B | HE-001, DOC-001, RFQ-001 상태·검증 이력이 존재함 | PR/CI 결과와 feature state 지속 동기화 | | Product Solidity | B | bounded RecipeBinding Manifest, lifecycle/history, Compliance Core, Router, hardened AMM/RFQ adapter가 컴파일·테스트됨 | production custody/partial fill, LP onboarding과 OrderBook 미구현 | | Foundry tests | B | unit/integration, canonical Uniswap v3 callback, RFQ failure path와 live Anvil E2E 존재 | 추가 adversarial/security tests | @@ -10,7 +10,7 @@ | RFQ reference service / host | A- | EIP-712 SDK, module conformance, local demo HTTP API/CLI, durable coordinator/reference file store와 별도 production host hardening smoke가 존재함 | HA transactional coordinator store, production signer custody, shared limiter/WORM audit/TLS 운영 통합 필요 | | `tools/deploy-v3` | B | profile 단위 테스트와 pinned core artifact integration 존재 | unified production deployment orchestration 추가 | | CI / static analysis | B | GitHub Actions와 local check가 동일한 repository-wide gate를 실행해 Foundry, 서비스 smoke, dashboard와 deploy-v3를 검증함 | medium warning budget과 독립 보안 분석 도입 | -| Security documentation | B | trust boundary, direct venue boundary와 구현 전 보안 규칙을 문서화함 | RFQ/dealer/custody 위협 모델과 production review 체크리스트 보강 | +| Security documentation | B+ | trust boundary, direct venue boundary, RFQ hardening and production onboarding no-broadcast/no-inventory-mutation verifier semantics를 문서화함 | production custody/incident runbook과 external audit checklist 보강 | ## Grade Guide diff --git a/docs/deployment-production.md b/docs/deployment-production.md index 878569c..8b0c273 100644 --- a/docs/deployment-production.md +++ b/docs/deployment-production.md @@ -166,10 +166,39 @@ claims. Those require issuer/TA evidence and separate Safe-reviewed onboarding: 8. run a compliant settlement and expected-rejection smoke test before opening user access. -The current Toolkit validates production core configuration and can package -generic governance proposal envelopes, but it does not yet generate the full -ERC-3643 onboarding calldata sequence. It also cannot infer legal requirements -from a token address. The issuer/legal-approved mapping and reviewed onboarding +The Toolkit now accepts an explicit, versioned production onboarding file such +as +[`services/toolkit/examples/corner-store.production-onboarding.json`](../services/toolkit/examples/corner-store.production-onboarding.json). +It generates deterministic calldata and Safe-compatible unsigned drafts for the +reviewed Element, Recipe, Manifest, venue, maker and signer activation sequence. +The onboarding config must include governance Safe metadata (`safe` and bounded +`requiredApprovals`), an explicit `operatorExecutor`, at least one active +venue and at least one read-only inventory requirement. Active RFQ venues additionally require an approved maker, +a signer delegate for an approved maker and inventory for an approved maker: + +```sh +corner-store production-onboarding-plan corner-store.production-onboarding.json --out safe-onboarding.json +corner-store production-onboarding-verify corner-store.production-onboarding.json --rpc-url https://approved-rpc.example +``` + +The plan command creates an immutable JSON output and refuses to overwrite an +existing file. Safe-owner drafts are restricted to `authority == safe-owner` and carry `chainId`, +`safe`, `requiredApprovals`, a deterministic `proposalId`, `expectedArtifactHash`, +`legalPackageHash` and the stable onboarding identity hash. Operator-authority +steps are exported separately as `operatorTransactions` with `chainId`, explicit +`executor`, deterministic `proposalId`, artifact/legal/onboarding identity and a +label; the tool does not assume the Safe is an operator. It never signs, submits, broadcasts, transfers +assets or generates ERC-20 approvals. Inventory activation is represented as a read-only verification +stage that checks balance, allowance and PII-free risk evidence before service +open. The verify command reads chain state through RPC and fails closed on any +unavailable or mismatched value: ERC-3643 token wiring, Identity Registry +dependencies, governance Safe ownership of safe-owner targets, registered Elements/Recipes, exact Manifest hash/fields/bindings, +ACTIVE Manifest with non-zero declarer/approver, global/asset/venue pause gates, +venue config, maker approval, active signer delegate and inventory minima. A +pending signer delay is reported but is not considered ready. + +The tool still cannot infer legal requirements from a token address. The issuer/ +legal-approved mapping, PII-free evidence hashes and reviewed onboarding transactions are required deployment inputs. ## Deployment Flow @@ -188,24 +217,26 @@ transactions are required deployment inputs. release `sourceCommit` and `contractsHash`, successful dry-run chain ID, successful fork simulation chain ID/block and review timestamp. `production-deploy` rejects missing, stale or mismatched evidence. -8. Build multisig payloads for policy, asset and venue activation. -9. Review payload target addresses, calldata, nonce, chain ID, config hash and - artifact hash. -10. Execute the core deployment through an external Foundry signer. The script's +8. Generate immutable onboarding Safe drafts with `production-onboarding-plan`; + review target addresses, calldata, stage dependencies, chain ID, Safe address, + operator executor, required approvals, deterministic proposal IDs, config hash, + artifact hash, legal package hash and onboarding hash. +9. Execute the core deployment through an external Foundry signer. The script's final deployment phase hands all governed contracts to the preflighted Safe; it activates no asset or venue. -11. Verify bytecode, owners, roles and complete Router/Engine bindings against +10. Verify bytecode, owners, roles and complete Router/Engine bindings against the production artifact. Verification compares each deployed runtime bytecode hash with the hash written by the reviewed deployment script. -12. Prepare and execute separate Safe proposals for the legal-approved policy, - existing ERC-3643 asset and venue activation. -13. Verify registry state, Manifest hash and venue - registration on-chain. -14. Activate legal-approved Manifest, then activate venue, maker, signer and - inventory in staged transactions. -15. Start monitoring, indexer finality tracking, alert routing and incident +11. Execute the reviewed Safe/operator onboarding transactions in order: + Element/Recipe registration, Manifest registration, Manifest approval, venue + registration, RFQ maker approval, signer scheduling and owner-only delayed signer execution. +12. Wait the signer authorization delay before executing the signer activation + transaction; pending authorization is not production-ready. +13. Run `production-onboarding-verify` and stop on any failed read, mismatch, + safe-owner target owner mismatch, missing operator role, pause/suspension or inventory minimum failure. +14. Start monitoring, indexer finality tracking, alert routing and incident response readiness. -16. Record immutable deployment evidence and update the production manifest. +15. Record immutable deployment evidence and update the production manifest. The evidence file has this minimum shape: @@ -268,7 +299,8 @@ trade flow. and allowance evidence exists. 6. Signer activation: signer is authorized under the maker authorizer policy and linked to the maker account. -7. Inventory activation: maker inventory, approvals and risk limits are set +7. Inventory activation: maker inventory and allowances are set by the maker/ + operator outside this tool, then verified read-only with risk-evidence hashes before accepting production RFQ requests. 8. Monitoring activation: event ingestion, finality policy, alerting and incident contacts are live before user-facing enablement. @@ -308,15 +340,16 @@ claim that any mainnet deployment has passed. ## Remaining Production Integrations -The repository now supplies fail-closed core deployment tooling, but a real -launch still needs organization-specific implementations and evidence: +The repository now supplies fail-closed core deployment and ERC-3643 asset +onboarding plan/verify tooling, but a real launch still needs organization- +specific implementations and evidence: 1. Safe creation or adoption, owner verification and an approved `N-of-M` governance policy; 2. issuer/TA confirmation of claim topics, trusted issuers, investor ONCHAINID claims, expiry and revocation behavior; -3. a legal-approved Element → Recipe → Manifest package and Safe transaction - calldata for the exact token; +3. a legal-approved Element → Recipe → Manifest package and reviewed + `production-onboarding-plan` Safe drafts for the exact token; 4. production RFQ pricing, risk, durable maker-scoped nonce, signer custody, inventory and allowance controls; 5. target-chain fork simulation, explorer source verification and deployment diff --git a/docs/security.md b/docs/security.md index be0623c..2d4d965 100644 --- a/docs/security.md +++ b/docs/security.md @@ -144,6 +144,27 @@ venue/adapter에만 실행을 위임하며, 성공 후 stateful compliance `comm - partial fill은 새 quote/adapter version과 별도 accounting/replay 검증 전까지 활성화하지 않는다. +## Production Onboarding Safety + +- `production-onboarding-plan`은 unsigned calldata/Safe draft export 전용이다. + private key, Safe owner signature, broadcast, token transfer 또는 ERC-20 approval을 + 생성하지 않는다. +- onboarding config는 exact schema를 사용하고 unknown field, signer-secret shaped + key/value, raw contact PII와 중복 address/key를 fail-closed한다. Governance Safe, bounded required approvals, + explicit operator executor, at least one active venue and at least + one inventory requirement are mandatory. Active RFQ venues additionally require approved maker, + signer delegate and approved-maker inventory evidence. Legal/TA evidence는 PII-free + hash로만 참조한다. +- `production-onboarding-verify`는 ERC-3643 token→IdentityRegistry→Compliance + wiring, Identity Registry dependencies, Element/Recipe registry state, exact + Manifest hash/fields/bindings, global/asset/venue pause gates, maker approval, + governance Safe ownership of safe-owner targets, active signer delegate, + operator executor authorization on TokenPolicyRegistry and RFQAdapter, and inventory balance/allowance minimum을 read-only로 확인한다. Unavailable RPC/read mismatch, safe-owner target owner mismatch, pending signer authorization, paused + global/asset/venue state or missing inventory evidence is not production-ready. +- Inventory mutation은 maker/operator custody process의 책임이다. Corner Store + tooling은 inventory-before-service-open 조건을 plan dependency와 verifier evidence로 + 표현할 뿐 transfer/approval/custody transaction을 합성하지 않는다. + ## Logging - RFQ host audit events must hash principals, request bodies and idempotency keys; diff --git a/docs/testing.md b/docs/testing.md index 707658f..02a30b2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -60,8 +60,10 @@ idempotent replay를 검증한다. Backend smoke는 injected scenario loading, ephemeral HTTP server의 health/quote API, fixed-rate pricing, maker signature, monotonic nonce와 numeric amount -거부를 검증한다. CLI smoke는 backend quote request path와 기존 -quote-file/서명 검증 경로를 함께 검증한다. +거부를 검증한다. CLI smoke는 backend quote request path, 기존 quote-file/서명 +검증 경로, `production-onboarding-plan --out` immutable export/overwrite +refusal and `production-onboarding-verify` fail-closed nonzero behavior를 함께 +검증한다. Standalone SDK integration smoke: @@ -74,8 +76,15 @@ Toolkit smoke는 unified `create`가 생성하는 `library-only`, `reference-service`, `existing-backend` 세 mode의 manifest, `.env.example`, vendored `vendor/rfq-service`, optional Docker files, overwrite refusal과 standalone package scripts(`doctor`, `deploy`, `verify`, `test:module`)를 -검증한다. SDK-002 문서 또는 packaging 변경에서는 CLI help, `doctor`, dry-run -`deploy`, `verify`/preflight와 `test-module` command path도 별도로 확인한다. +검증한다. Production onboarding smoke는 exact schema/unknown-field rejection, +PII/secret rejection, deterministic Element/Recipe/Manifest/Venue/RFQ calldata, +Safe/operator draft governance/proposal metadata, authority partition, explicit +operator executor metadata, safe-owner target owner checks, stage dependency including governance-delayed signer execution, mandatory active venue/inventory +gates, RFQ activation coherence, AMM-only coherent mode, read-only +inventory stage, ACTIVE Manifest field verification, pause gate verification and +pending-vs-active signer and safe-owner target owner mismatch/unavailable and operator role mismatch/unavailable fail-closed behavior를 포함한다. SDK-002 문서 또는 packaging 변경에서는 CLI help, `doctor`, +dry-run `deploy`, `verify`/preflight와 `test-module` command path도 별도로 +확인한다. Generated consumer projects should keep this local gate: diff --git a/scripts/check.sh b/scripts/check.sh index cc55784..064889e 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -45,36 +45,36 @@ echo "==> Running production RFQ host hardening smoke test" npm test ) -echo "==> Running CLI build + smoke test" +echo "==> Running Toolkit config build + smoke test" ( - cd services/cli + cd services/toolkit if [ ! -x node_modules/.bin/tsc ]; then npm ci fi npm test ) -echo "==> Running RFQ demo backend build + smoke test" +echo "==> Running CLI build + smoke test" ( - cd services/rfq-demo-backend + cd services/cli if [ ! -x node_modules/.bin/tsc ]; then npm ci fi npm test ) -echo "==> Running public-testnet RFQ demo build + smoke test" +echo "==> Running RFQ demo backend build + smoke test" ( - cd services/testnet-rfq-demo + cd services/rfq-demo-backend if [ ! -x node_modules/.bin/tsc ]; then npm ci fi npm test ) -echo "==> Running Toolkit config build + smoke test" +echo "==> Running public-testnet RFQ demo build + smoke test" ( - cd services/toolkit + cd services/testnet-rfq-demo if [ ! -x node_modules/.bin/tsc ]; then npm ci fi diff --git a/services/cli/README.md b/services/cli/README.md index f7b6e36..3aba94c 100644 --- a/services/cli/README.md +++ b/services/cli/README.md @@ -126,6 +126,30 @@ plan. Production orchestration, signer policy and ownership handoff remain separ `toolkit-test` always runs the full repository check rather than a partial user-selected scope, so Solidity, SDK, CLI, API and dashboard regressions are not hidden. +Production deployment/onboarding commands are signer-free unless `production-deploy` +is explicitly confirmed with an external Foundry signer: + +```sh +corner-store production-source-hash +corner-store production-plan corner-store.production.json +corner-store production-preflight corner-store.production.json --rpc-url https://approved-rpc.example +corner-store production-deploy corner-store.production.json --ledger --confirm production-deploy +corner-store production-verify corner-store.production.json --rpc-url https://approved-rpc.example +corner-store production-onboarding-plan corner-store.production-onboarding.json --out safe-onboarding.json +corner-store production-onboarding-verify corner-store.production-onboarding.json --rpc-url https://approved-rpc.example +``` + +`production-onboarding-plan` writes an immutable unsigned plan/Safe draft bundle and +refuses to overwrite an existing file. Safe-owner drafts include chain id, Safe address, required approvals, +deterministic proposal id, artifact hash, legal package hash and onboarding hash. +Operator-authority drafts are exported separately with explicit executor metadata; +the CLI does not assume the Safe is an operator. It stages governance-owner calls, +operator calls and owner-only delayed signer execution separately. It never broadcasts, signs, transfers tokens or +generates approvals; inventory appears as read-only verification evidence. +`production-onboarding-verify` fails closed on unavailable RPC reads, wiring/state +mismatches, safe-owner target owner mismatch, missing operator role, paused/suspended registry gates, pending signer +authorization or missing inventory balance/allowance. + Admin commands (`onboard`, `manifest`, `attest`, `investor-setup`, `maker`) default to the operator (account 0). `buy` defaults to the buyer (account 1). The asset is selected when the stack is deployed. `onboard --profile` and diff --git a/services/cli/src/commands.ts b/services/cli/src/commands.ts index 117f322..dc35102 100644 --- a/services/cli/src/commands.ts +++ b/services/cli/src/commands.ts @@ -3,6 +3,8 @@ import {createHash} from "crypto"; import { copyFileSync, existsSync, + openSync, + closeSync, lstatSync, mkdirSync, mkdtempSync, @@ -13,7 +15,7 @@ import { writeFileSync } from "fs"; import {tmpdir} from "os"; -import {formatEther, keccak256, NonceManager, parseEther} from "ethers"; +import {formatEther, JsonRpcProvider, keccak256, NonceManager, parseEther, Contract} from "ethers"; import {dirname, relative, resolve} from "path"; import {enabledEngineSpec, loadConfig, simulateConfig, writeDefaultConfig} from "../../toolkit/src/config"; @@ -32,6 +34,12 @@ import { validateProductionConfig } from "../../toolkit/src/production"; import {scaffoldRFQIntegration} from "../../toolkit/src/scaffold"; +import { + OnboardingReader, + createProductionOnboardingPlan, + loadProductionOnboardingConfig, + verifyProductionOnboarding +} from "../../toolkit/src/production-onboarding"; import { ACQ_SOURCE_ABI, @@ -67,7 +75,6 @@ import { } from "./config"; import { AbiCoder, - Contract, Interface, TypedDataEncoder, decodeBytes32String, @@ -318,6 +325,35 @@ export function cmdToolkitTest(): void { execFileSync("scripts/check.sh", [], {cwd: repoRoot, stdio: "inherit"}); } + +export function cmdProductionOnboardingPlan(path = "corner-store.production-onboarding.json", opts: {out?: string}): void { + const config = loadProductionOnboardingConfig(resolve(process.cwd(), path)); + const plan = createProductionOnboardingPlan(config, new Date().toISOString()); + const output = opts.out ? resolve(process.cwd(), opts.out) : undefined; + if (output) { + writeImmutableJson(output, plan); + console.log(`production onboarding plan written to ${output}`); + } else { + console.log(JSON.stringify(plan, null, 2)); + } +} + +export async function cmdProductionOnboardingVerify(path = "corner-store.production-onboarding.json", opts: GlobalOpts & {rpcUrl?: string}): Promise { + rejectProductionRawKey(opts); + const config = loadProductionOnboardingConfig(resolve(process.cwd(), path)); + const rpcUrl = opts.rpcUrl ?? explicitlyProvidedGlobalRpc() ?? process.env.CORNER_STORE_RPC_URL; + if (!rpcUrl) throw new CliError("production-onboarding-verify requires --rpc-url or CORNER_STORE_RPC_URL"); + const provider = new JsonRpcProvider(rpcUrl); + try { + const reader = new EthersOnboardingReader(provider); + const result = await verifyProductionOnboarding(config, reader); + console.log(JSON.stringify(result, null, 2)); + if (!result.ready) process.exitCode = 1; + } finally { + provider.destroy(); + } +} + export function cmdProductionPlan(path = "corner-store.production.json", opts: GlobalOpts & {rpcUrl?: string}): void { rejectProductionRawKey(opts); const config = productionConfigWithRuntimeOverrides(path, opts); @@ -440,6 +476,45 @@ export async function cmdProductionVerify(path = "corner-store.production.json", if (!result.ready) process.exitCode = 1; } + +class EthersOnboardingReader implements OnboardingReader { + constructor(private readonly provider: JsonRpcProvider) {} + + async chainId(): Promise { + const network = await this.provider.getNetwork(); + return Number(network.chainId); + } + + async getCode(address: string): Promise { + return this.provider.getCode(address); + } + + async call(address: string, abi: string[], functionName: string, args: unknown[] = []): Promise { + const contract = new Contract(address, abi, this.provider); + return contract.getFunction(functionName)(...(args as any[])); + } + + async balanceOf(token: string, holder: string): Promise { + return new Contract(token, ["function balanceOf(address) view returns (uint256)"], this.provider).balanceOf(holder); + } + + async allowance(token: string, owner: string, spender: string): Promise { + return new Contract(token, ["function allowance(address,address) view returns (uint256)"], this.provider).allowance(owner, spender); + } +} + +function writeImmutableJson(path: string, value: unknown): void { + mkdirSync(dirname(path), {recursive: true}); + let fd: number | undefined; + try { + fd = openSync(path, "wx"); + writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`); + } catch (err: any) { + throw new CliError(`cannot write immutable output ${path}: ${err.message}`); + } finally { + if (fd !== undefined) closeSync(fd); + } +} function productionConfigWithRuntimeOverrides(path: string, opts: GlobalOpts & {rpcUrl?: string}): ProductionConfig { const config = loadProductionConfig(resolve(process.cwd(), path)); const rpcUrl = opts.rpcUrl ?? explicitlyProvidedGlobalRpc() ?? process.env.CORNER_STORE_RPC_URL; diff --git a/services/cli/src/index.ts b/services/cli/src/index.ts index 9aa041f..8a7bf0e 100644 --- a/services/cli/src/index.ts +++ b/services/cli/src/index.ts @@ -161,6 +161,20 @@ program .option("--rpc-url ", "runtime RPC URL override (also supports CORNER_STORE_RPC_URL)") .action(run((path, opts, command) => cmd.cmdProductionPreflight(path, {...command.optsWithGlobals(), rpcUrl: opts.rpcUrl}))); +program + .command("production-onboarding-plan") + .description("render unsigned production ERC-3643 asset onboarding plan and Safe drafts") + .argument("[path]", "production onboarding config JSON path", "corner-store.production-onboarding.json") + .option("--out ", "immutable output JSON path") + .action(run((path, opts) => cmd.cmdProductionOnboardingPlan(path, {out: opts.out}))); + +program + .command("production-onboarding-verify") + .description("fail-closed RPC verification for production ERC-3643 asset onboarding post-state") + .argument("[path]", "production onboarding config JSON path", "corner-store.production-onboarding.json") + .option("--rpc-url ", "runtime RPC URL override (also supports CORNER_STORE_RPC_URL)") + .action(run((path, opts, command) => cmd.cmdProductionOnboardingVerify(path, {...command.optsWithGlobals(), rpcUrl: opts.rpcUrl}))); + program .command("production-deploy") .description("run the production Foundry deploy script with frozen evidence and an external signer") diff --git a/services/cli/test/smoke.ts b/services/cli/test/smoke.ts index 12f071a..0e82b2a 100644 --- a/services/cli/test/smoke.ts +++ b/services/cli/test/smoke.ts @@ -28,7 +28,7 @@ import { rfqDomain, writeQuoteFile } from "../src/rfq"; -import {cmdProductionDeploy, cmdProductionPlan} from "../src/commands"; +import {cmdProductionDeploy, cmdProductionOnboardingPlan, cmdProductionOnboardingVerify, cmdProductionPlan} from "../src/commands"; const CHAIN_ID = 31337; const RFQ_VERIFYING_CONTRACT = "0x7969c5eD335650692Bc04293B07F5BF2e7A673C0"; @@ -155,6 +155,79 @@ async function main() { assert(planOutput.includes("https://secure-rpc.example"), "production-plan supports explicit RPC runtime override"); assert(!planOutput.includes("--ledger") && !planOutput.includes("--account"), "production-plan is signer-free"); assertThrows(() => cmdProductionPlan("corner-store.production.json", {key: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"}), "production-plan rejects raw key"); + const onboardingPath = join(consumerRoot, "corner-store.production-onboarding.json"); + writeFileSync(onboardingPath, `${JSON.stringify({ + schemaVersion: 1, + chainId: 1, + configHash: "sha256:" + "a".repeat(64), + artifactHash: "sha256:" + "b".repeat(64), + legalPackageHash: "sha256:" + "c".repeat(64), + governance: {safe: "0x8888888888888888888888888888888888888888", requiredApprovals: 2, operatorExecutor: "0x5555555555555555555555555555555555555555"}, + addresses: { + token: "0x1000000000000000000000000000000000000001", + identityRegistry: "0x1000000000000000000000000000000000000002", + compliance: "0x1000000000000000000000000000000000000003", + topicsRegistry: "0x1000000000000000000000000000000000000004", + issuersRegistry: "0x1000000000000000000000000000000000000005", + identityStorage: "0x1000000000000000000000000000000000000006", + elementRegistry: "0x1000000000000000000000000000000000000007", + recipeRegistry: "0x1000000000000000000000000000000000000008", + tokenPolicyRegistry: "0x1000000000000000000000000000000000000009", + operatorRegistry: "0x1000000000000000000000000000000000000010", + venueRegistry: "0x1000000000000000000000000000000000000011", + rfqAdapter: "0x1000000000000000000000000000000000000012", + makerAuthorizer: "0x1000000000000000000000000000000000000013" + }, + elements: [{elementId: "0x" + "01".repeat(32), implementation: "0x2000000000000000000000000000000000000001"}], + recipes: [{recipeId: 1, version: 2, implementation: "0x2000000000000000000000000000000000000002"}], + manifest: {issuanceRecipeId: 1, issuanceRecipeVersion: 2, fundRecipeId: 0, enabledResalePaths: 1, supportedEngines: 5, stateScopeId: 7, factsPacked: "1", coverageScope: "3", fullManifestHash: "0x" + "02".repeat(32)}, + recipeBindings: [{recipeId: 1, recipeVersion: 2, mode: "REQUIRED_BLOCKING", pathGroupId: 0, priority: 100}], + venues: [{venue: "0x3000000000000000000000000000000000000001", venueType: "RFQ", adapter: "0x1000000000000000000000000000000000000012", target: "0x3000000000000000000000000000000000000002", operator: "0x5555555555555555555555555555555555555555", custody: "NONE", active: true}], + rfq: {makers: [{maker: "0x4000000000000000000000000000000000000001", approved: true}], signerDelegates: [{maker: "0x4000000000000000000000000000000000000001", delegate: "0x4000000000000000000000000000000000000002", reasonHash: "0x" + "03".repeat(32)}]}, + inventory: [{token: "0x1000000000000000000000000000000000000001", holder: "0x4000000000000000000000000000000000000001", spender: "0x1000000000000000000000000000000000000012", minBalance: "100", minAllowance: "50", riskEvidenceHash: "0x" + "04".repeat(32)}] + }, null, 2)} +`); + const onboardingOut = join(consumerRoot, "safe-onboarding.json"); + let onboardingLog = ""; + console.log = (value?: any) => { onboardingLog += String(value); }; + try { + cmdProductionOnboardingPlan("corner-store.production-onboarding.json", {out: onboardingOut}); + } finally { + console.log = previousLog; + } + const onboardingPlan = JSON.parse(readFileSync(onboardingOut, "utf8")); + assert(onboardingPlan.schema === "corner-store-production-onboarding", "production-onboarding-plan writes schema"); + assert(onboardingPlan.safeTransactions.length === onboardingPlan.transactions.filter((tx: any) => tx.authority === "safe-owner").length && onboardingPlan.operatorTransactions.length === onboardingPlan.transactions.filter((tx: any) => tx.authority === "operator").length, "production-onboarding-plan partitions Safe and operator drafts by authority"); + assert(onboardingLog.includes("production onboarding plan written"), "production-onboarding-plan logs immutable output path"); + assertThrows(() => cmdProductionOnboardingPlan("corner-store.production-onboarding.json", {out: onboardingOut}), "production-onboarding-plan rejects overwrite"); + + const rpc = createServer((req, res) => { + let body = ""; + req.on("data", (chunk) => { body += chunk; }); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + const method = parsed.method; + const result = method === "eth_chainId" ? "0x1" : method === "eth_getCode" ? "0x" : "0x"; + res.writeHead(200, {"content-type": "application/json"}); + res.end(JSON.stringify({jsonrpc: "2.0", id: parsed.id, result})); + }); + }); + await new Promise((resolve) => rpc.listen(0, "127.0.0.1", resolve)); + const rpcAddress = rpc.address(); + if (!rpcAddress || typeof rpcAddress === "string") throw new Error("onboarding RPC test server did not bind"); + const oldExitCode = process.exitCode; + process.exitCode = undefined; + let verifyLog = ""; + console.log = (value?: any) => { verifyLog += String(value); }; + try { + await cmdProductionOnboardingVerify("corner-store.production-onboarding.json", {rpcUrl: `http://127.0.0.1:${rpcAddress.port}`}); + } finally { + console.log = previousLog; + await new Promise((resolve, reject) => rpc.close((err) => (err ? reject(err) : resolve()))); + } + assert(process.exitCode === 1, "production-onboarding-verify sets nonzero on fail-closed mismatch"); + assert(JSON.parse(verifyLog.slice(verifyLog.indexOf("{"))).ready === false, "production-onboarding-verify prints not-ready result"); + process.exitCode = oldExitCode; await assertRejects( () => cmdProductionDeploy("corner-store.production.json", {ledger: true, confirm: "wrong"}), "production-deploy requires explicit confirmation before RPC preflight" diff --git a/services/toolkit/examples/corner-store.production-onboarding.json b/services/toolkit/examples/corner-store.production-onboarding.json new file mode 100644 index 0000000..b262450 --- /dev/null +++ b/services/toolkit/examples/corner-store.production-onboarding.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 1, + "chainId": 42161, + "configHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "artifactHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "legalPackageHash": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "governance": { + "safe": "0x8888888888888888888888888888888888888888", + "requiredApprovals": 2, + "operatorExecutor": "0x5555555555555555555555555555555555555555" + }, + "addresses": { + "token": "0x1000000000000000000000000000000000000001", + "identityRegistry": "0x1000000000000000000000000000000000000002", + "compliance": "0x1000000000000000000000000000000000000003", + "topicsRegistry": "0x1000000000000000000000000000000000000004", + "issuersRegistry": "0x1000000000000000000000000000000000000005", + "identityStorage": "0x1000000000000000000000000000000000000006", + "elementRegistry": "0x1000000000000000000000000000000000000007", + "recipeRegistry": "0x1000000000000000000000000000000000000008", + "tokenPolicyRegistry": "0x1000000000000000000000000000000000000009", + "operatorRegistry": "0x1000000000000000000000000000000000000010", + "venueRegistry": "0x1000000000000000000000000000000000000011", + "rfqAdapter": "0x1000000000000000000000000000000000000012", + "makerAuthorizer": "0x1000000000000000000000000000000000000013" + }, + "codeHashes": { + "token": "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "elements": [ + { + "elementId": "0x0101010101010101010101010101010101010101010101010101010101010101", + "implementation": "0x2000000000000000000000000000000000000001" + } + ], + "recipes": [ + { + "recipeId": 1, + "version": 2, + "implementation": "0x2000000000000000000000000000000000000002" + } + ], + "manifest": { + "issuanceRecipeId": 1, + "issuanceRecipeVersion": 2, + "fundRecipeId": 0, + "enabledResalePaths": 1, + "supportedEngines": 5, + "stateScopeId": 7, + "factsPacked": "1", + "coverageScope": "3", + "fullManifestHash": "0x0202020202020202020202020202020202020202020202020202020202020202" + }, + "recipeBindings": [ + { + "recipeId": 1, + "recipeVersion": 2, + "mode": "REQUIRED_BLOCKING", + "pathGroupId": 0, + "priority": 100 + } + ], + "venues": [ + { + "venue": "0x3000000000000000000000000000000000000001", + "venueType": "RFQ", + "adapter": "0x1000000000000000000000000000000000000012", + "target": "0x3000000000000000000000000000000000000002", + "operator": "0x5555555555555555555555555555555555555555", + "custody": "NONE", + "active": true + } + ], + "rfq": { + "makers": [ + { + "maker": "0x4000000000000000000000000000000000000001", + "approved": true + } + ], + "signerDelegates": [ + { + "maker": "0x4000000000000000000000000000000000000001", + "delegate": "0x4000000000000000000000000000000000000002", + "reasonHash": "0x0303030303030303030303030303030303030303030303030303030303030303" + } + ] + }, + "inventory": [ + { + "token": "0x1000000000000000000000000000000000000001", + "holder": "0x4000000000000000000000000000000000000001", + "spender": "0x1000000000000000000000000000000000000012", + "minBalance": "1000000000000000000000", + "minAllowance": "1000000000000000000000", + "riskEvidenceHash": "0x0404040404040404040404040404040404040404040404040404040404040404" + } + ] +} diff --git a/services/toolkit/package-lock.json b/services/toolkit/package-lock.json index 9876602..53be3e0 100644 --- a/services/toolkit/package-lock.json +++ b/services/toolkit/package-lock.json @@ -8,6 +8,9 @@ "name": "@corner-store/toolkit", "version": "0.1.0", "license": "GPL-3.0-or-later", + "dependencies": { + "ethers": "^6.13.5" + }, "devDependencies": { "@types/node": "^22.20.1", "typescript": "^5.7.3" @@ -16,6 +19,36 @@ "node": ">=18" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -26,6 +59,61 @@ "undici-types": "~6.21.0" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -46,6 +134,27 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/services/toolkit/package.json b/services/toolkit/package.json index 2738a6e..e0532d4 100644 --- a/services/toolkit/package.json +++ b/services/toolkit/package.json @@ -30,5 +30,8 @@ "devDependencies": { "@types/node": "^22.20.1", "typescript": "^5.7.3" + }, + "dependencies": { + "ethers": "^6.13.5" } } diff --git a/services/toolkit/src/index.ts b/services/toolkit/src/index.ts index f357fbb..31e122b 100644 --- a/services/toolkit/src/index.ts +++ b/services/toolkit/src/index.ts @@ -8,3 +8,5 @@ export * from "./production"; export * from "./proposal"; export * from "./scaffold"; export * from "./templates"; + +export * from "./production-onboarding"; diff --git a/services/toolkit/src/production-onboarding.ts b/services/toolkit/src/production-onboarding.ts new file mode 100644 index 0000000..57dcd8e --- /dev/null +++ b/services/toolkit/src/production-onboarding.ts @@ -0,0 +1,723 @@ +import {createHash} from "crypto"; +import {readFileSync} from "fs"; +import {resolve} from "path"; +import {Interface, keccak256} from "ethers"; + +export const PRODUCTION_ONBOARDING_SCHEMA_VERSION = 1; +export const POLICY_STATUS = {UNKNOWN: 0, UNREGULATED: 1, ACTIVE: 2, SUSPENDED: 3, PROPOSED: 4, RETIRED: 5} as const; +export const VENUE_TYPE = {AMM: 0, ORDER_BOOK: 1, RFQ: 2} as const; +export const CUSTODY_MODEL = {NONE: 0, POOL: 1, ESCROW: 2, OPERATOR: 3} as const; +export const RECIPE_BINDING_MODE = {REQUIRED_BLOCKING: 0, PATH_OPTION: 1, FLAG_ONLY: 2} as const; + +type GovernanceStage = "governance-owner" | "operator" | "governance-delayed" | "verification"; + +export interface ProductionOnboardingConfig { + schemaVersion: number; + chainId: number; + configHash: string; + artifactHash: string; + legalPackageHash: string; + governance: {safe: string; requiredApprovals: number; operatorExecutor: string}; + addresses: { + token: string; + identityRegistry: string; + compliance: string; + topicsRegistry: string; + issuersRegistry: string; + identityStorage: string; + elementRegistry: string; + recipeRegistry: string; + tokenPolicyRegistry: string; + operatorRegistry: string; + venueRegistry: string; + rfqAdapter?: string; + makerAuthorizer?: string; + }; + codeHashes?: Record; + elements: {elementId: string; implementation: string}[]; + recipes: {recipeId: number; version: number; implementation: string}[]; + manifest: ManifestInput; + recipeBindings: RecipeBindingInput[]; + venues: VenueInput[]; + rfq?: { + makers?: {maker: string; approved: boolean}[]; + signerDelegates?: {maker: string; delegate: string; reasonHash: string}[]; + }; + inventory: InventoryRequirement[]; +} + +export interface ManifestInput { + issuanceRecipeId: number; + issuanceRecipeVersion: number; + fundRecipeId: number; + enabledResalePaths: number; + supportedEngines: number; + stateScopeId: number; + factsPacked: string; + coverageScope: string; + fullManifestHash: string; +} + +export interface RecipeBindingInput { + recipeId: number; + recipeVersion: number; + mode: keyof typeof RECIPE_BINDING_MODE | number; + pathGroupId: number; + priority: number; +} + +export interface VenueInput { + venue: string; + venueType: keyof typeof VENUE_TYPE | number; + adapter: string; + target: string; + operator: string; + custody: keyof typeof CUSTODY_MODEL | number; + active: boolean; +} + +export interface InventoryRequirement { + token: string; + holder: string; + spender?: string; + minBalance: string; + minAllowance?: string; + riskEvidenceHash: string; +} + +export interface OnboardingTx { + id: string; + stage: GovernanceStage; + description: string; + to: string; + value: "0"; + data: string; + operation: 0; + dependsOn: string[]; + earliestExecution?: string; + authority: "safe-owner" | "operator" | "read-only"; +} + +export interface ProductionOnboardingPlan { + schema: "corner-store-production-onboarding"; + schemaVersion: 1; + chainId: number; + configHash: string; + artifactHash: string; + legalPackageHash: string; + onboardingHash: string; + generatedAt: string; + warnings: string[]; + transactions: OnboardingTx[]; + inventoryRequirements: InventoryRequirement[]; + safeTransactions: SafeOnboardingTransaction[]; + operatorTransactions: OperatorOnboardingTransaction[]; +} + +export interface SafeOnboardingTransaction extends OnboardingTx { + origin: "corner-store-toolkit"; + chainId: number; + safe: string; + requiredApprovals: number; + proposalId: string; + expectedArtifactHash: string; + legalPackageHash: string; + onboardingHash: string; + safeTxLabel: string; +} + +export interface OperatorOnboardingTransaction extends OnboardingTx { + origin: "corner-store-toolkit"; + chainId: number; + executor: string; + proposalId: string; + expectedArtifactHash: string; + legalPackageHash: string; + onboardingHash: string; + operatorTxLabel: string; +} + +export interface ProductionOnboardingCheck {name: string; pass: boolean; detail: string} +export interface ProductionOnboardingVerification {ready: boolean; checks: ProductionOnboardingCheck[]} + +export interface OnboardingReader { + chainId(): Promise; + getCode(address: string): Promise; + call(address: string, abi: string[], functionName: string, args?: unknown[]): Promise; + balanceOf(token: string, holder: string): Promise; + allowance(token: string, owner: string, spender: string): Promise; +} + +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const HASH32 = /^0x[0-9a-fA-F]{64}$/; +const SHA256 = /^sha256:[0-9a-f]{64}$/; +const SECRET_KEY = /(private_?key|mnemonic|seed|secret|signer_?secret|signer_?key|raw_?key|passport|ssn|dob|birth|email|phone|name|addressLine)/i; +const SECRET_VALUE = /^0x[0-9a-fA-F]{64}$/; +const UINT256_MAX = (1n << 256n) - 1n; + +const ELEMENT_REGISTRY = new Interface(["function registerElement(bytes32 elementId,address element)", "function elementOf(bytes32 elementId) view returns (address)"]); +const RECIPE_REGISTRY = new Interface(["function registerRecipe(uint16 recipeId,uint16 version,address recipe)", "function recipeOf(uint16 recipeId) view returns (address)"]); +const POLICY_REGISTRY = new Interface([ + "function registerManifest(address token,tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy) m,tuple(uint16 recipeId,uint16 recipeVersion,uint8 mode,uint16 pathGroupId,uint8 priority)[] bindings)", + "function approveManifest(address token)", + "function manifestOf(address token) view returns (tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy))", + "function recipeBindingsOf(address token) view returns (tuple(uint16 recipeId,uint16 recipeVersion,uint8 mode,uint16 pathGroupId,uint8 priority)[])", + "function statusOf(address token) view returns (uint8)" +]); +const VENUE_REGISTRY = new Interface([ + "function registerVenue(address venue,tuple(uint8 venueType,address adapter,address target,address operator,uint8 custody,bool active) cfg)", + "function venueOf(address venue) view returns (tuple(uint8 venueType,address adapter,address target,address operator,uint8 custody,bool active))" +]); +const RFQ_ADAPTER = new Interface([ + "function setMakerApproved(address maker,bool approved)", + "function approvedMaker(address maker) view returns (bool)" +]); +const MAKER_AUTHORIZER = new Interface([ + "function scheduleDelegate(address maker,address delegate,bytes32 reasonHash)", + "function executeDelegateAuthorization(address maker,address delegate)", + "function isDelegate(address maker,address delegate) view returns (bool)", + "function pendingDelegateReadyAt(address maker,address delegate) view returns (uint64)" +]); +const ERC3643_TOKEN = ["function identityRegistry() view returns (address)", "function compliance() view returns (address)"]; +const IDENTITY_REGISTRY = [ + "function topicsRegistry() view returns (address)", + "function issuersRegistry() view returns (address)", + "function identityStorage() view returns (address)" +]; +const OPERATOR_REGISTRY = [ + "function isGlobalPaused() view returns (bool)", + "function isAssetSuspended(address) view returns (bool)", + "function isVenueSuspended(address) view returns (bool)" +]; +const OWNED = ["function owner() view returns (address)"]; + +export function loadProductionOnboardingConfig(path: string): ProductionOnboardingConfig { + try { + return validateProductionOnboardingConfig(JSON.parse(readFileSync(path, "utf8"))); + } catch (err: any) { + throw new Error(`invalid production onboarding config ${resolve(path)}: ${err.message}`); + } +} + +export function validateProductionOnboardingConfig(value: unknown): ProductionOnboardingConfig { + rejectUnsafeEvidence(value, "onboarding"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("production onboarding config must be an object"); + assertKnownKeys(value, ["schemaVersion", "chainId", "configHash", "artifactHash", "legalPackageHash", "governance", "addresses", "codeHashes", "elements", "recipes", "manifest", "recipeBindings", "venues", "rfq", "inventory"], "onboarding"); + const c = value as Partial; + if (c.schemaVersion !== PRODUCTION_ONBOARDING_SCHEMA_VERSION) throw new Error(`schemaVersion must be ${PRODUCTION_ONBOARDING_SCHEMA_VERSION}`); + if (!Number.isSafeInteger(c.chainId) || Number(c.chainId) <= 0) throw new Error("chainId must be a positive integer"); + if (!isSha(c.configHash)) throw new Error("configHash must be a sha256 hash"); + if (!isSha(c.artifactHash)) throw new Error("artifactHash must be a sha256 hash"); + if (!isSha(c.legalPackageHash)) throw new Error("legalPackageHash must be a sha256 hash"); + if (!c.governance || typeof c.governance !== "object" || Array.isArray(c.governance)) throw new Error("governance is required"); + assertKnownKeys(c.governance, ["safe", "requiredApprovals", "operatorExecutor"], "governance"); + if (!isAddress(c.governance.safe)) throw new Error("governance.safe must be a non-zero address"); + if (!isAddress(c.governance.operatorExecutor)) throw new Error("governance.operatorExecutor must be a non-zero address"); + if (!Number.isSafeInteger(c.governance.requiredApprovals) || c.governance.requiredApprovals < 1 || c.governance.requiredApprovals > 50) throw new Error("governance.requiredApprovals must be between 1 and 50"); + if (!c.addresses || typeof c.addresses !== "object" || Array.isArray(c.addresses)) throw new Error("addresses are required"); + assertKnownKeys(c.addresses, ["token", "identityRegistry", "compliance", "topicsRegistry", "issuersRegistry", "identityStorage", "elementRegistry", "recipeRegistry", "tokenPolicyRegistry", "operatorRegistry", "venueRegistry", "rfqAdapter", "makerAuthorizer"], "addresses"); + for (const key of ["token", "identityRegistry", "compliance", "topicsRegistry", "issuersRegistry", "identityStorage", "elementRegistry", "recipeRegistry", "tokenPolicyRegistry", "operatorRegistry", "venueRegistry"] as const) { + if (!isAddress(c.addresses[key])) throw new Error(`addresses.${key} must be a non-zero address`); + } + if (c.addresses.rfqAdapter !== undefined && !isAddress(c.addresses.rfqAdapter)) throw new Error("addresses.rfqAdapter must be a non-zero address"); + if (c.addresses.makerAuthorizer !== undefined && !isAddress(c.addresses.makerAuthorizer)) throw new Error("addresses.makerAuthorizer must be a non-zero address"); + validateUniqueAddresses(c.addresses as Record); + if (c.codeHashes) { + if (typeof c.codeHashes !== "object" || Array.isArray(c.codeHashes)) throw new Error("codeHashes must be an object"); + const addressKeys = new Set(Object.keys(c.addresses).filter((key) => Boolean((c.addresses as any)[key]))); + for (const [key, value] of Object.entries(c.codeHashes)) { + if (!addressKeys.has(key)) throw new Error(`codeHashes.${key} must match a configured address key`); + if (!isHash32(value)) throw new Error(`codeHashes.${key} must be a 32-byte keccak256 hash`); + } + } + if (!Array.isArray(c.elements) || c.elements.length === 0) throw new Error("elements must contain at least one element"); + const elementIds = new Set(); + for (const [index, element] of c.elements.entries()) { + assertKnownKeys(element, ["elementId", "implementation"], `elements[${index}]`); + if (!isHash32(element?.elementId)) throw new Error(`elements[${index}].elementId must be bytes32`); + if (!isAddress(element?.implementation)) throw new Error(`elements[${index}].implementation must be a non-zero address`); + const key = element.elementId.toLowerCase(); + if (elementIds.has(key)) throw new Error("elements must not contain duplicate elementId values"); + elementIds.add(key); + } + if (!Array.isArray(c.recipes) || c.recipes.length === 0) throw new Error("recipes must contain at least one recipe"); + const recipeIds = new Set(); + for (const [index, recipe] of c.recipes.entries()) { + assertKnownKeys(recipe, ["recipeId", "version", "implementation"], `recipes[${index}]`); + validateUint(recipe?.recipeId, 16, `recipes[${index}].recipeId`); + validateUint(recipe?.version, 16, `recipes[${index}].version`); + if (recipe.recipeId === 0 || recipe.version === 0) throw new Error(`recipes[${index}] recipeId/version must be non-zero`); + if (!isAddress(recipe.implementation)) throw new Error(`recipes[${index}].implementation must be a non-zero address`); + if (recipeIds.has(recipe.recipeId)) throw new Error("recipes must not contain duplicate recipeId values"); + recipeIds.add(recipe.recipeId); + } + validateManifest(c.manifest); + if (!Array.isArray(c.recipeBindings) || c.recipeBindings.length === 0 || c.recipeBindings.length > 8) throw new Error("recipeBindings must contain 1-8 bindings"); + const bindingIds = new Set(); + let hasBlocking = false; + for (const [index, binding] of c.recipeBindings.entries()) { + assertKnownKeys(binding, ["recipeId", "recipeVersion", "mode", "pathGroupId", "priority"], `recipeBindings[${index}]`); + validateUint(binding?.recipeId, 16, `recipeBindings[${index}].recipeId`); + validateUint(binding?.recipeVersion, 16, `recipeBindings[${index}].recipeVersion`); + validateUint(binding?.pathGroupId, 16, `recipeBindings[${index}].pathGroupId`); + validateUint(binding?.priority, 8, `recipeBindings[${index}].priority`); + const mode = enumValue(binding.mode, RECIPE_BINDING_MODE, `recipeBindings[${index}].mode`); + if (binding.recipeId === 0 || binding.recipeVersion === 0) throw new Error(`recipeBindings[${index}] recipeId/version must be non-zero`); + if (!recipeIds.has(binding.recipeId)) throw new Error(`recipeBindings[${index}].recipeId has no registered recipe`); + if (bindingIds.has(binding.recipeId)) throw new Error("recipeBindings must not contain duplicate recipeId values"); + bindingIds.add(binding.recipeId); + if (mode === RECIPE_BINDING_MODE.PATH_OPTION) { + if (binding.pathGroupId === 0) throw new Error("PATH_OPTION recipeBindings require pathGroupId"); + hasBlocking = true; + } else { + if (binding.pathGroupId !== 0) throw new Error("non-PATH_OPTION recipeBindings must use pathGroupId 0"); + if (mode === RECIPE_BINDING_MODE.REQUIRED_BLOCKING) hasBlocking = true; + } + } + if (!hasBlocking) throw new Error("recipeBindings must include a blocking REQUIRED_BLOCKING or PATH_OPTION binding"); + if (!Array.isArray(c.venues) || c.venues.length === 0) throw new Error("venues must contain at least one venue"); + const venueSeen = new Set(); + for (const [index, venue] of c.venues.entries()) validateVenue(venue, index, venueSeen); + if (!c.venues.some((venue) => venue.active)) throw new Error("venues must contain at least one active venue"); + const activeRfqVenues = c.venues.filter((venue) => enumValue(venue.venueType, VENUE_TYPE, "venueType") === VENUE_TYPE.RFQ && venue.active); + if (c.rfq && activeRfqVenues.length === 0) throw new Error("rfq config requires an active RFQ venue"); + const approvedMakers = new Set(); + if (c.rfq) { + assertKnownKeys(c.rfq, ["makers", "signerDelegates"], "rfq"); + const makerSeen = new Set(); + for (const [index, maker] of (c.rfq.makers ?? []).entries()) { + assertKnownKeys(maker, ["maker", "approved"], `rfq.makers[${index}]`); + if (!isAddress(maker?.maker)) throw new Error(`rfq.makers[${index}].maker must be a non-zero address`); + if (typeof maker.approved !== "boolean") throw new Error(`rfq.makers[${index}].approved must be boolean`); + const key = maker.maker.toLowerCase(); + if (makerSeen.has(key)) throw new Error("rfq.makers must not contain duplicate maker values"); + makerSeen.add(key); + if (maker.approved) approvedMakers.add(key); + } + const delegateSeen = new Set(); + for (const [index, delegate] of (c.rfq.signerDelegates ?? []).entries()) { + if (!c.addresses.makerAuthorizer) throw new Error("addresses.makerAuthorizer is required when rfq.signerDelegates are configured"); + assertKnownKeys(delegate, ["maker", "delegate", "reasonHash"], `rfq.signerDelegates[${index}]`); + if (!isAddress(delegate?.maker)) throw new Error(`rfq.signerDelegates[${index}].maker must be a non-zero address`); + if (!isAddress(delegate?.delegate)) throw new Error(`rfq.signerDelegates[${index}].delegate must be a non-zero address`); + if (same(delegate.maker, delegate.delegate)) throw new Error("rfq.signerDelegates maker and delegate must differ"); + if (!approvedMakers.has(delegate.maker.toLowerCase())) throw new Error("rfq.signerDelegates maker must have configured approved=true maker activation"); + if (!isHash32(delegate.reasonHash)) throw new Error(`rfq.signerDelegates[${index}].reasonHash must be bytes32`); + const key = `${delegate.maker.toLowerCase()}:${delegate.delegate.toLowerCase()}`; + if (delegateSeen.has(key)) throw new Error("rfq.signerDelegates must not contain duplicates"); + delegateSeen.add(key); + } + if ((c.rfq.makers?.length ?? 0) > 0 && !c.addresses.rfqAdapter) throw new Error("addresses.rfqAdapter is required when rfq.makers are configured"); + } + if (activeRfqVenues.length > 0) { + if (!c.rfq) throw new Error("active RFQ venue requires rfq config"); + if (approvedMakers.size === 0) throw new Error("active RFQ venue requires at least one approved maker"); + if ((c.rfq.signerDelegates ?? []).length === 0) throw new Error("active RFQ venue requires at least one signer delegate"); + } + if (!Array.isArray(c.inventory) || c.inventory.length === 0) throw new Error("inventory must contain at least one read-only requirement"); + const seen = new Set(); + const inventoryHolders = new Set(); + for (const [index, inv] of c.inventory.entries()) { + assertKnownKeys(inv, ["token", "holder", "spender", "minBalance", "minAllowance", "riskEvidenceHash"], `inventory[${index}]`); + if (!isAddress(inv?.token)) throw new Error(`inventory[${index}].token must be a non-zero address`); + if (!isAddress(inv?.holder)) throw new Error(`inventory[${index}].holder must be a non-zero address`); + if (inv.spender !== undefined && !isAddress(inv.spender)) throw new Error(`inventory[${index}].spender must be a non-zero address`); + parseUint(inv.minBalance, `inventory[${index}].minBalance`); + if (inv.minAllowance !== undefined) parseUint(inv.minAllowance, `inventory[${index}].minAllowance`); + if (inv.minAllowance !== undefined && !inv.spender) throw new Error(`inventory[${index}].spender is required with minAllowance`); + if (!isHash32(inv.riskEvidenceHash)) throw new Error(`inventory[${index}].riskEvidenceHash must be bytes32`); + if ((c.rfq?.makers ?? []).some((maker) => same(maker.maker, inv.holder)) && !(c.rfq?.makers ?? []).some((maker) => same(maker.maker, inv.holder) && maker.approved)) { + throw new Error(`inventory[${index}].holder RFQ maker must have approved maker config`); + } + const key = `${inv.token.toLowerCase()}:${inv.holder.toLowerCase()}:${(inv.spender ?? "").toLowerCase()}`; + if (seen.has(key)) throw new Error("inventory must not contain duplicate token/holder/spender requirements"); + seen.add(key); + inventoryHolders.add(inv.holder.toLowerCase()); + } + if (activeRfqVenues.length > 0 && !Array.from(approvedMakers).some((maker) => inventoryHolders.has(maker))) { + throw new Error("active RFQ venue requires inventory for an approved maker"); + } + return c as ProductionOnboardingConfig; +} + +export function createProductionOnboardingPlan(config: ProductionOnboardingConfig, generatedAt = "1970-01-01T00:00:00.000Z"): ProductionOnboardingPlan { + const selected = validateProductionOnboardingConfig(config); + const txs: OnboardingTx[] = []; + const ids = {elements: [] as string[], recipes: [] as string[], venues: [] as string[], makers: [] as string[], delegates: [] as string[]}; + for (const [index, element] of selected.elements.entries()) { + const id = `element-${index + 1}-${digestId(element.elementId)}`; + ids.elements.push(id); + txs.push(tx(id, "governance-owner", `Register compliance element ${element.elementId}`, selected.addresses.elementRegistry, ELEMENT_REGISTRY.encodeFunctionData("registerElement", [element.elementId, element.implementation]), [], "safe-owner")); + } + for (const recipe of selected.recipes) { + const id = `recipe-${recipe.recipeId}-v${recipe.version}`; + ids.recipes.push(id); + txs.push(tx(id, "governance-owner", `Register recipe ${recipe.recipeId} v${recipe.version}`, selected.addresses.recipeRegistry, RECIPE_REGISTRY.encodeFunctionData("registerRecipe", [recipe.recipeId, recipe.version, recipe.implementation]), ids.elements, "safe-owner")); + } + const manifestId = "manifest-register"; + txs.push(tx(manifestId, "governance-owner", "Register token manifest as PROPOSED", selected.addresses.tokenPolicyRegistry, POLICY_REGISTRY.encodeFunctionData("registerManifest", [selected.addresses.token, manifestTuple(selected.manifest), bindingTuples(selected.recipeBindings)]), ids.recipes, "safe-owner")); + const approveManifestId = "manifest-approve"; + txs.push(tx(approveManifestId, "operator", "Approve token manifest as ACTIVE", selected.addresses.tokenPolicyRegistry, POLICY_REGISTRY.encodeFunctionData("approveManifest", [selected.addresses.token]), [manifestId], "operator")); + for (const [index, venue] of (selected.venues ?? []).entries()) { + const id = `venue-${index + 1}`; + ids.venues.push(id); + txs.push(tx(id, "governance-owner", `Register venue ${venue.venue}`, selected.addresses.venueRegistry, VENUE_REGISTRY.encodeFunctionData("registerVenue", [venue.venue, venueTuple(venue)]), [approveManifestId], "safe-owner")); + } + for (const [index, maker] of (selected.rfq?.makers ?? []).entries()) { + const id = `maker-${index + 1}`; + ids.makers.push(id); + const deps = ids.venues.length > 0 ? ids.venues : [approveManifestId]; + txs.push(tx(id, "operator", `Set RFQ maker approval ${maker.maker}=${maker.approved}`, selected.addresses.rfqAdapter!, RFQ_ADAPTER.encodeFunctionData("setMakerApproved", [maker.maker, maker.approved]), deps, "operator")); + } + for (const [index, delegate] of (selected.rfq?.signerDelegates ?? []).entries()) { + const schedule = `signer-${index + 1}-schedule`; + const execute = `signer-${index + 1}-execute`; + ids.delegates.push(execute); + txs.push(tx(schedule, "governance-owner", `Schedule RFQ signer delegate ${delegate.delegate}`, selected.addresses.makerAuthorizer!, MAKER_AUTHORIZER.encodeFunctionData("scheduleDelegate", [delegate.maker, delegate.delegate, delegate.reasonHash]), ids.makers, "safe-owner")); + txs.push({...tx(execute, "governance-delayed", `Execute delayed RFQ signer delegate ${delegate.delegate}`, selected.addresses.makerAuthorizer!, MAKER_AUTHORIZER.encodeFunctionData("executeDelegateAuthorization", [delegate.maker, delegate.delegate]), [schedule], "safe-owner"), earliestExecution: "+1 day after signer schedule readyAt"}); + } + for (const [index, inv] of selected.inventory.entries()) { + txs.push(tx(`inventory-${index + 1}-verify`, "verification", `Verify read-only inventory for ${inv.holder}`, inv.token, "0x", ids.makers.length > 0 ? ids.makers : [approveManifestId], "read-only")); + } + const hashInput = {...selected, generatedAt: ""}; + const onboardingHash = `sha256:${createHash("sha256").update(canonicalJson(hashInput)).digest("hex")}`; + const safeTransactions = txs.filter((entry) => entry.authority === "safe-owner").map((entry, index) => { + const safeTxLabel = `${String(index + 1).padStart(2, "0")}-${entry.id}`; + return { + ...entry, + origin: "corner-store-toolkit" as const, + chainId: selected.chainId, + safe: selected.governance.safe, + requiredApprovals: selected.governance.requiredApprovals, + proposalId: deterministicProposalId(selected, entry, index, "safe"), + expectedArtifactHash: selected.artifactHash, + legalPackageHash: selected.legalPackageHash, + onboardingHash, + safeTxLabel + }; + }); + const operatorTransactions = txs.filter((entry) => entry.authority === "operator").map((entry, index) => { + const operatorTxLabel = `${String(index + 1).padStart(2, "0")}-${entry.id}`; + return { + ...entry, + origin: "corner-store-toolkit" as const, + chainId: selected.chainId, + executor: selected.governance.operatorExecutor, + proposalId: deterministicProposalId(selected, entry, index, "operator"), + expectedArtifactHash: selected.artifactHash, + legalPackageHash: selected.legalPackageHash, + onboardingHash, + operatorTxLabel + }; + }); + return { + schema: "corner-store-production-onboarding", + schemaVersion: 1, + chainId: selected.chainId, + configHash: selected.configHash, + artifactHash: selected.artifactHash, + legalPackageHash: selected.legalPackageHash, + onboardingHash, + generatedAt, + warnings: [ + "plan/export only: transactions are unsigned and are never broadcast by this tool", + "governance-owner and operator steps are separated for Safe/operator review", + "inventory activation is read-only: no token transfers, approvals, or custody mutations are synthesized", + "external ERC-3643/ONCHAINID contracts remain an external trust boundary and are verified fail-closed" + ], + transactions: txs, + inventoryRequirements: selected.inventory, + safeTransactions, + operatorTransactions + }; +} + +export async function verifyProductionOnboarding(config: ProductionOnboardingConfig, reader: OnboardingReader): Promise { + const selected = validateProductionOnboardingConfig(config); + const checks: ProductionOnboardingCheck[] = []; + const check = (name: string, pass: boolean, detail: string) => checks.push({name, pass, detail}); + try { + const chain = await reader.chainId(); + check("chain-id", chain === selected.chainId, `expected=${selected.chainId}; actual=${chain}`); + } catch (err: any) { check("chain-id", false, `unavailable: ${err.message}`); } + for (const [key, address] of Object.entries(selected.addresses)) { + if (address) await verifyCode(reader, address, `code-${key}`, check, selected.codeHashes?.[key]); + } + await verifyCallAddress(reader, selected.addresses.token, ERC3643_TOKEN, "identityRegistry", [], selected.addresses.identityRegistry, "erc3643-identity-registry", check); + await verifyCallAddress(reader, selected.addresses.token, ERC3643_TOKEN, "compliance", [], selected.addresses.compliance, "erc3643-compliance", check); + await verifyCallAddress(reader, selected.addresses.identityRegistry, IDENTITY_REGISTRY, "topicsRegistry", [], selected.addresses.topicsRegistry, "identity-topics-registry", check); + await verifyCallAddress(reader, selected.addresses.identityRegistry, IDENTITY_REGISTRY, "issuersRegistry", [], selected.addresses.issuersRegistry, "identity-issuers-registry", check); + await verifyCallAddress(reader, selected.addresses.identityRegistry, IDENTITY_REGISTRY, "identityStorage", [], selected.addresses.identityStorage, "identity-storage", check); + await verifyCallBool(reader, selected.addresses.operatorRegistry, OPERATOR_REGISTRY, "isGlobalPaused", [], false, "global-paused", check); + await verifyCallBool(reader, selected.addresses.operatorRegistry, OPERATOR_REGISTRY, "isAssetSuspended", [selected.addresses.token], false, "asset-suspended", check); + await verifyOwner(reader, selected.addresses.elementRegistry, selected.governance.safe, "owner-element-registry", check); + await verifyOwner(reader, selected.addresses.recipeRegistry, selected.governance.safe, "owner-recipe-registry", check); + await verifyOwner(reader, selected.addresses.tokenPolicyRegistry, selected.governance.safe, "owner-token-policy-registry", check); + await verifyOwner(reader, selected.addresses.venueRegistry, selected.governance.safe, "owner-venue-registry", check); + if ((selected.rfq?.signerDelegates ?? []).length > 0) { + await verifyOwner(reader, selected.addresses.makerAuthorizer!, selected.governance.safe, "owner-maker-authorizer", check); + } + await verifyOperatorRole(reader, selected.addresses.tokenPolicyRegistry, selected.governance.operatorExecutor, "token-policy-operator", check); + if ((selected.rfq?.makers ?? []).length > 0) { + await verifyOperatorRole(reader, selected.addresses.rfqAdapter!, selected.governance.operatorExecutor, "rfq-adapter-operator", check); + } + for (const [index, element] of selected.elements.entries()) { + await verifyCallAddress(reader, selected.addresses.elementRegistry, ["function elementOf(bytes32) view returns (address)"], "elementOf", [element.elementId], element.implementation, `element-${index + 1}-${digestId(element.elementId)}`, check); + } + for (const recipe of selected.recipes) { + await verifyCallAddress(reader, selected.addresses.recipeRegistry, ["function recipeOf(uint16) view returns (address)"], "recipeOf", [recipe.recipeId], recipe.implementation, `recipe-${recipe.recipeId}`, check); + } + try { + const status = Number(await reader.call(selected.addresses.tokenPolicyRegistry, ["function statusOf(address) view returns (uint8)"], "statusOf", [selected.addresses.token])); + check("manifest-status", status === POLICY_STATUS.ACTIVE, `expected=ACTIVE(${POLICY_STATUS.ACTIVE}); actual=${status}`); + } catch (err: any) { check("manifest-status", false, `unavailable: ${err.message}`); } + try { + const manifest = await reader.call(selected.addresses.tokenPolicyRegistry, ["function manifestOf(address) view returns (tuple(uint8 status,uint16 issuanceRecipeId,uint16 issuanceRecipeVersion,uint16 fundRecipeId,uint32 enabledResalePaths,uint8 supportedEngines,uint16 stateScopeId,uint256 factsPacked,uint256 coverageScope,bytes32 fullManifestHash,address declaredBy,address approvedBy))"], "manifestOf", [selected.addresses.token]); + const expected = manifestTuple(selected.manifest); + check("manifest-hash", String(manifest.fullManifestHash ?? manifest[9]).toLowerCase() === selected.manifest.fullManifestHash.toLowerCase(), `expected=${selected.manifest.fullManifestHash}; actual=${String(manifest.fullManifestHash ?? manifest[9])}`); + check("manifest-factsPacked", BigInt(manifest.factsPacked ?? manifest[7]) === parseUint(selected.manifest.factsPacked, "manifest.factsPacked"), `expected=${selected.manifest.factsPacked}; actual=${String(manifest.factsPacked ?? manifest[7])}`); + check("manifest-coverageScope", BigInt(manifest.coverageScope ?? manifest[8]) === parseUint(selected.manifest.coverageScope, "manifest.coverageScope"), `expected=${selected.manifest.coverageScope}; actual=${String(manifest.coverageScope ?? manifest[8])}`); + check("manifest-declaredBy", isAddress(String(manifest.declaredBy ?? manifest[10])), `declaredBy=${String(manifest.declaredBy ?? manifest[10])}`); + check("manifest-approvedBy", isAddress(String(manifest.approvedBy ?? manifest[11])), `approvedBy=${String(manifest.approvedBy ?? manifest[11])}`); + for (const [name, pos] of [["issuanceRecipeId", 1], ["issuanceRecipeVersion", 2], ["fundRecipeId", 3], ["enabledResalePaths", 4], ["supportedEngines", 5], ["stateScopeId", 6]] as const) { + check(`manifest-${name}`, Number(manifest[name] ?? manifest[pos]) === Number(expected[pos]), `expected=${expected[pos]}; actual=${String(manifest[name] ?? manifest[pos])}`); + } + } catch (err: any) { check("manifest-core", false, `unavailable: ${err.message}`); } + try { + const bindings = await reader.call(selected.addresses.tokenPolicyRegistry, ["function recipeBindingsOf(address) view returns (tuple(uint16 recipeId,uint16 recipeVersion,uint8 mode,uint16 pathGroupId,uint8 priority)[])"], "recipeBindingsOf", [selected.addresses.token]); + check("manifest-bindings", JSON.stringify(normalizeBindings(bindings)) === JSON.stringify(bindingTuples(selected.recipeBindings).map((b) => b.map(Number))), `expected=${JSON.stringify(bindingTuples(selected.recipeBindings))}; actual=${JSON.stringify(normalizeBindings(bindings))}`); + } catch (err: any) { check("manifest-bindings", false, `unavailable: ${err.message}`); } + for (const [index, venue] of (selected.venues ?? []).entries()) { + try { + const actual = await reader.call(selected.addresses.venueRegistry, ["function venueOf(address) view returns (tuple(uint8 venueType,address adapter,address target,address operator,uint8 custody,bool active))"], "venueOf", [venue.venue]); + const expected = venueTuple(venue); + check(`venue-${index + 1}`, venueMatches(actual, expected), `expected=${JSON.stringify(expected)}; actual=${JSON.stringify(tupleToJson(actual))}`); + await verifyCallBool(reader, selected.addresses.operatorRegistry, OPERATOR_REGISTRY, "isVenueSuspended", [venue.venue], false, `venue-${index + 1}-suspended`, check); + } catch (err: any) { check(`venue-${index + 1}`, false, `unavailable: ${err.message}`); } + } + for (const [index, maker] of (selected.rfq?.makers ?? []).entries()) { + try { + const actual = await reader.call(selected.addresses.rfqAdapter!, ["function approvedMaker(address) view returns (bool)"], "approvedMaker", [maker.maker]); + check(`maker-${index + 1}`, actual === maker.approved, `expected=${maker.approved}; actual=${actual}`); + } catch (err: any) { check(`maker-${index + 1}`, false, `unavailable: ${err.message}`); } + } + for (const [index, delegate] of (selected.rfq?.signerDelegates ?? []).entries()) { + try { + const active = await reader.call(selected.addresses.makerAuthorizer!, ["function isDelegate(address,address) view returns (bool)"], "isDelegate", [delegate.maker, delegate.delegate]); + const pending = await reader.call(selected.addresses.makerAuthorizer!, ["function pendingDelegateReadyAt(address,address) view returns (uint64)"], "pendingDelegateReadyAt", [delegate.maker, delegate.delegate]); + check(`signer-${index + 1}-active`, active === true, `active=${active}; pendingReadyAt=${pending}`); + check(`signer-${index + 1}-pending`, BigInt(pending) === 0n, `pendingReadyAt=${pending}`); + } catch (err: any) { check(`signer-${index + 1}`, false, `unavailable: ${err.message}`); } + } + for (const [index, inv] of selected.inventory.entries()) { + try { + const balance = await reader.balanceOf(inv.token, inv.holder); + check(`inventory-${index + 1}-balance`, balance >= parseUint(inv.minBalance, "minBalance"), `min=${inv.minBalance}; actual=${balance.toString()}; riskEvidenceHash=${inv.riskEvidenceHash}`); + if (inv.spender && inv.minAllowance !== undefined) { + const allowance = await reader.allowance(inv.token, inv.holder, inv.spender); + check(`inventory-${index + 1}-allowance`, allowance >= parseUint(inv.minAllowance, "minAllowance"), `min=${inv.minAllowance}; actual=${allowance.toString()}; riskEvidenceHash=${inv.riskEvidenceHash}`); + } + } catch (err: any) { check(`inventory-${index + 1}`, false, `unavailable: ${err.message}`); } + } + return {ready: checks.every((item) => item.pass), checks}; +} + +export function productionOnboardingInterfaces() { + return {ELEMENT_REGISTRY, RECIPE_REGISTRY, POLICY_REGISTRY, VENUE_REGISTRY, RFQ_ADAPTER, MAKER_AUTHORIZER}; +} + +function tx(id: string, stage: GovernanceStage, description: string, to: string, data: string, dependsOn: string[], authority: OnboardingTx["authority"]): OnboardingTx { + return {id, stage, description, to, value: "0", data, operation: 0, dependsOn, authority}; +} + +function manifestTuple(m: ManifestInput): any[] { + return [POLICY_STATUS.PROPOSED, m.issuanceRecipeId, m.issuanceRecipeVersion, m.fundRecipeId, m.enabledResalePaths, m.supportedEngines, m.stateScopeId, m.factsPacked, m.coverageScope, m.fullManifestHash, "0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000"]; +} + +function bindingTuples(bindings: RecipeBindingInput[]): any[][] { + return bindings.map((b) => [b.recipeId, b.recipeVersion, enumValue(b.mode, RECIPE_BINDING_MODE, "mode"), b.pathGroupId, b.priority]); +} + +function venueTuple(v: VenueInput): any[] { + return [enumValue(v.venueType, VENUE_TYPE, "venueType"), v.adapter, v.target, v.operator, enumValue(v.custody, CUSTODY_MODEL, "custody"), v.active]; +} + +function validateManifest(m: any): void { + if (!m || typeof m !== "object" || Array.isArray(m)) throw new Error("manifest is required"); + assertKnownKeys(m, ["issuanceRecipeId", "issuanceRecipeVersion", "fundRecipeId", "enabledResalePaths", "supportedEngines", "stateScopeId", "factsPacked", "coverageScope", "fullManifestHash"], "manifest"); + validateUint(m.issuanceRecipeId, 16, "manifest.issuanceRecipeId"); + validateUint(m.issuanceRecipeVersion, 16, "manifest.issuanceRecipeVersion"); + validateUint(m.fundRecipeId, 16, "manifest.fundRecipeId"); + validateUint(m.enabledResalePaths, 32, "manifest.enabledResalePaths"); + validateUint(m.supportedEngines, 8, "manifest.supportedEngines"); + validateUint(m.stateScopeId, 16, "manifest.stateScopeId"); + parseUint(m.factsPacked, "manifest.factsPacked"); + parseUint(m.coverageScope, "manifest.coverageScope"); + if (!isHash32(m.fullManifestHash) || /^0x0{64}$/i.test(m.fullManifestHash)) throw new Error("manifest.fullManifestHash must be a non-zero bytes32"); +} + +function validateVenue(venue: VenueInput, index: number, seen: Set): void { + assertKnownKeys(venue, ["venue", "venueType", "adapter", "target", "operator", "custody", "active"], `venues[${index}]`); + if (!isAddress(venue?.venue)) throw new Error(`venues[${index}].venue must be a non-zero address`); + enumValue(venue.venueType, VENUE_TYPE, `venues[${index}].venueType`); + if (!isAddress(venue.adapter)) throw new Error(`venues[${index}].adapter must be a non-zero address`); + if (!isAddress(venue.target)) throw new Error(`venues[${index}].target must be a non-zero address`); + if (!isAddress(venue.operator)) throw new Error(`venues[${index}].operator must be a non-zero address`); + enumValue(venue.custody, CUSTODY_MODEL, `venues[${index}].custody`); + if (typeof venue.active !== "boolean") throw new Error(`venues[${index}].active must be boolean`); + const key = venue.venue.toLowerCase(); + if (seen.has(key)) throw new Error("venues must not contain duplicates"); + seen.add(key); +} + +function validateUniqueAddresses(addresses: Record): void { + const seen = new Map(); + for (const [key, value] of Object.entries(addresses)) { + if (!value) continue; + const lower = value.toLowerCase(); + if (seen.has(lower)) throw new Error(`addresses.${key} duplicates addresses.${seen.get(lower)}`); + seen.set(lower, key); + } +} + +function assertKnownKeys(value: unknown, allowed: string[], path: string): void { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`); + const allowedSet = new Set(allowed); + for (const key of Object.keys(value as Record)) { + if (!allowedSet.has(key)) throw new Error(`${path}.${key} is not supported`); + } +} + +function digestId(value: string): string { + return createHash("sha256").update(value.toLowerCase()).digest("hex").slice(0, 12); +} + +function deterministicProposalId(config: ProductionOnboardingConfig, entry: OnboardingTx, index: number, lane: "safe" | "operator"): string { + const input = { + chainId: config.chainId, + lane, + safe: config.governance.safe.toLowerCase(), + operatorExecutor: config.governance.operatorExecutor.toLowerCase(), + requiredApprovals: config.governance.requiredApprovals, + configHash: config.configHash, + artifactHash: config.artifactHash, + legalPackageHash: config.legalPackageHash, + index, + id: entry.id, + stage: entry.stage, + to: entry.to.toLowerCase(), + value: entry.value, + data: entry.data.toLowerCase(), + dependsOn: entry.dependsOn + }; + return `onboarding-${createHash("sha256").update(canonicalJson(input)).digest("hex")}`; +} + +function enumValue(value: unknown, values: Record, name: string): number { + if (typeof value === "string") { + if (!(value in values)) throw new Error(`${name} is not supported`); + return values[value]; + } + if (!Number.isSafeInteger(value) || Number(value) < 0 || !Object.values(values).includes(Number(value))) throw new Error(`${name} is not supported`); + return Number(value); +} + +function validateUint(value: unknown, bits: number, name: string): void { + if (!Number.isSafeInteger(value) || Number(value) < 0 || BigInt(Number(value)) > ((1n << BigInt(bits)) - 1n)) throw new Error(`${name} must fit uint${bits}`); +} + +function parseUint(value: unknown, name: string): bigint { + if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) throw new Error(`${name} must be a decimal string`); + const n = BigInt(value); + if (n > UINT256_MAX) throw new Error(`${name} must fit uint256`); + return n; +} + +function isAddress(value: unknown): value is string { return typeof value === "string" && ADDRESS.test(value) && !/^0x0{40}$/i.test(value); } +function isHash32(value: unknown): value is string { return typeof value === "string" && HASH32.test(value); } +function isSha(value: unknown): value is string { return typeof value === "string" && SHA256.test(value); } +function same(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase(); } + +function rejectUnsafeEvidence(value: unknown, path: string): void { + if (typeof value === "string") { + const hashLike = /(Hash|hash|elementId|reasonHash|fullManifestHash)$/.test(path) || path.includes(".codeHashes."); + const addressLike = ADDRESS.test(value); + const decimalAmountLike = /\.(minBalance|minAllowance|factsPacked|coverageScope)$/.test(path); + if (SECRET_VALUE.test(value) && !hashLike) throw new Error(`${path} must not contain signer secrets or raw private keys`); + if (!addressLike && !hashLike && !decimalAmountLike && (/@/.test(value) || /\+?[0-9][0-9 .()\-]{7,}/.test(value))) throw new Error(`${path} must be PII-free evidence, not raw contact/person data`); + return; + } + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) return value.forEach((entry, i) => rejectUnsafeEvidence(entry, `${path}[${i}]`)); + for (const [key, entry] of Object.entries(value as Record)) { + if (SECRET_KEY.test(key)) throw new Error(`${path}.${key} must not contain signer secrets, raw PII, or private keys`); + rejectUnsafeEvidence(entry, `${path}.${key}`); + } +} + +function canonicalJson(value: unknown): string { + if (typeof value === "bigint") return JSON.stringify(value.toString()); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +async function verifyCode(reader: OnboardingReader, address: string, name: string, check: (name: string, pass: boolean, detail: string) => void, expected?: string): Promise { + try { + const code = await reader.getCode(address); + const present = code !== "0x"; + if (!present) return check(name, false, `${address} has no code`); + if (expected) { + const actual = keccak256(code); + return check(name, actual.toLowerCase() === expected.toLowerCase(), `expected=${expected}; actual=${actual}`); + } + check(name, true, `${address} code present`); + } catch (err: any) { check(name, false, `unavailable: ${err.message}`); } +} + +async function verifyCallAddress(reader: OnboardingReader, to: string, abi: string[], fn: string, args: unknown[], expected: string, name: string, check: (name: string, pass: boolean, detail: string) => void): Promise { + try { + const actual = await reader.call(to, abi, fn, args); + check(name, String(actual).toLowerCase() === expected.toLowerCase(), `expected=${expected}; actual=${String(actual)}`); + } catch (err: any) { check(name, false, `unavailable: ${err.message}`); } +} + +async function verifyOwner(reader: OnboardingReader, to: string, expectedOwner: string, name: string, check: (name: string, pass: boolean, detail: string) => void): Promise { + try { + const actual = await reader.call(to, OWNED, "owner", []); + check(name, String(actual).toLowerCase() === expectedOwner.toLowerCase(), `expected=${expectedOwner}; actual=${String(actual)}`); + } catch (err: any) { check(name, false, `unavailable: ${err.message}`); } +} + +async function verifyOperatorRole(reader: OnboardingReader, to: string, operator: string, name: string, check: (name: string, pass: boolean, detail: string) => void): Promise { + try { + const actual = await reader.call(to, ["function isOperator(address) view returns (bool)"], "isOperator", [operator]); + check(name, actual === true, `operator=${operator}; isOperator=${actual}`); + } catch (err: any) { check(name, false, `unavailable: ${err.message}`); } +} + +async function verifyCallBool(reader: OnboardingReader, to: string, abi: string[], fn: string, args: unknown[], expected: boolean, name: string, check: (name: string, pass: boolean, detail: string) => void): Promise { + try { + const actual = await reader.call(to, abi, fn, args); + check(name, Boolean(actual) === expected, `expected=${expected}; actual=${Boolean(actual)}`); + } catch (err: any) { check(name, false, `unavailable: ${err.message}`); } +} + +function normalizeBindings(bindings: any): number[][] { + return Array.from(bindings ?? []).map((b: any) => [Number(b.recipeId ?? b[0]), Number(b.recipeVersion ?? b[1]), Number(b.mode ?? b[2]), Number(b.pathGroupId ?? b[3]), Number(b.priority ?? b[4])]); +} + +function venueMatches(actual: any, expected: any[]): boolean { + return Number(actual.venueType ?? actual[0]) === expected[0] && + String(actual.adapter ?? actual[1]).toLowerCase() === String(expected[1]).toLowerCase() && + String(actual.target ?? actual[2]).toLowerCase() === String(expected[2]).toLowerCase() && + String(actual.operator ?? actual[3]).toLowerCase() === String(expected[3]).toLowerCase() && + Number(actual.custody ?? actual[4]) === expected[4] && + Boolean(actual.active ?? actual[5]) === expected[5]; +} + +function tupleToJson(tuple: any): any { + return Array.isArray(tuple) ? tuple.map((v) => typeof v === "bigint" ? v.toString() : v) : tuple; +} diff --git a/services/toolkit/test/smoke.ts b/services/toolkit/test/smoke.ts index 86baf8c..5d68d76 100644 --- a/services/toolkit/test/smoke.ts +++ b/services/toolkit/test/smoke.ts @@ -1,4 +1,12 @@ import {existsSync, mkdtempSync, readFileSync, writeFileSync} from "fs"; + +import {keccak256} from "ethers"; +import { + createProductionOnboardingPlan, + productionOnboardingInterfaces, + validateProductionOnboardingConfig, + verifyProductionOnboarding +} from "../src/production-onboarding"; import {tmpdir} from "os"; import {join} from "path"; import {defaultConfig, enabledEngineSpec, loadConfig, simulateConfig, validateConfig, writeDefaultConfig} from "../src/config"; @@ -19,6 +27,19 @@ import {toSafeTransactionDraft} from "../src/multisig"; import {defaultIntegrationManifest, validateIntegrationManifest} from "../src/integration"; import {scaffoldRFQIntegration} from "../src/scaffold"; +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertThrows(fn: () => void, message: string): void { + try { + fn(); + throw new Error(`${message}: accepted`); + } catch (err: any) { + if (String(err.message).includes(": accepted")) throw err; + } +} + const dir = mkdtempSync(join(tmpdir(), "corner-store-toolkit-")); const path = join(dir, "corner-store.config.json"); writeDefaultConfig(path); @@ -176,6 +197,179 @@ try { if (!err.message.includes("signer secrets")) throw err; } +const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; + +const onboardingConfig = validateProductionOnboardingConfig({ + schemaVersion: 1, + chainId: 1, + configHash: productionConfigHash(productionConfig), + artifactHash: `sha256:${"c".repeat(64)}`, + legalPackageHash: `sha256:${"d".repeat(64)}`, + governance: {safe: "0x8888888888888888888888888888888888888888", requiredApprovals: 2, operatorExecutor: "0x5555555555555555555555555555555555555555"}, + addresses: { + token: "0x1000000000000000000000000000000000000001", + identityRegistry: "0x1000000000000000000000000000000000000002", + compliance: "0x1000000000000000000000000000000000000003", + topicsRegistry: "0x1000000000000000000000000000000000000004", + issuersRegistry: "0x1000000000000000000000000000000000000005", + identityStorage: "0x1000000000000000000000000000000000000006", + elementRegistry: "0x1000000000000000000000000000000000000007", + recipeRegistry: "0x1000000000000000000000000000000000000008", + tokenPolicyRegistry: "0x1000000000000000000000000000000000000009", + operatorRegistry: "0x1000000000000000000000000000000000000013", + venueRegistry: "0x1000000000000000000000000000000000000010", + rfqAdapter: "0x1000000000000000000000000000000000000011", + makerAuthorizer: "0x1000000000000000000000000000000000000012" + }, + codeHashes: {token: keccak256("0x6000")}, + elements: [{elementId: `0x${"01".repeat(32)}`, implementation: "0x2000000000000000000000000000000000000001"}], + recipes: [{recipeId: 1, version: 2, implementation: "0x2000000000000000000000000000000000000002"}], + manifest: { + issuanceRecipeId: 1, + issuanceRecipeVersion: 2, + fundRecipeId: 0, + enabledResalePaths: 1, + supportedEngines: 5, + stateScopeId: 7, + factsPacked: "1", + coverageScope: "3", + fullManifestHash: `0x${"02".repeat(32)}` + }, + recipeBindings: [{recipeId: 1, recipeVersion: 2, mode: "REQUIRED_BLOCKING", pathGroupId: 0, priority: 100}], + venues: [{ + venue: "0x3000000000000000000000000000000000000001", + venueType: "RFQ", + adapter: "0x1000000000000000000000000000000000000011", + target: "0x3000000000000000000000000000000000000002", + operator: "0x5555555555555555555555555555555555555555", + custody: "NONE", + active: true + }], + rfq: { + makers: [{maker: "0x4000000000000000000000000000000000000001", approved: true}], + signerDelegates: [{maker: "0x4000000000000000000000000000000000000001", delegate: "0x4000000000000000000000000000000000000002", reasonHash: `0x${"03".repeat(32)}`}] + }, + inventory: [{ + token: "0x1000000000000000000000000000000000000001", + holder: "0x4000000000000000000000000000000000000001", + spender: "0x1000000000000000000000000000000000000011", + minBalance: "100", + minAllowance: "50", + riskEvidenceHash: `0x${"04".repeat(32)}` + }] +}); +const onboardingPlan = createProductionOnboardingPlan(onboardingConfig, "2026-08-23T00:00:00.000Z"); +const onboardingPlanRepeat = createProductionOnboardingPlan(onboardingConfig, "2026-08-24T00:00:00.000Z"); +assert(onboardingPlan.onboardingHash === onboardingPlanRepeat.onboardingHash, "onboarding hash is deterministic across render time"); +assert(onboardingPlan.transactions.map((t) => t.id).join(",") === "element-1-359577154d98,recipe-1-v2,manifest-register,manifest-approve,venue-1,maker-1,signer-1-schedule,signer-1-execute,inventory-1-verify", "onboarding stage order is deterministic"); +assert(onboardingPlan.safeTransactions.every((tx) => tx.authority === "safe-owner" && tx.origin === "corner-store-toolkit" && tx.operation === 0 && tx.value === "0"), "Safe onboarding drafts are safe-owner unsigned calls"); +assert(onboardingPlan.operatorTransactions.every((tx) => tx.authority === "operator" && tx.origin === "corner-store-toolkit" && tx.executor === onboardingConfig.governance.operatorExecutor && tx.operation === 0 && tx.value === "0"), "operator onboarding drafts are explicit-executor unsigned calls"); +assert(!onboardingPlan.safeTransactions.some((tx) => tx.id === "manifest-approve" || tx.id.startsWith("maker-")), "operator authority steps are excluded from Safe drafts"); +assert(onboardingPlan.operatorTransactions.map((tx) => tx.id).join(",") === "manifest-approve,maker-1", "operator transactions exclude owner-only delayed signer execution"); +assert(onboardingPlan.safeTransactions.every((tx) => tx.chainId === 1 && tx.safe === onboardingConfig.governance.safe && tx.requiredApprovals === 2 && tx.proposalId.startsWith("onboarding-") && tx.proposalId.length === 75 && tx.expectedArtifactHash === onboardingConfig.artifactHash && tx.legalPackageHash === onboardingConfig.legalPackageHash && tx.onboardingHash === onboardingPlan.onboardingHash), "Safe onboarding drafts carry governance and identity metadata"); +assert(new Set(onboardingPlan.safeTransactions.map((tx) => tx.proposalId)).size === onboardingPlan.safeTransactions.length, "Safe proposal IDs are collision-safe per transaction"); +assert(new Set(onboardingPlan.operatorTransactions.map((tx) => tx.proposalId)).size === onboardingPlan.operatorTransactions.length, "operator proposal IDs are collision-safe per transaction"); +assert(onboardingPlan.safeTransactions[0].proposalId === onboardingPlanRepeat.safeTransactions[0].proposalId, "Safe proposal IDs are independent of generatedAt"); +assert(onboardingPlan.operatorTransactions[0].proposalId === onboardingPlanRepeat.operatorTransactions[0].proposalId, "operator proposal IDs are independent of generatedAt"); +assert(onboardingPlan.transactions.some((tx) => tx.id === "signer-1-execute" && tx.dependsOn.includes("signer-1-schedule") && tx.earliestExecution), "signer owner execution is delay-gated"); +assert(onboardingPlan.inventoryRequirements.length === 1 && !onboardingPlan.transactions.some((tx) => /approve\(|transfer/i.test(tx.description + tx.data)), "inventory activation is read-only"); +const ifaces = productionOnboardingInterfaces(); +const decodedElement = ifaces.ELEMENT_REGISTRY.decodeFunctionData("registerElement", onboardingPlan.transactions[0].data); +assert(decodedElement[0] === onboardingConfig.elements[0].elementId && decodedElement[1] === onboardingConfig.elements[0].implementation, "element calldata decodes"); +const decodedRecipe = ifaces.RECIPE_REGISTRY.decodeFunctionData("registerRecipe", onboardingPlan.transactions[1].data); +assert(Number(decodedRecipe[0]) === 1 && Number(decodedRecipe[1]) === 2, "recipe calldata decodes"); +const decodedManifest = ifaces.POLICY_REGISTRY.decodeFunctionData("registerManifest", onboardingPlan.transactions[2].data); +assert(decodedManifest[0] === onboardingConfig.addresses.token && decodedManifest[2].length === 1, "manifest calldata decodes with binding"); +const decodedVenue = ifaces.VENUE_REGISTRY.decodeFunctionData("registerVenue", onboardingPlan.transactions[4].data); +assert(decodedVenue[0] === onboardingConfig.venues![0].venue && Number(decodedVenue[1][0]) === 2, "venue calldata decodes"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, elements: [...onboardingConfig.elements, onboardingConfig.elements[0]]}), "duplicate element rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, legalPackageHash: "mailto:alice@example.com"}), "PII/invalid legal evidence rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, rfq: {...onboardingConfig.rfq, signerPrivateKey: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"}}), "secret fields rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, addresses: {...onboardingConfig.addresses, compliance: onboardingConfig.addresses.identityRegistry}}), "duplicate addresses rejected"); + +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, venues: undefined as any}), "venues are required"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, venues: []}), "empty venues rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, venues: onboardingConfig.venues.map((venue) => ({...venue, active: false}))}), "at least one active venue required"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, inventory: undefined as any}), "inventory is required"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, inventory: []}), "empty inventory rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, rfq: undefined as any}), "active RFQ requires rfq config"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, rfq: {...onboardingConfig.rfq, makers: [{maker: "0x4000000000000000000000000000000000000001", approved: false}]}}), "active RFQ requires approved maker"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, rfq: {...onboardingConfig.rfq, signerDelegates: []}}), "active RFQ requires signer delegate"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, rfq: {...onboardingConfig.rfq, signerDelegates: [{maker: "0x4000000000000000000000000000000000000003", delegate: "0x4000000000000000000000000000000000000004", reasonHash: `0x${"05".repeat(32)}`}]}}), "signer delegate maker must be approved"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, inventory: [{...onboardingConfig.inventory[0], holder: "0x4000000000000000000000000000000000000003"}]}), "active RFQ requires inventory for approved maker"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, venues: onboardingConfig.venues.map((venue) => ({...venue, venueType: "AMM"})), rfq: onboardingConfig.rfq}), "rfq config rejected without RFQ venue"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, governance: {...onboardingConfig.governance, requiredApprovals: 0}}), "invalid governance requiredApprovals rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, governance: {...onboardingConfig.governance, operatorExecutor: "0x0000000000000000000000000000000000000000"}}), "invalid governance operatorExecutor rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, governance: {...onboardingConfig.governance, extra: true} as any}), "unknown governance field rejected"); +assertThrows(() => validateProductionOnboardingConfig({...onboardingConfig, codeHashes: {...onboardingConfig.codeHashes, unknownAddress: `0x${"06".repeat(32)}`}}), "unsupported codeHashes key rejected"); +const ammOnlyConfig = validateProductionOnboardingConfig({ + ...onboardingConfig, + venues: [{...onboardingConfig.venues[0], venueType: "AMM", adapter: "0x5000000000000000000000000000000000000001"}], + rfq: undefined, + inventory: [{...onboardingConfig.inventory[0], holder: "0x5000000000000000000000000000000000000002", spender: "0x5000000000000000000000000000000000000001"}], + addresses: {...onboardingConfig.addresses, rfqAdapter: undefined, makerAuthorizer: undefined} +}); +assert(createProductionOnboardingPlan(ammOnlyConfig).transactions.some((tx) => tx.id === "inventory-1-verify"), "AMM-only coherent mode still requires read-only inventory verification"); +const calls: string[] = []; +const okReader = { + async chainId() { return 1; }, + async getCode(address: string) { return address === onboardingConfig.addresses.token ? "0x6000" : "0x6001"; }, + async call(_address: string, _abi: string[], fn: string, args: unknown[] = []) { + calls.push(fn); + if (fn === "identityRegistry") return onboardingConfig.addresses.identityRegistry; + if (fn === "compliance") return onboardingConfig.addresses.compliance; + if (fn === "topicsRegistry") return onboardingConfig.addresses.topicsRegistry; + if (fn === "issuersRegistry") return onboardingConfig.addresses.issuersRegistry; + if (fn === "identityStorage") return onboardingConfig.addresses.identityStorage; + if (fn === "isGlobalPaused") return false; + if (fn === "isAssetSuspended") return false; + if (fn === "isVenueSuspended") return false; + if (fn === "owner") return onboardingConfig.governance.safe; + if (fn === "isOperator") return true; + if (fn === "elementOf") return onboardingConfig.elements[0].implementation; + if (fn === "recipeOf") return onboardingConfig.recipes[0].implementation; + if (fn === "statusOf") return 2; + if (fn === "manifestOf") return [2, 1, 2, 0, 1, 5, 7, 1n, 3n, onboardingConfig.manifest.fullManifestHash, "0x8888888888888888888888888888888888888888", "0x5555555555555555555555555555555555555555"]; + if (fn === "recipeBindingsOf") return [[1, 2, 0, 0, 100]]; + if (fn === "venueOf") return [2, onboardingConfig.venues![0].adapter, onboardingConfig.venues![0].target, onboardingConfig.venues![0].operator, 0, true]; + if (fn === "approvedMaker") return true; + if (fn === "isDelegate") return true; + if (fn === "pendingDelegateReadyAt") return 0n; + throw new Error(`unexpected call ${fn}`); + }, + async balanceOf() { calls.push("balanceOf"); return 100n; }, + async allowance() { calls.push("allowance"); return 50n; } +}; +const onboardingVerificationPromise = verifyProductionOnboarding(onboardingConfig, okReader).then((onboardingVerify) => { + assert(onboardingVerify.ready, `onboarding verifier should pass: ${JSON.stringify(onboardingVerify.checks)}`); + assert(calls.includes("balanceOf") && calls.includes("allowance") && !calls.some((name) => name === "approve" || name === "transfer"), "inventory verifier only reads balance/allowance"); + const ownerMismatchReader = {...okReader, async call(address: string, abi: string[], fn: string, args: unknown[] = []) { if (fn === "owner" && address === onboardingConfig.addresses.venueRegistry) return "0x9999999999999999999999999999999999999999"; return okReader.call(address, abi, fn, args); }}; + return verifyProductionOnboarding(onboardingConfig, ownerMismatchReader); +}).then((ownerMismatchVerify) => { + assert(!ownerMismatchVerify.ready && ownerMismatchVerify.checks.some((check) => check.name === "owner-venue-registry" && !check.pass), "safe-owner target owner mismatch fails closed"); + const ownerUnavailableReader = {...okReader, async call(address: string, abi: string[], fn: string, args: unknown[] = []) { if (fn === "owner" && address === onboardingConfig.addresses.makerAuthorizer) throw new Error("owner unavailable"); return okReader.call(address, abi, fn, args); }}; + return verifyProductionOnboarding(onboardingConfig, ownerUnavailableReader); +}).then((ownerUnavailableVerify) => { + assert(!ownerUnavailableVerify.ready && ownerUnavailableVerify.checks.some((check) => check.name === "owner-maker-authorizer" && !check.pass), "safe-owner target owner unavailable fails closed"); + const operatorMismatchReader = {...okReader, async call(address: string, abi: string[], fn: string, args: unknown[] = []) { if (fn === "isOperator" && address === onboardingConfig.addresses.rfqAdapter) return false; return okReader.call(address, abi, fn, args); }}; + return verifyProductionOnboarding(onboardingConfig, operatorMismatchReader); +}).then((operatorMismatchVerify) => { + assert(!operatorMismatchVerify.ready && operatorMismatchVerify.checks.some((check) => check.name === "rfq-adapter-operator" && !check.pass), "RFQ operator role mismatch fails closed"); + const operatorUnavailableReader = {...okReader, async call(address: string, abi: string[], fn: string, args: unknown[] = []) { if (fn === "isOperator" && address === onboardingConfig.addresses.tokenPolicyRegistry) throw new Error("operator role unavailable"); return okReader.call(address, abi, fn, args); }}; + return verifyProductionOnboarding(onboardingConfig, operatorUnavailableReader); +}).then((operatorUnavailableVerify) => { + assert(!operatorUnavailableVerify.ready && operatorUnavailableVerify.checks.some((check) => check.name === "token-policy-operator" && !check.pass), "token policy operator role unavailable fails closed"); + const pendingReader = {...okReader, async call(address: string, abi: string[], fn: string, args: unknown[] = []) { if (fn === "isDelegate") return false; if (fn === "pendingDelegateReadyAt") return 123n; return okReader.call(address, abi, fn, args); }}; + return verifyProductionOnboarding(onboardingConfig, pendingReader); +}).then((pendingVerify) => { + assert(!pendingVerify.ready && pendingVerify.checks.some((check) => check.name === "signer-1-active" && !check.pass) && pendingVerify.checks.some((check) => check.name === "signer-1-pending" && !check.pass), "pending signer is reported but not ready"); + const badReader = {...okReader, async call(address: string, abi: string[], fn: string, args: unknown[] = []) { if (fn === "identityRegistry") throw new Error("rpc unavailable"); return okReader.call(address, abi, fn, args); }}; + return verifyProductionOnboarding(onboardingConfig, badReader); +}).then((badVerify) => { + assert(!badVerify.ready && badVerify.checks.some((check) => check.name === "erc3643-identity-registry" && !check.pass), "onboarding verifier fails closed on unavailable reads"); +}); + + const referenceTarget = join(dir, "reference-rfq"); const reference = scaffoldRFQIntegration(referenceTarget, { mode: "reference-service", @@ -257,4 +451,9 @@ try { } catch (err: any) { if (!err.message.includes("environment variable")) throw err; } -console.log("corner-store toolkit smoke ok"); +onboardingVerificationPromise.then(() => { + console.log("corner-store toolkit smoke ok"); +}).catch((err) => { + console.error(err); + process.exitCode = 1; +});