diff --git a/.github/workflows/example-preview.yml b/.github/workflows/example-preview.yml index 0615740c..5eb2a1a2 100644 --- a/.github/workflows/example-preview.yml +++ b/.github/workflows/example-preview.yml @@ -11,6 +11,7 @@ on: types: [opened, synchronize, reopened] paths: - "examples/status-message-dapp/**" + - "examples/perch-status-onchain/**" - ".github/workflows/example-preview.yml" # The example exercises the wallet's connect / delegation-handover / # session-signing flows, so a wallet or SDK PR needs the example preview @@ -60,6 +61,7 @@ jobs: npm run build -w @nidohq/passkey-sdk npm run build -w @nidohq/stellar-wallets-kit-module npm run build -w status_message + npm run build -w @nidohq/testkit - name: Build the example (apex base) run: npx vite build @@ -69,6 +71,16 @@ jobs: run: echo "/* /index.html 200" > dist/_redirects working-directory: examples/status-message-dapp + # Extra examples nested under their own subpath in the same deploy. + # Relative base (--base=./) so each loads correctly from its subpath. + - name: Build the perch tour (nested) + run: npm run build:preview + working-directory: examples/perch-status-onchain + + - name: Nest the tour under the deploy + run: | + cp -r examples/perch-status-onchain/dist examples/status-message-dapp/dist/perch-status-onchain + # Deploy to a branch alias of the mysoroban project. The custom-domain # wildcard (*.mysoroban.xyz) only serves the project's PRODUCTION branch, # so for the example we use wrangler's branch-alias *.pages.dev URL — the @@ -101,7 +113,11 @@ jobs: const previewUrl = process.env.PREVIEW_URL || `https://example-pr-${prNum}.mysoroban.pages.dev`; const liveUrl = `https://${context.repo.owner}.github.io/${context.repo.repo}/`; - const body = `**Example dApp preview deployed!**\n\n${previewUrl}\n\nThe \`status-message\` example (testnet), wallet = THIS PR's preview (https://${prNum}.nido.fyi). The live home is ${liveUrl} once merged.`; + const base = previewUrl.replace(/\/?$/, '/'); + const body = `**Example dApp preview deployed!**\n\n` + + `- \`status-message\` — ${previewUrl}\n` + + `- \`perch-status-onchain\` (guided tour) — ${base}perch-status-onchain/\n\n` + + `status-message runs on testnet, wallet = THIS PR's preview (https://${prNum}.nido.fyi); the perch tour drives perch on real testnet. The live home is ${liveUrl} once merged.`; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1d0c4fce..978f0f0b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,14 +1,21 @@ -name: Deploy Example to GitHub Pages +name: Deploy examples to GitHub Pages -# Deploys examples/status-message-dapp/ as a live testnet demo at the GitHub -# Pages project URL for this repository. Independent of the Cloudflare deploy of -# the main Nido frontend (see deploy.yml / preview.yml). +# Deploys the repo's example dApps as a live testnet demo at the GitHub Pages +# project URL. status-message-dapp is the home (project root); each additional +# example is nested under its own subpath. To add an example: build it into a +# subfolder of the uploaded site (see the "extra examples" step). Independent of +# the Cloudflare deploy of the main Nido frontend (deploy.yml / preview.yml). +# +# // → status-message-dapp +# //perch-status-onchain/ → perch guided tour (enforced on live testnet) on: push: branches: [main] paths: - "examples/status-message-dapp/**" + - "examples/perch-status-onchain/**" + - "packages/**" - ".github/workflows/pages.yml" workflow_dispatch: @@ -17,14 +24,11 @@ permissions: pages: write id-token: write -# Allow one concurrent deployment; don't cancel an in-progress publish. concurrency: group: pages cancel-in-progress: false -# Build-time config for the live demo. All public (no secrets). The committed -# client is bound to the testnet contract id; these point the frontend at -# testnet and at the production Nido passkey wallet. +# Build-time config for the status-message live demo. All public (no secrets). env: PAGES_BASE_PATH: "/${{ github.event.repository.name }}/" PUBLIC_STELLAR_NETWORK: TESTNET @@ -47,24 +51,36 @@ jobs: - name: Install dependencies run: npm ci - # The example imports these workspace packages, which publish built dist/. - # The status_message client is committed as source and built here too. + # Workspace packages the examples import (each publishes a built dist/). - name: Build workspace dependencies run: | npm run build -w @nidohq/passkey-sdk npm run build -w @nidohq/stellar-wallets-kit-module npm run build -w status_message + npm run build -w @nidohq/testkit - - name: Build the example (GitHub Pages base) + # --- home: status-message-dapp at the project root --- + - name: Build status-message-dapp (GitHub Pages base) run: npm run build:pages working-directory: examples/status-message-dapp - # GitHub Pages serves 404.html for unmatched paths; making it the SPA - # shell lets client-side routes under the project path survive a direct reload. + # GitHub Pages serves 404.html for unmatched paths; making it the SPA shell + # lets status-message's client-side routes survive a direct reload. - name: SPA fallback run: cp dist/index.html dist/404.html working-directory: examples/status-message-dapp + # --- extra examples: each nested under its own subpath --- + # Relative base (--base=./) so the bundle loads correctly from the subpath. + - name: Build the perch tour (nested) + run: npm run build:preview + working-directory: examples/perch-status-onchain + + - name: Nest the tour under the site + run: | + cp -r examples/perch-status-onchain/dist \ + examples/status-message-dapp/dist/perch-status-onchain + - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v3 diff --git a/examples/perch-status-onchain/.gitignore b/examples/perch-status-onchain/.gitignore new file mode 100644 index 00000000..dfd4cb46 --- /dev/null +++ b/examples/perch-status-onchain/.gitignore @@ -0,0 +1,4 @@ +test-results/ +dist/ +node_modules/ +thresholdProof.json diff --git a/examples/perch-status-onchain/README.md b/examples/perch-status-onchain/README.md new file mode 100644 index 00000000..3f4508b3 --- /dev/null +++ b/examples/perch-status-onchain/README.md @@ -0,0 +1,122 @@ +# Scope a key with perch — a Nido guided tour + +A guided, six-act tour that takes one job — **"give your CI pipeline a key that +ships releases but can never touch admin or move funds — and prove it"** — from a +raw keypair all the way to a policy the chain enforces, then adds a **multi-sig +quorum**. It teaches, in order, what a smart account is, the OpenZeppelin model, +what Nido adds, **how perch makes authorization easier and safer**, and **how a +rule can require M-of-N signatures** — each ending in a live testnet demonstration. + +Styled in Nido's own "Warm Nest" design language. + +## The six acts + +1. **One key, total power** — a Stellar G-address is all-or-nothing; handing it + to CI hands over the treasury. *The problem.* +2. **The account becomes a program** — a smart account (C-address) runs your + `__check_auth`; authorization is now code. +3. **OZ gives you the vocabulary** — `Signer` + `ContextRule` + **policies** — + but policies are contracts you write, deploy, and audit (with an INV-2 + footgun that can brick your admin). +4. **Nido makes it human** — passkeys, a factory, recovery; connect a real + account and see its **signers across verifiers** — secp256r1 (live), a + **post-quantum ML-DSA-65** key, and a **Delegated → another account** + (a co-signer / treasury). But scoping the CI key is still on you. +5. **perch: describe · prove · enforce** — first, the account's **full policy**: + several rules where perch (the CI rule) **composes with OZ-native policies** + (policy-free admin, an OZ spending-limit cap, a post-quantum co-signer). Then, + on the CI rule: + - **build** the policy as data → a live **policy builder**: toggle the + functions the key may call, the `args[1] = self` author guard, and an + `not-after-ledger` expiry, and watch the wire `PolicyDoc`, its `doc_hash`, + the **reachable calls**, and a safety read all re-derive on every change; + - **narrow** it safely → attenuation is a *machine-checked subset* (perch + accepts a narrowing, refuses a widening); + - **enforce** it on real testnet → the CI key `post`s (allowed) but cannot + `clear` (denied by perch), with real tx links. +6. **Add signers · M-of-N** — the same account model also holds **several + co-signers** and can require a **quorum**. The **policy panel** (the signers × + rules matrix from Act 5) gains a `2-of-3` rule via Nido's **OZ multisig + policy** — perch scopes *what* a key may do; the threshold policy governs *how + many* must sign, **composed on one account**. Proven live: `post` signed by + **2 of 3** succeeds, signed by **1** is denied on-chain. + +Closes on why perch is easier *and* safer: one interpreter audited once, +INV-1/INV-2, machine-checked attenuation, and `doc_hash` = exactly what enforces — +and both perch and the multisig policy are just policies on ContextRules, no +bespoke account code. + +## Run it + +```sh +npm install # from the repo root (workspaces) +npm run dev -w perch-status-onchain +``` + +Click through the acts. In Act 5, **build** the CI grant with the toggles (watch +the `PolicyDoc`, `doc_hash`, and reachable calls update live), **Narrow → +publish-only** (accepted), **Try to widen** (refused), then **Publish** (allowed +on-chain) and **Wipe** (denied on-chain). In Act 6, **Sign with 2** (allowed — +quorum met) and **Sign with 1** (denied on-chain — below threshold). Fees are +paid by an ephemeral friendbot account funded on demand. + +## Verify (browser snapshots) + +```sh +npx playwright install chromium +npm run test:e2e -w perch-status-onchain # LIVE testnet — funds + submits real txs +``` + +Walks all six acts, exercises attenuation, drives real `post`/`clear` +transactions, and proves the 2-of-3 quorum live; writes `artifacts/*.png`. Needs +network + testnet, so it is **not** in the offline CI lane — run it explicitly. + +## Deployed testnet pieces + +| contract | address | +|---|---| +| perch interpreter (OZ Policy) | `CBO4FIGR2LP242IKWDME6NPFGCFAT5R7CSLKYLOOJFVXCCIGKVF6O44G` | +| status board (`post`/`clear`/`get`) | `CBVXSCMALSZBF32OGUXIXFAFMPYFOJM4BOA27PBCMJPR6ZNUREX5ELWM` | +| WebAuthn verifier (secp256r1) | `CACVGSAHYFBXY4LJKWW5B57LAAXHCZVDZOANUTYPLNV6HHQI4Q35EGMY` | +| perch-governed Nido account (Act 5) | `CAZSVYNP52AGK66S3XIAW6HJDFLMXHH3IQECRNCWKHSPIXKMD4RBNMPV` | +| Nido multisig policy (Act 6) | `CCSDKJYOFCPTCCGQZPF73RJNHFC7TPO532Q36N3M2VBYZFWQOTDB7J7G` | +| 2-of-3 quorum account (Act 6) | `CCJLM2X6SDBX5QXFI7QCZ42Q3TAYWBWYA2IG56IUHLXRNQIKP4OU3GQL` | + +`interpreter wasm hash d0f93aac… · account wasm hash 5bb9f585… · doc_hash 7e6b00a4…` + +The Act-6 quorum is proven live — `post` on the 2-of-3 account signed by **2 of 3** +co-signers is [allowed](https://stellar.expert/explorer/testnet/tx/21302c3eab6037c8c2f562a69b57ce42940fb161cb264da5c28a69722ddcb34b) +(`21302c3e…`); signed by **1** it is [denied on-chain](https://stellar.expert/explorer/testnet/tx/6f0265f978ab48fafc9753bd8a892351e701fc67f6183b40acea7fb5da45dde4) +(`6f0265f9…`, `FAILED`). `scripts/prove-threshold.ts` deploys the account and +reproduces both. + +## How the policy gets on-chain + +The RPN lowering (`PolicyDoc → InstallParams`) is Rust-only, so the policy is +compiled by the `perch-plan` CLI (perch repo) and installed as the account's +Default-rule policy at construction. `scripts/deploy-and-prove.ts` does the whole +thing (compile → deploy → prove allow+deny) and is the source of truth for the +addresses above. `src/perchOnchain.ts` holds the invoke flow used by Acts 5–6. + +Act 6's quorum account is orthogonal: no perch policy, just Nido's **multisig +policy** (`SimpleThresholdAccountParams { threshold: 2 }`) on the Default rule +over three secp256r1 co-signers. `scripts/prove-threshold.ts` deploys it and the +browser drives the multi-signer ceremony live — M assertions over one auth digest +land in a single `AuthPayload` via `injectSignedAuthPayload`. perch scopes *what* +a key may do; the threshold policy governs *how many* must sign — composed on one +account, no bespoke account code either way. + +### The footprint gotcha + +Recording `simulateTransaction` never runs `__check_auth`, so its footprint +omits what the account's auth check touches — the verifier code, the interpreter +code, and the read-**write** `Program(account, rule_id)` entry perch `extend_ttl`s +— failing with `scecExceededLimit`. Fixed by **re-simulating the signed tx** +(enforcing mode) and submitting with that footprint. Every OZ/perch/nido test +mocks auth, so this path was untested until here. + +## Roadmap — v2: author policies in the browser + +Today the policy is fixed (compiled by the Rust CLI at deploy time). Next: a +WebAssembly build of the perch compiler so a policy built in Act 5 can be +compiled in-browser (byte-identical to Rust) and deployed live. diff --git a/examples/perch-status-onchain/artifacts/01-act1-problem.png b/examples/perch-status-onchain/artifacts/01-act1-problem.png new file mode 100644 index 00000000..c595fa4a Binary files /dev/null and b/examples/perch-status-onchain/artifacts/01-act1-problem.png differ diff --git a/examples/perch-status-onchain/artifacts/02-act2-smart-account.png b/examples/perch-status-onchain/artifacts/02-act2-smart-account.png new file mode 100644 index 00000000..3e73bb41 Binary files /dev/null and b/examples/perch-status-onchain/artifacts/02-act2-smart-account.png differ diff --git a/examples/perch-status-onchain/artifacts/03-act3-oz-model.png b/examples/perch-status-onchain/artifacts/03-act3-oz-model.png new file mode 100644 index 00000000..dedc00bc Binary files /dev/null and b/examples/perch-status-onchain/artifacts/03-act3-oz-model.png differ diff --git a/examples/perch-status-onchain/artifacts/04-act4-nido.png b/examples/perch-status-onchain/artifacts/04-act4-nido.png new file mode 100644 index 00000000..148f0622 Binary files /dev/null and b/examples/perch-status-onchain/artifacts/04-act4-nido.png differ diff --git a/examples/perch-status-onchain/artifacts/05-act5-attenuate.png b/examples/perch-status-onchain/artifacts/05-act5-attenuate.png new file mode 100644 index 00000000..8ca51008 Binary files /dev/null and b/examples/perch-status-onchain/artifacts/05-act5-attenuate.png differ diff --git a/examples/perch-status-onchain/artifacts/06-post-allowed.png b/examples/perch-status-onchain/artifacts/06-post-allowed.png new file mode 100644 index 00000000..cf36ac6a Binary files /dev/null and b/examples/perch-status-onchain/artifacts/06-post-allowed.png differ diff --git a/examples/perch-status-onchain/artifacts/07-clear-denied.png b/examples/perch-status-onchain/artifacts/07-clear-denied.png new file mode 100644 index 00000000..bdac65b9 Binary files /dev/null and b/examples/perch-status-onchain/artifacts/07-clear-denied.png differ diff --git a/examples/perch-status-onchain/artifacts/08-act6-quorum.png b/examples/perch-status-onchain/artifacts/08-act6-quorum.png new file mode 100644 index 00000000..55ed59c4 Binary files /dev/null and b/examples/perch-status-onchain/artifacts/08-act6-quorum.png differ diff --git a/examples/perch-status-onchain/artifacts/09-mofn-2of3-allowed.png b/examples/perch-status-onchain/artifacts/09-mofn-2of3-allowed.png new file mode 100644 index 00000000..549d4823 Binary files /dev/null and b/examples/perch-status-onchain/artifacts/09-mofn-2of3-allowed.png differ diff --git a/examples/perch-status-onchain/artifacts/10-mofn-1of3-denied.png b/examples/perch-status-onchain/artifacts/10-mofn-1of3-denied.png new file mode 100644 index 00000000..6eaacb8b Binary files /dev/null and b/examples/perch-status-onchain/artifacts/10-mofn-1of3-denied.png differ diff --git a/examples/perch-status-onchain/index.html b/examples/perch-status-onchain/index.html new file mode 100644 index 00000000..1f32c444 --- /dev/null +++ b/examples/perch-status-onchain/index.html @@ -0,0 +1,23 @@ + + + + + + + + Scope a key with perch — a Nido guided tour + + + + + + + + +
+ + + diff --git a/examples/perch-status-onchain/package.json b/examples/perch-status-onchain/package.json new file mode 100644 index 00000000..6a696fbb --- /dev/null +++ b/examples/perch-status-onchain/package.json @@ -0,0 +1,28 @@ +{ + "name": "perch-status-onchain", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build:pages": "tsc && vite build --base=/${REPO_NAME:-nido}/", + "build:preview": "tsc && vite build --base=./", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", + "prove": "tsx scripts/deploy-and-prove.ts" + }, + "dependencies": { + "@nidohq/passkey-sdk": "*", + "@nidohq/testkit": "*", + "@stellar/stellar-sdk": "^15.1.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "tsx": "^4.19.0", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vite-plugin-node-polyfills": "^0.25.0" + } +} diff --git a/examples/perch-status-onchain/playwright.config.ts b/examples/perch-status-onchain/playwright.config.ts new file mode 100644 index 00000000..997f2073 --- /dev/null +++ b/examples/perch-status-onchain/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + reporter: [['list']], + timeout: 120_000, + use: { + baseURL: 'http://localhost:5178', + viewport: { width: 1200, height: 1500 }, + // Settle entrance animations so full-page snapshots aren't caught mid-fade. + reducedMotion: 'reduce', + }, + webServer: { + command: 'npx vite --port 5178 --strictPort', + url: 'http://localhost:5178', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/examples/perch-status-onchain/scripts/deploy-and-prove.ts b/examples/perch-status-onchain/scripts/deploy-and-prove.ts new file mode 100644 index 00000000..ba094952 --- /dev/null +++ b/examples/perch-status-onchain/scripts/deploy-and-prove.ts @@ -0,0 +1,266 @@ +/** + * Deploy a perch-governed Nido smart account to testnet and PROVE, on-chain, + * that perch allows an in-policy call and denies an out-of-policy one. + * + * account (Default rule, id 0) + * signer: poster (secp256r1 / real webauthn-verifier, driven by a LOCAL key) + * policy: perch interpreter, program = FnIn{post} ∧ arg[1]=self ∧ MinSigners≥1 + * + * post("gm", author=account) → perch ALLOW → tx succeeds + * clear(author=account) → perch DENY → tx fails in __check_auth + * + * The RPN lowering is Rust-only, so the perch program is compiled by the + * `perch-plan` CLI (perch repo) and dropped in as an InstallParams ScVal — the + * exact bytes the chain enforces. Everything else is @stellar/stellar-sdk + + * @nidohq/{testkit,passkey-sdk} against public testnet RPC. + * + * Run: npx tsx examples/perch-status-onchain/scripts/deploy-and-prove.ts + */ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + Address, + BASE_FEE, + Keypair, + nativeToScVal, + Networks, + Operation, + TransactionBuilder, + rpc, + xdr, +} from '@stellar/stellar-sdk'; +import { secp256r1Keypair, buildSyntheticAssertion, computeAuthDigest, deriveAccountAddress } from '@nidohq/testkit'; +import { buildAuthHash, injectPasskeySignature } from '@nidohq/passkey-sdk'; + +// ---- deployed testnet pieces (see scratchpad/deployed.json) ---- +const RPC_URL = 'https://soroban-testnet.stellar.org'; +const NET = Networks.TESTNET; +const INTERP = 'CBO4FIGR2LP242IKWDME6NPFGCFAT5R7CSLKYLOOJFVXCCIGKVF6O44G'; +const INTERP_WASM_HASH = 'd0f93aacc9a19c4d29a46f61d1e602caa0f4779adcebb4c5b618d090b2fd24de'; +const BOARD = 'CBVXSCMALSZBF32OGUXIXFAFMPYFOJM4BOA27PBCMJPR6ZNUREX5ELWM'; +const VERIFIER = 'CACVGSAHYFBXY4LJKWW5B57LAAXHCZVDZOANUTYPLNV6HHQI4Q35EGMY'; +const ACCOUNT_WASM_HASH = '5bb9f585fa7d1485c3252ff00a521b1247ab71d57436fbc8c8b8e4a0ff010afb'; +const PERCH_CLI_DIR = process.env.PERCH_DIR ?? '/Users/willem/c/stellar-registry/perch-testnet'; +const SOURCE_ALIAS = process.env.SOURCE_ALIAS ?? 'perch-demo'; + +const server = new rpc.Server(RPC_URL); +const hex = (b: Uint8Array) => Buffer.from(b).toString('hex'); + +// Deterministic poster key so the deployed account is stable/reproducible. +const poster = secp256r1Keypair(new Uint8Array(32).fill(7)); +const SALT = new Uint8Array(32).fill(42); + +function sourceKeypair(): Keypair { + const secret = execFileSync('stellar', ['keys', 'show', SOURCE_ALIAS]).toString().trim(); + return Keypair.fromSecret(secret); +} + +/** Compile the demo PolicyDoc → InstallParams XDR via the perch-plan CLI. */ +function compilePolicy(): { installXdr: string; docHash: string } { + const doc = { + version: 1, + network: NET, + signers: [{ id: 'poster', verifier: VERIFIER, key: hex(poster.publicKey) }], + rules: [ + { + name: 'poster-can-post', + scope: { type: 'contract', address: BOARD }, + principals: { type: 'all', signers: ['poster'] }, + functions: ['post'], + args: [{ index: 1, pred: { type: 'is-self' } }], + }, + ], + }; + const dir = mkdtempSync(join(tmpdir(), 'perch-doc-')); + const docPath = join(dir, 'doc.json'); + writeFileSync(docPath, JSON.stringify(doc, null, 2)); + const out = execFileSync('cargo', ['run', '-q', '-p', 'perch-plan-cli', '--', docPath, INTERP_WASM_HASH], { + cwd: PERCH_CLI_DIR, + }).toString(); + const plan = JSON.parse(out); + const rule = plan.rules[0]; + if (!rule.install_xdr) throw new Error('expected an interpreter-attached rule'); + return { installXdr: rule.install_xdr, docHash: plan.doc_hash }; +} + +/** Signer::External(verifier, pubkey) as an ScVal Vec[Symbol, Address, Bytes]. */ +function externalSigner(verifier: string, pubkey: Uint8Array): xdr.ScVal { + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol('External'), + Address.fromString(verifier).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(pubkey)), + ]); +} + +async function loadSource(kp: Keypair) { + return server.getAccount(kp.publicKey()); +} + +async function submit(tx: Awaited>, kp: Keypair, label: string) { + tx.sign(kp); + const sent = await server.sendTransaction(tx); + if (sent.status === 'ERROR') { + console.log(` ${label}: send ERROR`, JSON.stringify(sent.errorResult?.result?.() ?? sent)); + return { ok: false, hash: sent.hash, detail: 'send-error' as const }; + } + const final = await server.pollTransaction(sent.hash, { attempts: 15, sleepStrategy: () => 2000 }); + return { ok: final.status === 'SUCCESS', hash: sent.hash, status: final.status, final }; +} + +async function accountExists(addr: string): Promise { + try { + const r = await server.getContractData(addr, xdr.ScVal.scvLedgerKeyContractInstance()); + return !!r; + } catch { + return false; + } +} + +async function deployAccount(installXdr: string): Promise { + const kp = sourceKeypair(); + // Deterministic address (createCustomContract derives contract-id from the + // deployer address + salt, identical to the factory's deployer+salt scheme). + const derived = deriveAccountAddress(kp.publicKey(), Buffer.from(SALT), NET); + if (await accountExists(derived)) { + console.log(` reusing existing account: ${derived}`); + return derived; + } + const signers = xdr.ScVal.scvVec([externalSigner(VERIFIER, poster.publicKey)]); + const policies = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: Address.fromString(INTERP).toScVal(), + val: xdr.ScVal.fromXDR(installXdr, 'hex'), + }), + ]); + const recovery = xdr.ScVal.scvVoid(); // Option::None + const op = Operation.createCustomContract({ + address: Address.fromString(kp.publicKey()), + wasmHash: Buffer.from(ACCOUNT_WASM_HASH, 'hex'), + constructorArgs: [signers, policies, recovery], + salt: Buffer.from(SALT), + }); + const tx = new TransactionBuilder(await loadSource(kp), { fee: BASE_FEE, networkPassphrase: NET }) + .addOperation(op) + .setTimeout(60) + .build(); + const prepared = await server.prepareTransaction(tx); + const res = await submit(prepared, kp, 'deploy'); + if (!res.ok) throw new Error(`account deploy failed: ${JSON.stringify(res)}`); + const rv = (res.final as rpc.Api.GetSuccessfulTransactionResponse).returnValue!; + const addr = Address.fromScVal(rv).toString(); + console.log(` deployed account: ${addr} (tx ${res.hash})`); + return addr; +} + +interface InvokeResult { + ok: boolean; + denied?: boolean; + hash?: string; + status?: string; + error?: string; + sorobanData?: xdr.SorobanTransactionData; +} + +/** + * Build → recording-simulate → sign the account's auth entry → RE-simulate the + * SIGNED tx (enforcing mode runs __check_auth: verifier.verify + perch enforce, + * capturing their footprint the recording pass omits) → submit with that + * footprint. `reuseSorobanData` lets the deny case borrow the allow case's + * footprint (same ledger keys) so a rejected call still submits a real, + * cleanly-Denied on-chain tx instead of tripping ExceededLimit. + */ +async function invokeAsAccount( + account: string, + fn: 'post' | 'clear', + message: string | null, + reuseSorobanData?: xdr.SorobanTransactionData, +): Promise { + const kp = sourceKeypair(); + const args = + fn === 'post' + ? [nativeToScVal(message ?? '', { type: 'string' }), Address.fromString(account).toScVal()] + : [Address.fromString(account).toScVal()]; + const op = Operation.invokeContractFunction({ contract: BOARD, function: fn, args }); + const tx = new TransactionBuilder(await loadSource(kp), { fee: (Number(BASE_FEE) * 100).toString(), networkPassphrase: NET }) + .addOperation(op) + .setTimeout(120) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) return { ok: false, error: `record-sim: ${sim.error}` }; + const lastLedger = (await server.getLatestLedger()).sequence; + const assembled = rpc.assembleTransaction(tx, sim).build(); + + // Sign the account's auth entry with the poster key. + const entry = (assembled.operations[0] as Operation.InvokeHostFunction).auth![0]; + const authDigest = computeAuthDigest(buildAuthHash(entry, NET, lastLedger), [0]); + const a = buildSyntheticAssertion(poster.secretKey, authDigest); + injectPasskeySignature( + assembled, + { authenticatorData: a.authenticatorData, clientDataJson: a.clientDataJSON, signature: a.signature }, + VERIFIER, + poster.publicKey, + lastLedger, + undefined, + [0], + ); + + // Enforcing re-simulation: runs __check_auth for real. Success → correct + // footprint; error → the account rejected it (for `clear`, that's perch). + let sorobanData = reuseSorobanData; + const sim2 = await server.simulateTransaction(assembled); + if (rpc.Api.isSimulationError(sim2)) { + const denied = /Denied|UnauthorizedSigner|InvalidAction|#\d+/.test(sim2.error); + console.log(` ${fn}: enforcing __check_auth → ${denied ? 'DENIED by account/policy' : 'error'}: ${sim2.error.split('\n')[0]}`); + if (!reuseSorobanData) return { ok: false, denied, error: sim2.error }; + // else: submit anyway with the borrowed footprint to land a real failed tx. + } else { + sorobanData = sim2.transactionData.build(); + } + + const resourceFee = Number((sim2 as rpc.Api.SimulateTransactionSuccessResponse).minResourceFee ?? 0); + // Clone the signed tx (preserves the op's injected AuthPayload) but swap in the + // enforcing footprint + a fee that covers the real resource cost. + const finalTx = TransactionBuilder.cloneFrom(assembled, { fee: (resourceFee + 2_000_000).toString() }) + .setSorobanData(sorobanData!) + .build(); + const res = await submit(finalTx, kp, fn); + return { ok: res.ok, hash: res.hash, status: res.status, sorobanData }; +} + +async function main() { + console.log('▶ compiling perch policy (Rust perch-plan CLI)…'); + const { installXdr, docHash } = compilePolicy(); + console.log(` doc_hash ${docHash}`); + console.log(` install_xdr ${installXdr.length / 2} bytes`); + + console.log('▶ deploying perch-governed account…'); + const account = await deployAccount(installXdr); + + console.log('▶ ALLOW case: poster.post("gm on-chain", self)'); + const allow = await invokeAsAccount(account, 'post', 'gm on-chain via perch'); + console.log(` → ${allow.ok ? 'ALLOWED ✓' : 'denied ✕'} ${allow.hash ?? ''} ${allow.error ?? ''}`); + + console.log('▶ DENY case: poster.clear(self) [perch FnIn{post} should refuse]'); + const deny = await invokeAsAccount(account, 'clear', null, allow.sorobanData); + console.log(` → ${deny.ok ? 'ALLOWED (unexpected!) ✗' : 'DENIED ✓'} ${deny.hash ?? ''} ${deny.status ?? ''}`); + + const verdict = allow.ok && !deny.ok; + console.log(`\n${verdict ? '✅ PROVEN' : '❌ NOT proven'}: perch ${verdict ? 'allowed post and denied clear on-chain' : 'did not behave as expected'}`); + writeFileSync( + join(PERCH_CLI_DIR, '..', 'perch-onchain-proof.json'), + JSON.stringify( + { account, board: BOARD, interpreter: INTERP, docHash, allow: { ...allow, sorobanData: undefined }, deny: { ...deny, sorobanData: undefined }, verdict }, + null, + 2, + ), + ); + process.exit(verdict ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/perch-status-onchain/scripts/diag-authtree.ts b/examples/perch-status-onchain/scripts/diag-authtree.ts new file mode 100644 index 00000000..f56520f1 --- /dev/null +++ b/examples/perch-status-onchain/scripts/diag-authtree.ts @@ -0,0 +1,48 @@ +// Diagnostic: what does recording simulation return as the account's auth tree +// for a perch-gated call? Does it include the nested interpreter.enforce +// sub-invocation (whose smart_account.require_auth must be satisfied)? +import { Address, BASE_FEE, Keypair, nativeToScVal, Networks, Operation, TransactionBuilder, rpc, xdr } from '@stellar/stellar-sdk'; +import { execFileSync } from 'node:child_process'; + +const server = new rpc.Server('https://soroban-testnet.stellar.org'); +const NET = Networks.TESTNET; +const BOARD = 'CBVXSCMALSZBF32OGUXIXFAFMPYFOJM4BOA27PBCMJPR6ZNUREX5ELWM'; +const ACCOUNT = process.argv[2] ?? 'CAZSVYNP52AGK66S3XIAW6HJDFLMXHH3IQECRNCWKHSPIXKMD4RBNMPV'; + +function dumpInvocation(inv: xdr.SorobanAuthorizedInvocation, depth = 0): void { + const pad = ' '.repeat(depth); + const fn = inv.function(); + if (fn.switch().name === 'sorobanAuthorizedFunctionTypeContractFn') { + const c = fn.contractFn(); + const addr = Address.fromScAddress(c.contractAddress()).toString(); + console.log(`${pad}• ${addr}.${c.functionName().toString()}(${c.args().length} args)`); + } else { + console.log(`${pad}• [create-contract]`); + } + for (const sub of inv.subInvocations()) dumpInvocation(sub, depth + 1); +} + +async function main() { + const src = Keypair.fromSecret(execFileSync('stellar', ['keys', 'show', 'perch-demo']).toString().trim()); + const op = Operation.invokeContractFunction({ + contract: BOARD, + function: 'post', + args: [nativeToScVal('diag', { type: 'string' }), Address.fromString(ACCOUNT).toScVal()], + }); + const tx = new TransactionBuilder(await server.getAccount(src.publicKey()), { fee: (Number(BASE_FEE) * 100).toString(), networkPassphrase: NET }) + .addOperation(op).setTimeout(60).build(); + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { console.log('SIM ERROR:', sim.error); return; } + const auth = sim.result?.auth ?? []; + console.log(`auth entries: ${auth.length}`); + auth.forEach((e, i) => { + const c = e.credentials(); + console.log(`\n[entry ${i}] credentials: ${c.switch().name}`); + if (c.switch().name === 'sorobanCredentialsAddress') { + console.log(` address: ${Address.fromScAddress(c.address().address()).toString()}`); + } + console.log(' rootInvocation tree:'); + dumpInvocation(e.rootInvocation(), 2); + }); +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/examples/perch-status-onchain/scripts/diag-events.ts b/examples/perch-status-onchain/scripts/diag-events.ts new file mode 100644 index 00000000..4430cc8d --- /dev/null +++ b/examples/perch-status-onchain/scripts/diag-events.ts @@ -0,0 +1,32 @@ +// Fetch the diagnostic events of a (failed) testnet tx to see exactly which +// contract panicked and why — verifier reject vs interpreter C1/C4/Denied. +import { rpc, scValToNative, xdr } from '@stellar/stellar-sdk'; +const server = new rpc.Server('https://soroban-testnet.stellar.org'); + +async function main() { + const hash = process.argv[2]; + if (!hash) throw new Error('usage: diag-events '); + const tx = await server.getTransaction(hash); + console.log(`status: ${tx.status}`); + const anyTx = tx as unknown as { resultMetaXdr?: xdr.TransactionMeta; diagnosticEventsXdr?: xdr.DiagnosticEvent[]; returnValue?: xdr.ScVal }; + // Newer SDKs expose diagnosticEventsXdr directly on failed txs. + const raw = (tx as unknown as { diagnosticEventsXdr?: unknown }).diagnosticEventsXdr as xdr.DiagnosticEvent[] | undefined; + const events = raw ?? []; + console.log(`diagnostic events: ${events.length}`); + for (const ev of events) { + try { + const e = ev.event(); + const body = e.body().v0(); + const topics = body.topics().map((t) => { + try { return JSON.stringify(scValToNative(t)); } catch { return t.switch().name; } + }); + let data = ''; + try { data = JSON.stringify(scValToNative(body.data())); } catch { data = body.data().switch().name; } + const ctype = e.contractId() ? require('@stellar/stellar-sdk').StrKey.encodeContract(e.contractId()!) : '(host)'; + console.log(` [${e.type().name}] ${ctype} topics=${topics.join(',')} data=${data}`); + } catch (err) { + console.log(' (undecodable event)', (err as Error).message); + } + } +} +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/examples/perch-status-onchain/scripts/prove-threshold.ts b/examples/perch-status-onchain/scripts/prove-threshold.ts new file mode 100644 index 00000000..c7918133 --- /dev/null +++ b/examples/perch-status-onchain/scripts/prove-threshold.ts @@ -0,0 +1,211 @@ +/** + * Deploy a 2-of-3 (M-of-N) Nido smart account to testnet and PROVE, on-chain, + * that the multisig policy enforces the threshold: + * + * account (Default rule, id 0) + * signers: owner, backup, treasury (3 × secp256r1, local keys) + * policy: nido multisig-policy, SimpleThresholdAccountParams { threshold: 2 } + * + * post(_, author=account) signed by 2 keys → 2-of-3 met → tx succeeds + * post(_, author=account) signed by 1 key → threshold → tx fails (Denied) + * + * The multi-signer ceremony puts M assertions over the SAME auth digest into one + * AuthPayload (`injectSignedAuthPayload`). Everything is @stellar/stellar-sdk + + * @nidohq/{testkit,passkey-sdk} against public testnet RPC; source = `perch-demo`. + * + * Run: npx tsx examples/perch-status-onchain/scripts/prove-threshold.ts + */ +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + Address, + BASE_FEE, + Keypair, + nativeToScVal, + Networks, + Operation, + TransactionBuilder, + rpc, + xdr, +} from '@stellar/stellar-sdk'; +import { secp256r1Keypair, buildSyntheticAssertion, computeAuthDigest, deriveAccountAddress } from '@nidohq/testkit'; +import { buildAuthHash, injectSignedAuthPayload } from '@nidohq/passkey-sdk'; + +const RPC_URL = 'https://soroban-testnet.stellar.org'; +const NET = Networks.TESTNET; +const BOARD = 'CBVXSCMALSZBF32OGUXIXFAFMPYFOJM4BOA27PBCMJPR6ZNUREX5ELWM'; +const VERIFIER = 'CACVGSAHYFBXY4LJKWW5B57LAAXHCZVDZOANUTYPLNV6HHQI4Q35EGMY'; +const ACCOUNT_WASM_HASH = '5bb9f585fa7d1485c3252ff00a521b1247ab71d57436fbc8c8b8e4a0ff010afb'; +const MULTISIG = 'CCSDKJYOFCPTCCGQZPF73RJNHFC7TPO532Q36N3M2VBYZFWQOTDB7J7G'; +const SOURCE_ALIAS = process.env.SOURCE_ALIAS ?? 'perch-demo'; +const OUT = process.env.OUT ?? join(process.cwd(), 'examples/perch-status-onchain/src/thresholdProof.json'); + +const server = new rpc.Server(RPC_URL); + +// The 2-of-3 signer set: deterministic keys → a stable, reproducible account. +const SIGNERS = [ + { id: 'owner', kp: secp256r1Keypair(new Uint8Array(32).fill(11)) }, + { id: 'backup', kp: secp256r1Keypair(new Uint8Array(32).fill(12)) }, + { id: 'treasury', kp: secp256r1Keypair(new Uint8Array(32).fill(13)) }, +]; +const THRESHOLD = 2; +const SALT = new Uint8Array(32).fill(87); // #87 — the M-of-N issue + +function sourceKeypair(): Keypair { + return Keypair.fromSecret(execFileSync('stellar', ['keys', 'show', SOURCE_ALIAS]).toString().trim()); +} + +function externalSigner(pubkey: Uint8Array): xdr.ScVal { + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol('External'), + Address.fromString(VERIFIER).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(pubkey)), + ]); +} + +/** `SimpleThresholdAccountParams { threshold }` as its ScVal (struct → symbol map). */ +function thresholdInstall(threshold: number): xdr.ScVal { + return xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol('threshold'), val: xdr.ScVal.scvU32(threshold) }), + ]); +} + +async function submit(tx: Awaited>, kp: Keypair, label: string) { + tx.sign(kp); + const sent = await server.sendTransaction(tx); + if (sent.status === 'ERROR') { + return { ok: false, hash: sent.hash, status: 'SEND_ERROR' as const }; + } + const final = await server.pollTransaction(sent.hash, { attempts: 20, sleepStrategy: () => 2000 }); + return { ok: final.status === 'SUCCESS', hash: sent.hash, status: final.status, final }; +} + +async function accountExists(addr: string): Promise { + try { + return !!(await server.getContractData(addr, xdr.ScVal.scvLedgerKeyContractInstance())); + } catch { + return false; + } +} + +async function deployAccount(): Promise { + const kp = sourceKeypair(); + const derived = deriveAccountAddress(kp.publicKey(), SALT, NET); + if (await accountExists(derived)) { + console.log(` reusing existing 2-of-3 account: ${derived}`); + return derived; + } + const signers = xdr.ScVal.scvVec(SIGNERS.map((s) => externalSigner(s.kp.publicKey))); + const policies = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ key: Address.fromString(MULTISIG).toScVal(), val: thresholdInstall(THRESHOLD) }), + ]); + const op = Operation.createCustomContract({ + address: Address.fromString(kp.publicKey()), + wasmHash: Buffer.from(ACCOUNT_WASM_HASH, 'hex'), + constructorArgs: [signers, policies, xdr.ScVal.scvVoid()], + salt: Buffer.from(SALT), + }); + const tx = new TransactionBuilder(await server.getAccount(kp.publicKey()), { fee: BASE_FEE, networkPassphrase: NET }) + .addOperation(op) + .setTimeout(60) + .build(); + const res = await submit(await server.prepareTransaction(tx), kp, 'deploy'); + if (!res.ok) throw new Error(`account deploy failed: ${JSON.stringify(res)}`); + const addr = Address.fromScVal((res.final as rpc.Api.GetSuccessfulTransactionResponse).returnValue!).toString(); + console.log(` deployed 2-of-3 account: ${addr} (tx ${res.hash})`); + return addr; +} + +/** Invoke `post` as the account, signing the default-rule auth digest with `keys`. */ +async function postSignedBy( + account: string, + message: string, + keys: { publicKey: Uint8Array; secretKey: Uint8Array }[], + reuse?: xdr.SorobanTransactionData, +) { + const kp = sourceKeypair(); + const op = Operation.invokeContractFunction({ + contract: BOARD, + function: 'post', + args: [nativeToScVal(message, { type: 'string' }), Address.fromString(account).toScVal()], + }); + const tx = new TransactionBuilder(await server.getAccount(kp.publicKey()), { + fee: (Number(BASE_FEE) * 100).toString(), + networkPassphrase: NET, + }) + .addOperation(op) + .setTimeout(120) + .build(); + + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) return { ok: false, error: `record-sim: ${sim.error}` }; + const lastLedger = (await server.getLatestLedger()).sequence; + const assembled = rpc.assembleTransaction(tx, sim).build(); + + // M assertions over the one auth digest → one AuthPayload. + const entry = (assembled.operations[0] as Operation.InvokeHostFunction).auth![0]; + const authDigest = computeAuthDigest(buildAuthHash(entry, NET, lastLedger), [0]); + const signed = keys.map((k) => { + const a = buildSyntheticAssertion(k.secretKey, authDigest); + return { + kind: 'external' as const, + verifierAddress: VERIFIER, + publicKey: k.publicKey, + passkeySignature: { authenticatorData: a.authenticatorData, clientDataJson: a.clientDataJSON, signature: a.signature }, + }; + }); + injectSignedAuthPayload(assembled, signed, lastLedger, undefined, [0]); + + // Enforcing re-simulation runs __check_auth (multisig enforce over the threshold). + let sorobanData = reuse; + const sim2 = await server.simulateTransaction(assembled); + if (rpc.Api.isSimulationError(sim2)) { + const denied = /Denied|Threshold|UnauthorizedSigner|InsufficientSigners|#\d+/.test(sim2.error); + console.log(` post signed by ${keys.length} → ${denied ? 'DENIED (threshold)' : 'error'}: ${sim2.error.split('\n')[0]}`); + if (!reuse) return { ok: false, denied, error: sim2.error }; + } else { + sorobanData = sim2.transactionData.build(); + } + const resourceFee = Number((sim2 as rpc.Api.SimulateTransactionSuccessResponse).minResourceFee ?? 0); + const finalTx = TransactionBuilder.cloneFrom(assembled, { fee: (resourceFee + 2_000_000).toString() }) + .setSorobanData(sorobanData!) + .build(); + const res = await submit(finalTx, kp, `post/${keys.length}`); + return { ok: res.ok, hash: res.hash, status: res.status, sorobanData }; +} + +async function main() { + console.log('▶ deploying 2-of-3 account (multisig policy, threshold=2)…'); + const account = await deployAccount(); + + console.log('▶ MEETS threshold: post signed by owner + backup (2 of 3)'); + const two = await postSignedBy(account, '2-of-3 authorized this on-chain', [SIGNERS[0].kp, SIGNERS[1].kp]); + console.log(` → ${two.ok ? 'ALLOWED ✓' : 'denied ✕'} ${two.hash ?? ''} ${two.error ?? ''}`); + + console.log('▶ BELOW threshold: post signed by owner only (1 of 3)'); + const one = await postSignedBy(account, 'one signer should not pass', [SIGNERS[0].kp], two.sorobanData); + console.log(` → ${one.ok ? 'ALLOWED (unexpected!) ✗' : 'DENIED ✓'} ${one.hash ?? ''} ${one.status ?? ''}`); + + const verdict = two.ok && !one.ok; + const proof = { + account, + board: BOARD, + multisig: MULTISIG, + verifier: VERIFIER, + threshold: THRESHOLD, + signerCount: SIGNERS.length, + met: { hash: two.hash, ok: two.ok }, + below: { hash: one.hash, ok: one.ok, status: one.status }, + verdict, + }; + writeFileSync(OUT, JSON.stringify(proof, null, 2) + '\n'); + console.log(`\n${verdict ? '✅ PROVEN' : '❌ NOT proven'}: 2-of-3 ${verdict ? 'passed with 2 sigs and was denied with 1' : 'did not behave as expected'}`); + console.log(` wrote ${OUT}`); + process.exit(verdict ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/examples/perch-status-onchain/src/config.ts b/examples/perch-status-onchain/src/config.ts new file mode 100644 index 00000000..caa0e795 --- /dev/null +++ b/examples/perch-status-onchain/src/config.ts @@ -0,0 +1,46 @@ +// Deployed testnet pieces for the perch-on-chain demo. These are REAL contracts +// on Stellar testnet; the account below is governed by a perch policy that lets +// its secp256r1 "poster" key call board.post but not board.clear. +import { Networks } from '@stellar/stellar-sdk'; + +export const RPC_URL = 'https://soroban-testnet.stellar.org'; +export const NETWORK = Networks.TESTNET; +export const FRIENDBOT = 'https://friendbot.stellar.org'; + +export const CONTRACTS = { + /** perch interpreter (OZ Policy) — the contract that says allow/deny on-chain. */ + interpreter: 'CBO4FIGR2LP242IKWDME6NPFGCFAT5R7CSLKYLOOJFVXCCIGKVF6O44G', + /** status board: post(message, author) / clear(author) / get(author). */ + board: 'CBVXSCMALSZBF32OGUXIXFAFMPYFOJM4BOA27PBCMJPR6ZNUREX5ELWM', + /** real deployed WebAuthn verifier (secp256r1). */ + verifier: 'CACVGSAHYFBXY4LJKWW5B57LAAXHCZVDZOANUTYPLNV6HHQI4Q35EGMY', + /** the perch-governed Nido smart account. */ + account: 'CAZSVYNP52AGK66S3XIAW6HJDFLMXHH3IQECRNCWKHSPIXKMD4RBNMPV', +} as const; + +/** Deterministic seed for the demo's "poster" secp256r1 key (testnet only) — so + * the bundled account is stable and anyone can reproduce it. */ +export const POSTER_SEED = new Uint8Array(32).fill(7); + +/** The Default context rule the perch policy is installed on. */ +export const RULE_ID = 0; + +/** The M-of-N (Act 6) pieces: a separate account whose Default rule is a 2-of-3 + * via Nido's multisig policy. Deployed + proven by `scripts/prove-threshold.ts`; + * the demo drives its multi-signer ceremony live. */ +export const THRESHOLD = { + /** Nido multisig policy (SimpleThresholdAccountParams) — `unverified/multisig-policy`. */ + policy: 'CCSDKJYOFCPTCCGQZPF73RJNHFC7TPO532Q36N3M2VBYZFWQOTDB7J7G', + /** the deployed 2-of-3 account. */ + account: 'CCJLM2X6SDBX5QXFI7QCZ42Q3TAYWBWYA2IG56IUHLXRNQIKP4OU3GQL', + threshold: 2, + /** deterministic seeds for the three secp256r1 co-signers (fill 11/12/13). */ + signers: [ + { id: 'owner', seed: new Uint8Array(32).fill(11) }, + { id: 'backup', seed: new Uint8Array(32).fill(12) }, + { id: 'treasury', seed: new Uint8Array(32).fill(13) }, + ], +} as const; + +export const explorerTx = (h: string) => `https://stellar.expert/explorer/testnet/tx/${h}`; +export const explorerContract = (c: string) => `https://stellar.expert/explorer/testnet/contract/${c}`; diff --git a/examples/perch-status-onchain/src/main.ts b/examples/perch-status-onchain/src/main.ts new file mode 100644 index 00000000..443ec5e5 --- /dev/null +++ b/examples/perch-status-onchain/src/main.ts @@ -0,0 +1,629 @@ +// A guided tour: scope a CI key with perch, from a raw keypair to a policy the +// chain enforces. Five acts (pain → relief), styled in Nido's "Warm Nest". +// +// 1 One key, total power (the problem) +// 2 The account becomes a program (account abstraction) +// 3 OZ gives you the vocabulary (Signer + ContextRule + policies) +// 4 Nido makes it human (connect a real account) +// 5 perch: describe · prove · enforce (the payoff — live on testnet) +import { Keypair } from '@stellar/stellar-sdk'; +import { reachableCalls, isNarrowing, docHash, type PolicyDoc } from '@nidohq/testkit'; +import type { Fn, InvokeOutcome, ThresholdOutcome } from './perchOnchain.js'; +import { fundedFeeSource, invokeBoardCall, proveThreshold } from './perchOnchain.js'; +import { buildDoc, DEFAULT_BUILD, BOARD_FUNCTIONS, SIGNERS, RULES, MOFN_SIGNERS, MOFN_RULE, type SignerView, type RuleView, type PolicyKind, type BuildConfig } from './policyModel.js'; +import { CONTRACTS, THRESHOLD, explorerContract, explorerTx } from './config.js'; +import type { xdr } from '@stellar/stellar-sdk'; + +// ---------- state ---------- +const STEPS = [ + { n: 1, label: 'The problem' }, + { n: 2, label: 'Smart account' }, + { n: 3, label: 'OZ model' }, + { n: 4, label: 'Nido' }, + { n: 5, label: 'perch' }, + { n: 6, label: 'M-of-N' }, +]; +const LAST_STEP = STEPS.length; +const state = { + step: 1, + connected: false, + build: { functions: [...DEFAULT_BUILD.functions], selfArg: DEFAULT_BUILD.selfArg, notAfterLedger: DEFAULT_BUILD.notAfterLedger } as BuildConfig, + attnMsg: null as { ok: boolean; text: string } | null, + feeKp: null as Keypair | null, + busy: false, + lastAllowFootprint: undefined as xdr.SorobanTransactionData | undefined, + mofnFootprint: undefined as xdr.SorobanTransactionData | undefined, +}; + +const app = document.getElementById('app')!; +const el = (t: K, cls?: string, html?: string): HTMLElementTagNameMap[K] => { + const e = document.createElement(t); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; +}; +const short = (a: string) => (a.length > 16 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a); +const G_EXAMPLE = 'GBX...RELEASEBOT'; // illustrative + +// Render a JSON value as indented, syntax-highlighted HTML for a `.codebox`. +// Long C-address strings are shortened for readability (the doc_hash below is +// over the real canonical bytes, so shortening here is purely cosmetic). +const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); +function hjson(v: unknown, indent = 0): string { + const pad = ' '.repeat(indent); + const pad1 = ' '.repeat(indent + 1); + if (v === null) return 'null'; + if (typeof v === 'number' || typeof v === 'boolean') return `${v}`; + if (typeof v === 'string') { const t = v.length > 30 ? short(v) : v; return `"${esc(t)}"`; } + if (Array.isArray(v)) { + if (v.length === 0) return '[]'; + return `[\n${v.map((x) => pad1 + hjson(x, indent + 1)).join(',\n')}\n${pad}]`; + } + const o = v as Record; + const keys = Object.keys(o); + if (keys.length === 0) return '{}'; + const body = keys.map((k) => `${pad1}"${esc(k)}": ${hjson(o[k], indent + 1)}`).join(',\n'); + return `{\n${body}\n${pad}}`; +} + +function go(step: number): void { state.step = step; render(); } + +// ---------- brand ---------- +function nestRing(size = 30): string { + return ``; +} + +function topbar(): HTMLElement { + const t = el('div', 'topbar'); + const brand = el('div', 'brand'); + brand.append(el('span', 'mark', nestRing(30))); + brand.append(el('span', 'word', 'Nido')); + brand.append(el('span', 'sep', '·')); + brand.append(el('span', 'tag', 'perch guided tour')); + t.append(brand); + const scen = el('div', 'chip acc'); + scen.innerHTML = '🎯 give CI a key that ships releases, not the treasury'; + t.append(scen); + return t; +} + +function rail(): HTMLElement { + const r = el('div', 'rail'); + for (const s of STEPS) { + const st = el('button', 'st' + (s.n === state.step ? ' active' : s.n < state.step ? ' done' : '')); + st.type = 'button'; + st.append(el('span', 'n', String(s.n))); + st.append(el('span', 'lbl', s.label)); + st.addEventListener('click', () => go(s.n)); + r.append(st); + } + return r; +} + +// ---------- act helpers ---------- +function actHead(eyebrow: string, h1Html: string, ledeHtml: string): HTMLElement { + const d = el('div'); + d.append(el('div', 'eyebrow', eyebrow)); + d.append(el('h1', undefined, h1Html)); + d.append(el('p', 'lede', ledeHtml)); + return d; +} +function capItem(cls: string, mark: string, text: string): HTMLElement { + const li = el('li', cls); + li.append(el('span', 'mk', mark)); + li.append(el('span', undefined, text)); + return li; +} +function nav(): HTMLElement { + const n = el('div', 'nav'); + const back = el('button', 'btn ghost', '← Back'); back.type = 'button'; + back.disabled = state.step === 1; + back.addEventListener('click', () => go(Math.max(1, state.step - 1))); + n.append(back); + const next = el('button', 'btn acc', state.step === LAST_STEP ? 'Start over ↺' : 'Next →'); next.type = 'button'; + next.addEventListener('click', () => go(state.step === LAST_STEP ? 1 : state.step + 1)); + n.append(next); + return n; +} + +// ---------- Act 1 ---------- +function act1(): HTMLElement { + const a = el('div', 'act halo'); + a.append(actHead('The problem', 'One key. Total power over everything.', + 'A Stellar G-address is a single keypair. Whoever holds it can do anything the account can — send every last stroop, hand off ownership, sign any call. There is no "a little bit of authority."')); + const card = el('div', 'card stack'); + card.append(el('div', 'section-label', 'Hand this key to your release pipeline and it can:')); + const list = el('ul', 'caplist deny'); + for (const [m, t] of [['✓', 'publish a release'], ['✓', 'move the entire balance'], ['✓', 'change the admin / owner'], ['✓', 'delete history'], ['✓', 'anything the account can — forever']] as const) + list.append(capItem('', m, t)); + card.append(list); + card.append((() => { const p = el('div', 'addr'); p.style.marginTop = '.9rem'; p.textContent = `signing key: ${G_EXAMPLE}`; return p; })()); + a.append(card); + const al = el('div', 'alert danger'); + al.innerHTML = 'A leaked or misbehaving CI job = a drained treasury. And you can\'t hand back only part of the power. We need scoping.'; + a.append((() => { const w = el('div', 'stack'); w.append(al); return w; })()); + return a; +} + +// ---------- Act 2 ---------- +function act2(): HTMLElement { + const a = el('div', 'act'); + a.append(actHead('Account abstraction', 'Move to a smart account and auth becomes code.', + 'A Soroban smart account is a contract — a C-address. Instead of a fixed signature check, it runs your __check_auth on every call. Your code decides what "authorized" means.')); + const card = el('div', 'card stack'); + card.append(el('div', 'section-label', 'Every call runs through your account program')); + const flow = el('div', 'flow'); + flow.append(el('span', 'node mono', 'board.post(…)')); + flow.append(el('span', 'arw', '→')); + flow.append(el('span', 'node acc mono', 'account.__check_auth')); + flow.append(el('span', 'arw', '→')); + flow.append(el('span', 'node', '✓ allow / ✕ deny')); + card.append(flow); + const p = el('p'); p.style.marginTop = '.9rem'; p.style.color = 'var(--ink-soft)'; + p.innerHTML = 'Now you can have several keys with different powers, expiry, and limits. The open question: how do you express those rules — safely?'; + card.append(p); + a.append(card); + return a; +} + +// ---------- Act 3 ---------- +function act3(): HTMLElement { + const a = el('div', 'act'); + a.append(actHead('OpenZeppelin smart accounts', 'A standard vocabulary: Signers, Rules, Policies.', + 'OZ\'s stellar-accounts gives the account real structure. Every authorization is a ContextRule.')); + const g = el('div', 'grid2 stack'); + const anat = el('div', 'card'); + anat.append(el('h3', undefined, 'A ContextRule')); + anat.append(el('p', 'sub', 'which keys, over what scope, gated by which policies')); + const r = el('div', 'rule'); + r.append(el('div', 'rn', 'ci-can-publish')); + const row1 = el('div', 'rrow'); row1.append(el('span', 'rk', 'signers'), el('span', 'chip', 'External · verifier + key'), el('span', 'chip', 'Delegated · another account')); r.append(row1); + const row2 = el('div', 'rrow'); row2.append(el('span', 'rk', 'scope'), el('span', 'chip acc', 'a contract + function')); r.append(row2); + const row3 = el('div', 'rrow'); row3.append(el('span', 'rk', 'policies'), el('span', 'chip warn', 'attached contracts — the final say')); r.append(row3); + anat.append(r); + g.append(anat); + + const catchCard = el('div', 'card'); + catchCard.append(el('h3', undefined, 'The catch: you write the policy')); + catchCard.append(el('p', 'sub', 'To say "publish but not admin", you attach a policy — and unless a built-in fits, you write it. In Rust. Deploy it. Audit it.')); + const code = el('div', 'codebox'); + code.innerHTML = [ + '// a hand-written OZ Policy — you own every line', + 'fn enforce(e, ctx, signers, rule, account) {', + ' account.require_auth();', + ' if ctx.fn != "publish" { panic!("denied") }', + ' if ctx.args[1] != account { panic!("not self") }', + ' // …caps? expiry? multi-sig? all by hand', + '}', + ].join('\n'); + catchCard.append(code); + catchCard.append((() => { const c = el('div', 'codecap'); c.innerHTML = '⚠︎ INV-2 footgun: a deny-bug in a policy on your admin rule can lock you out of your own account. And every policy is new code on the critical path — to audit, per account.'; return c; })()); + g.append(catchCard); + a.append(g); + return a; +} + +// ---------- Act 4 ---------- +function verifierBadge(s: SignerView): HTMLElement { + const map: Record = { + 'secp256r1': ['chip good', 'secp256r1'], + 'ml-dsa-65': ['chip warn', 'ML-DSA-65 · post-quantum'], + 'delegated': ['chip acc', '→ another account'], + }; + const [cls, txt] = map[s.verifier]; + return el('span', cls, txt); +} +function signerCard(s: SignerView): HTMLElement { + const c = el('div', 'signer'); + const top = el('div', 'top'); + const left = el('span'); left.style.display = 'flex'; left.style.gap = '.45rem'; left.style.alignItems = 'center'; + left.append(el('span', 'id', s.label), el('span', 'chip', s.kind)); + top.append(left); + const badge = el('span'); badge.style.display = 'flex'; badge.style.gap = '.3rem'; badge.append(verifierBadge(s)); + if (s.status === 'sim') badge.append(el('span', 'chip', '· sim')); + top.append(badge); + c.append(top); + c.append((() => { const d = el('div', 'addr'); d.style.marginTop = '.45rem'; d.textContent = s.detail; return d; })()); + if (s.note) c.append(el('div', 'note', s.note)); + return c; +} +function act4(): HTMLElement { + const a = el('div', 'act'); + a.append(actHead('Nido', 'Nido makes smart accounts usable.', + 'Passkeys instead of seed phrases, a factory, social recovery, and a path from your old G-address. A Nido account holds keys across verifiers — even a post-quantum one — and can delegate to another account.')); + const card = el('div', 'card stack'); + if (!state.connected) { + card.append(el('div', 'section-label', 'Connect the account')); + card.append((() => { const p = el('p', 'sub'); p.textContent = 'A local-key Nido account — no passkey ceremony, no seed.'; return p; })()); + const b = el('button', 'btn acc', 'Connect Nido account'); b.type = 'button'; b.id = 'connect'; + b.addEventListener('click', () => { state.connected = true; render(); }); + card.append(b); + } else { + card.append(el('div', 'section-label', 'Nido smart account')); + const link = el('a', 'addr'); link.href = explorerContract(CONTRACTS.account); link.target = '_blank'; link.textContent = CONTRACTS.account; + card.append(link); + card.append((() => { const l = el('div', 'section-label'); l.style.marginTop = '.4rem'; l.textContent = 'Signers — one per verifier'; return l; })()); + const grid = el('div', 'sig'); + for (const s of SIGNERS) grid.append(signerCard(s)); + card.append(grid); + const ex = el('p', 'sub'); + ex.innerHTML = 'External signers carry a key a verifier contract checks (secp256r1 passkeys, post-quantum ML-DSA). Delegated signers point at another account that authorizes for you — a co-signer, recovery, a treasury.'; + card.append(ex); + const note = el('div', 'alert info'); + note.innerHTML = 'Keys across verifiers, a delegated treasury. But scoping the CI key — the problem from Act 3 — is still on you. That\'s perch.'; + card.append(note); + } + a.append(card); + return a; +} + +// ---------- Act 5 ---------- +function reachRows(doc: PolicyDoc): HTMLElement { + const reach = reachableCalls(doc); + const wrap = el('div'); + for (const rs of reach) { + const row = el('div', 'rrow'); + row.append(el('span', 'chip', rs.rule)); + row.append(el('span', 'arw', '→')); + row.append(el('span', 'chip acc', rs.scope === 'self-admin' ? 'self-admin' : short(rs.scope))); + // "any function" is the broad, dangerous reach — flag it red. + if (rs.functions.kind === 'any') row.append(el('span', 'chip bad', 'any function')); + else for (const f of rs.functions.functions) row.append(el('span', 'chip good', `${f}()`)); + wrap.append(row); + } + return wrap; +} + +function ptypeBadge(p: PolicyKind): HTMLElement { + const map: Record = { + 'policy-free': ['chip', 'policy-free'], + 'perch': ['chip acc', 'perch interpreter'], + 'spending-limit': ['chip warn', 'OZ spending-limit'], + 'm-of-n': ['chip warn', 'OZ multisig · M-of-N'], + }; + const [cls, txt] = map[p]; + return el('span', cls, txt); +} +// ---------- Act 5 · the policy builder ---------- +/** A toggle chip (checkbox affordance) that flips a boolean and re-renders. */ +function toggle(on: boolean, label: string, onFlip: () => void, danger = false): HTMLElement { + const b = el('button', `tog${on ? ' on' : ''}${on && danger ? ' risk' : ''}`); + b.type = 'button'; + b.innerHTML = `${on ? '✓' : ''}${label}`; + b.addEventListener('click', () => { onFlip(); state.attnMsg = null; render(); }); + return b; +} + +/** Left half of the builder: author the CI key's single rule. */ +function builderControls(): HTMLElement { + const c = el('div', 'card stack'); + c.append(el('h3', undefined, '① Build the policy')); + c.append(el('p', 'sub', 'A perch policy is data you author, not a contract you deploy. Toggle the grant — every change re-derives the wire document, its doc_hash, and exactly what the key can reach.')); + + // Signer + scope are fixed for the CI story — shown as context, not editable. + const ctx = el('div', 'stack'); + const r1 = el('div', 'rrow'); r1.append(el('span', 'rk', 'signer'), el('span', 'chip', 'CI key'), el('span', 'note2', 'the secp256r1 release key')); ctx.append(r1); + const r2 = el('div', 'rrow'); r2.append(el('span', 'rk', 'scope'), el('span', 'chip acc', short(CONTRACTS.board)), el('span', 'note2', 'the status board contract')); ctx.append(r2); + c.append(ctx); + + const label = (t: string): HTMLElement => { const l = el('div', 'section-label'); l.style.marginTop = '.8rem'; l.textContent = t; return l; }; + + c.append(label('Functions it may call')); + const fns = el('div', 'toggles'); + for (const f of BOARD_FUNCTIONS) { + fns.append(toggle(state.build.functions.includes(f.name), `${f.name}()`, () => { + const set = new Set(state.build.functions); + if (set.has(f.name)) set.delete(f.name); else set.add(f.name); + state.build.functions = BOARD_FUNCTIONS.map((x) => x.name).filter((n) => set.has(n)); + }, f.risky)); + } + c.append(fns); + + c.append(label('Argument guard')); + const ag = el('div', 'toggles'); + ag.append(toggle(state.build.selfArg, 'author = self · args[1]', () => { state.build.selfArg = !state.build.selfArg; })); + c.append(ag); + + c.append(label('Expiry')); + const expOn = state.build.notAfterLedger != null; + const ex = el('div', 'toggles'); + ex.append(toggle(expOn, 'expires at a ledger', () => { state.build.notAfterLedger = expOn ? null : 60_000_000; })); + c.append(ex); + if (expOn) { + const fld = el('div', 'fld'); fld.style.marginTop = '.5rem'; + fld.append(el('label', undefined, 'not-after-ledger')); + const inp = el('input', 'input') as HTMLInputElement; + inp.type = 'number'; inp.min = '1'; inp.value = String(state.build.notAfterLedger ?? 60_000_000); inp.id = 'expiry'; + inp.addEventListener('change', () => { const n = parseInt(inp.value, 10); state.build.notAfterLedger = Number.isFinite(n) && n > 0 ? n : null; render(); }); + fld.append(inp); + c.append(fld); + } + return c; +} + +/** Dynamic safety read of the current build — the "is this a good grant?" voice. */ +function safetyAlerts(): HTMLElement | null { + const b = state.build; + const wrap = el('div', 'stack'); + const push = (kind: string, html: string) => { const a = el('div', `alert ${kind}`); a.innerHTML = html; wrap.append(a); }; + if (b.functions.length === 0) + push('danger', 'No function selected — the rule omits functions, which means any function on the board. Broader, not narrower. Pick at least post.'); + else if (b.functions.includes('clear')) + push('warn', 'Over-broad — the key can also clear() (wipe history). Narrow to publish-only below.'); + if (!b.selfArg) + push('warn', 'Any author — without args[1] = self the key can post as anyone. Turn the self-guard on.'); + if (b.functions.length === 1 && b.functions[0] === 'post' && b.selfArg) + push('good', 'Tightly scoped — post() only, author = self. This is the grant the chain enforces in ③.'); + return wrap.childElementCount ? wrap : null; +} + +/** Right half of the builder: the live PolicyDoc, its hash, and its reach. */ +function policyOutput(): HTMLElement { + const doc = buildDoc(state.build); + const c = el('div', 'card stack'); + c.append(el('h3', undefined, 'The policy, as data')); + c.append(el('p', 'sub', 'The exact PolicyDoc — kebab-case wire shape. What a reviewer reads, and what the doc_hash commits to.')); + const box = el('div', 'codebox'); box.innerHTML = hjson(doc); c.append(box); + const dh = el('div', 'addr'); dh.style.marginTop = '.7rem'; dh.id = 'doc-hash'; dh.textContent = `doc_hash ${docHash(doc)}`; c.append(dh); + const l = el('div', 'section-label'); l.style.marginTop = '.7rem'; l.textContent = 'Reachable calls — everything the key can touch'; c.append(l); + c.append(reachRows(doc)); + const sa = safetyAlerts(); if (sa) c.append(sa); + return c; +} + +function act5(): HTMLElement { + const a = el('div', 'act'); + a.append(actHead('perch', 'Build the policy. Prove it\'s safe. Watch it enforce.', + 'perch is a policy you write as data, compiled to one tiny interpreter that\'s audited once. No per-account Rust. And the same doc_hash you review is the program the chain runs.')); + + // full policy view — perch composed with OZ-native policies (the panel reused + // in Act 6, so the picture updates as a quorum rule is added there). + a.append(policyPanel( + 'The account\'s full policy', + 'Several rules. perch handles function/arg scoping (the CI rule); it composes with OZ-native policies — a spend cap on the treasury, policy-free admin (INV-2), a post-quantum co-signer.', + SIGNERS, + RULES, + )); + + // ① build it — the interactive policy builder (controls + live document) + const build = el('div', 'grid2 stack'); + build.append(builderControls(), policyOutput()); + a.append(build); + + // ② attenuate — narrowing accepted, widening refused, machine-checked over + // whatever the builder currently holds. + const d2 = el('div', 'card stack'); + d2.append(el('h3', undefined, '② Prove it only narrows')); + d2.append(el('p', 'sub', 'Attenuation is a machine-checked subset: perch accepts a narrowing and refuses a widening — reachable(child) ⊆ reachable(parent), read straight off the compiled programs.')); + const isPublishOnly = state.build.functions.length === 1 && state.build.functions[0] === 'post'; + const btns = el('div'); btns.style.display = 'flex'; btns.style.gap = '.6rem'; btns.style.flexWrap = 'wrap'; + const narrow = el('button', 'btn sm acc', 'Narrow → publish-only'); narrow.type = 'button'; narrow.id = 'narrow'; + narrow.disabled = isPublishOnly; + narrow.addEventListener('click', () => { + const check = isNarrowing(buildDoc(state.build), buildDoc({ ...state.build, functions: ['post'] })); + if (check.ok) { state.build.functions = ['post']; state.attnMsg = { ok: true, text: 'Verified narrowing — reachable(child) ⊆ reachable(parent). doc_hash updated.' }; } + else state.attnMsg = { ok: false, text: check.reason ?? 'refused' }; + render(); + }); + const widen = el('button', 'btn sm ghost', 'Try to widen → add set_admin'); widen.type = 'button'; widen.id = 'widen'; + widen.addEventListener('click', () => { + const check = isNarrowing(buildDoc(state.build), buildDoc({ ...state.build, functions: [...state.build.functions, 'set_admin'] })); + state.attnMsg = check.ok ? { ok: true, text: 'widened' } : { ok: false, text: `Refused — not a narrowing. ${check.reason ?? 'adds set_admin()'}. The grant is unchanged.` }; + render(); + }); + btns.append(narrow, widen); + d2.append(btns); + if (state.attnMsg) { + const al = el('div', `alert ${state.attnMsg.ok ? 'good' : 'danger'}`); + al.innerHTML = `${state.attnMsg.ok ? '✓' : '✕'}${state.attnMsg.text}`; + d2.append(al); + } + a.append(d2); + + // 5c enforce on-chain + const d3 = el('div', 'card stack'); + d3.append(el('h3', undefined, '③ Enforce it — on real testnet')); + d3.append(el('p', 'sub', 'The scoped policy is deployed on-chain. Drive the CI key for real — the perch interpreter says yes or no.')); + const row = el('div', 'grid2'); + row.append(oncard('post', 'Publish a release', 'In policy — the CI key may post(message, self).', 'btn acc', 'Publish → expect ALLOW')); + row.append(oncard('clear', 'Wipe history', 'Out of policy — perch refuses clear(self).', 'btn ghost', 'Wipe → expect DENY')); + d3.append(row); + a.append(d3); + + // payoff + const pay = el('div', 'bn stack'); + const before = el('div', 'col'); + before.innerHTML = '
Hand-written policy
  • Rust per account — write, deploy, audit each one.
  • INV-2 footgun — a deny-bug can brick admin.
  • Opaque — no proof of what a key can reach.
  • Trust that a change didn\'t widen power.
'; + const now = el('div', 'col now'); + now.innerHTML = '
With perch
  • Data, not code — one interpreter, audited once.
  • Safe by construction — INV-1/INV-2, fail-closed.
  • Analyzable — reachable-calls answers "can it ever?"
  • Attenuation is proof — narrowing is machine-checked.
  • doc_hash = what enforces — no drift, on-chain.
'; + pay.append(before, now); + a.append(pay); + return a; +} + +// ---------- policy visualization (a signers × rules panel) ---------- +/** The account's current policy, rendered: signer legend + a rules matrix + * (who authorizes each rule, via which policy, and what it reaches). Reused + * across acts, so the picture updates as the tour adds rules. */ +function policyPanel(title: string, sub: string, signers: SignerView[], rules: RuleView[]): HTMLElement { + const c = el('div', 'card stack'); + c.append(el('h3', undefined, title)); + if (sub) c.append(el('p', 'sub', sub)); + + const leg = el('div', 'policy-signers'); + for (const s of signers) { + const chip = el('span', `psig${s.status === 'sim' ? ' sim' : ''}`); + chip.append(el('span', 'psig-id', s.label)); + chip.append(verifierBadge(s)); + leg.append(chip); + } + c.append(leg); + + const tbl = el('div', 'policy-matrix'); + const head = el('div', 'pm-row pm-head'); + head.append(el('span', 'pm-c', 'rule'), el('span', 'pm-c', 'who authorizes'), el('span', 'pm-c', 'via'), el('span', 'pm-c', 'reaches')); + tbl.append(head); + for (const r of rules) { + const row = el('div', `pm-row${r.onchain ? ' on' : ''}${r.status === 'sim' ? ' sim' : ''}`); + const name = el('span', 'pm-c pm-name'); + name.append(el('b', undefined, r.name)); + if (r.policy === 'perch') name.append(el('span', 'star', '★')); + if (r.onchain) name.append(el('span', 'on-mark', 'on-chain')); + else if (r.status === 'sim') name.append(el('span', 'chip', 'sim')); + row.append(name); + const who = el('span', 'pm-c pm-who'); + if (r.policy === 'm-of-n') who.append(el('span', 'quorum', `${THRESHOLD.threshold} of ${r.signers.length} ·`)); + for (const s of r.signers) who.append(el('span', 'chip', s)); + row.append(who); + const via = el('span', 'pm-c'); via.append(ptypeBadge(r.policy)); row.append(via); + row.append(el('span', 'pm-c pm-reach', r.reach)); + tbl.append(row); + } + c.append(tbl); + return c; +} + +// ---------- Act 6 · adding signers, M-of-N ---------- +function thresholdCard(keyCount: number, title: string, desc: string, expect: 'allow' | 'deny'): HTMLElement { + const c = el('div', 'card'); + c.append(el('h3', undefined, title)); + c.append(el('p', 'sub', desc)); + const b = el('button', `btn ${expect === 'allow' ? 'acc' : 'ghost'}`, expect === 'allow' ? `Sign with ${keyCount} → expect ALLOW` : `Sign with ${keyCount} → expect DENY`); + b.type = 'button'; b.id = `mofn-${keyCount}`; b.disabled = state.busy; + b.addEventListener('click', () => runThreshold(keyCount)); + c.append(b); + c.append((() => { const s = el('div'); s.id = `res-mofn-${keyCount}`; s.style.marginTop = '.7rem'; return s; })()); + return c; +} + +function act6(): HTMLElement { + const a = el('div', 'act'); + a.append(actHead('M-of-N', 'Add signers. Require a quorum.', + 'A Nido account holds many signers — and a rule can require several of them to agree. Attach the OZ multisig policy to a rule and it becomes M-of-N: no single key is enough. perch scopes what a key may do; the threshold policy governs how many must sign — composed on one account.')); + + a.append(policyPanel( + 'The account, now with a quorum', + 'Three secp256r1 co-signers on the Default rule, gated by Nido’s multisig policy at threshold 2 — a live 2-of-3 on testnet.', + MOFN_SIGNERS, + [MOFN_RULE], + )); + + const acct = el('p', 'sub'); + const link = el('a', 'mono'); (link as HTMLAnchorElement).href = explorerContract(THRESHOLD.account); (link as HTMLAnchorElement).target = '_blank'; + link.textContent = short(THRESHOLD.account); + acct.append(document.createTextNode('Deployed 2-of-3 account: '), link, document.createTextNode(' · policy ')); + const plink = el('a', 'mono'); (plink as HTMLAnchorElement).href = explorerContract(THRESHOLD.policy); (plink as HTMLAnchorElement).target = '_blank'; + plink.textContent = 'nido multisig'; + acct.append(plink); + a.append(acct); + + const d = el('div', 'card stack'); + d.append(el('h3', undefined, 'Prove the threshold — on real testnet')); + d.append(el('p', 'sub', 'Drive the account’s Default rule for real. Two of the three co-signers meet the quorum; one alone does not — the multisig policy’s enforce says so on-chain.')); + const row = el('div', 'grid2'); + row.append(thresholdCard(2, 'Two signers agree', 'owner + backup co-sign one post — 2 of 3.', 'allow')); + row.append(thresholdCard(1, 'One signer alone', 'owner signs by themselves — below the threshold.', 'deny')); + d.append(row); + a.append(d); + + const pay = el('div', 'bn stack'); + const l = el('div', 'col'); + l.innerHTML = '
Adding signers
  • Hold keys across verifiers — passkeys, post-quantum, delegated.
  • A rule with N signers is N-of-N by default (all must sign).
  • Attach the multisig policy to make it M-of-N.
'; + const r2 = el('div', 'col now'); + r2.innerHTML = '
Composed on one account
  • perchwhat each key may do (function/arg scope), audited once.
  • multisighow many must sign (M-of-N quorum).
  • Both are just policies on ContextRules — no bespoke account code.
  • Every verdict is the chain’s, in __check_auth.
'; + pay.append(l, r2); + a.append(pay); + return a; +} + +async function runThreshold(keyCount: number): Promise { + if (state.busy) return; + state.busy = true; + const setBtns = (dis: boolean) => { for (const k of [1, 2]) { const b = document.getElementById(`mofn-${k}`) as HTMLButtonElement | null; if (b) b.disabled = dis; } }; + setBtns(true); + setMofnResult(keyCount, 'info', ['Preparing…']); + try { + if (!state.feeKp) { setMofnResult(keyCount, 'info', ['Funding an ephemeral fee account (friendbot)…']); state.feeKp = await fundedFeeSource(); } + // The below-threshold case borrows the 2-of-3 footprint to land a real failed tx. + const reuse = keyCount < THRESHOLD.threshold ? state.mofnFootprint : undefined; + const out: ThresholdOutcome = await proveThreshold(state.feeKp, keyCount, reuse, (s) => setMofnResult(keyCount, 'info', [s])); + if (keyCount >= THRESHOLD.threshold && out.ok) state.mofnFootprint = out.sorobanData; + if (out.ok) setMofnResult(keyCount, 'good', [`✓ Quorum met — ${keyCount} of 3 authorized on-chain.`, txLink(out.hash)]); + else setMofnResult(keyCount, 'danger', [`✕ Denied on-chain — ${out.reason ?? 'threshold not met'}`, out.hash ? txLink(out.hash) : '(rejected at enforcing simulation — the multisig verdict)']); + } catch (e) { + setMofnResult(keyCount, 'danger', [`Error: ${(e as Error).message}`]); + } finally { + state.busy = false; + setBtns(false); + } +} +function setMofnResult(keyCount: number, kind: 'good' | 'danger' | 'info', lines: (string | HTMLElement)[]): void { + const slot = document.getElementById(`res-mofn-${keyCount}`); if (!slot) return; + const al = el('div', `alert ${kind}${kind === 'danger' ? ' shake' : ''}`); + const body = el('span'); + lines.forEach((l, i) => { if (i) body.append(el('br')); body.append(typeof l === 'string' ? document.createTextNode(l) : l); }); + al.append(el('span', 'ic', kind === 'good' ? '✓' : kind === 'danger' ? '✕' : '·'), body); + slot.replaceChildren(al); +} + +function oncard(fn: Fn, title: string, desc: string, btnCls: string, btnLabel: string): HTMLElement { + const c = el('div', 'card'); + c.append(el('h3', undefined, title)); + c.append(el('p', 'sub', desc)); + const b = el('button', btnCls, btnLabel); b.type = 'button'; b.id = `on-${fn}`; + b.disabled = state.busy; + b.addEventListener('click', () => runOnchain(fn)); + c.append(b); + c.append((() => { const s = el('div'); s.id = `res-${fn}`; s.style.marginTop = '.7rem'; return s; })()); + return c; +} + +async function runOnchain(fn: Fn): Promise { + if (state.busy) return; + state.busy = true; + setOnResult(fn, 'info', ['Preparing…']); + const setBtns = (d: boolean) => { for (const f of ['post', 'clear']) { const b = document.getElementById(`on-${f}`) as HTMLButtonElement | null; if (b) b.disabled = d; } }; + setBtns(true); + try { + if (!state.feeKp) { setOnResult(fn, 'info', ['Funding an ephemeral fee account (friendbot)…']); state.feeKp = await fundedFeeSource(); } + const msg = fn === 'post' ? 'shipped v1.0.0 via a perch-scoped CI key' : null; + const reuse = fn === 'clear' ? state.lastAllowFootprint : undefined; + const out: InvokeOutcome = await invokeBoardCall(state.feeKp, fn, msg, reuse, (s) => setOnResult(fn, 'info', [s])); + if (fn === 'post' && out.ok) state.lastAllowFootprint = out.sorobanData; + if (out.ok) setOnResult(fn, 'good', ['✓ Authorized on-chain — perch enforce returned.', txLink(out.hash)]); + else setOnResult(fn, 'danger', [`✕ Denied on-chain — ${out.reason ?? 'refused'}`, out.hash ? txLink(out.hash) : '(rejected at enforcing simulation — the chain\'s __check_auth verdict)']); + } catch (e) { + setOnResult(fn, 'danger', [`Error: ${(e as Error).message}`]); + } finally { + state.busy = false; + setBtns(false); + } +} + +function txLink(hash?: string): HTMLElement { + if (!hash) return el('span', undefined, 'submitted'); + const a = el('a'); (a as HTMLAnchorElement).href = explorerTx(hash); (a as HTMLAnchorElement).target = '_blank'; + a.className = 'mono'; a.textContent = `tx ${short(hash)} ↗`; + return a; +} +function setOnResult(fn: Fn, kind: 'good' | 'danger' | 'info', lines: (string | HTMLElement)[]): void { + const slot = document.getElementById(`res-${fn}`); if (!slot) return; + const al = el('div', `alert ${kind}${kind === 'danger' ? ' shake' : ''}`); + const body = el('span'); + lines.forEach((l, i) => { if (i) body.append(el('br')); body.append(typeof l === 'string' ? document.createTextNode(l) : l); }); + al.append(el('span', 'ic', kind === 'good' ? '✓' : kind === 'danger' ? '✕' : '·'), body); + slot.replaceChildren(al); +} + +// ---------- render ---------- +const ACTS: Record HTMLElement> = { 1: act1, 2: act2, 3: act3, 4: act4, 5: act5, 6: act6 }; +function render(): void { + app.replaceChildren(); + const stage = el('div', 'stage'); + stage.append(topbar(), rail()); + stage.append(ACTS[state.step]!()); + stage.append(nav()); + app.append(stage); +} +render(); +(window as unknown as { __tour: unknown }).__tour = { go, state }; diff --git a/examples/perch-status-onchain/src/perchOnchain.ts b/examples/perch-status-onchain/src/perchOnchain.ts new file mode 100644 index 00000000..4a63d1a7 --- /dev/null +++ b/examples/perch-status-onchain/src/perchOnchain.ts @@ -0,0 +1,241 @@ +// The proven on-chain invoke flow, browser-ready. Drives a real testnet call +// authorized by a perch-governed Nido account with a LOCAL secp256r1 key: +// +// build → recording-simulate → sign the account's OZ AuthPayload with the +// poster key → RE-simulate the SIGNED tx (enforcing mode runs __check_auth: +// webauthn verify + perch enforce, capturing their footprint the recording +// pass omits) → submit with that footprint. +// +// The DENY case surfaces at the enforcing re-simulation as the interpreter's +// `Denied` — the same verdict the chain reaches — and we still submit it (with +// the allow case's footprint) to land a real, cleanly-failed on-chain tx. +import { + Address, + BASE_FEE, + Keypair, + Networks, + Operation, + TransactionBuilder, + nativeToScVal, + rpc, + xdr, +} from '@stellar/stellar-sdk'; +import { buildSyntheticAssertion, computeAuthDigest, secp256r1Keypair } from '@nidohq/testkit'; +import { buildAuthHash, injectPasskeySignature, injectSignedAuthPayload } from '@nidohq/passkey-sdk'; +import { CONTRACTS, NETWORK, POSTER_SEED, RPC_URL, RULE_ID, THRESHOLD } from './config.js'; + +export type Fn = 'post' | 'clear'; +export interface InvokeOutcome { + fn: Fn; + ok: boolean; + denied: boolean; + hash?: string; + /** on-chain reason (perch's Denied error, or a validation message). */ + reason?: string; + sorobanData?: xdr.SorobanTransactionData; +} + +export interface Progress { + (step: string): void; +} + +export const server = new rpc.Server(RPC_URL); +export const poster = secp256r1Keypair(POSTER_SEED); + +/** Generate + friendbot-fund an ephemeral classic account to pay fees. */ +export async function fundedFeeSource(note: Progress = () => {}): Promise { + const kp = Keypair.random(); + note('Funding an ephemeral fee account (friendbot)…'); + const res = await fetch(`https://friendbot.stellar.org/?addr=${kp.publicKey()}`); + if (!res.ok && res.status !== 400) throw new Error(`friendbot: ${res.status}`); + // Poll until the account is visible to RPC. + for (let i = 0; i < 10; i++) { + try { + await server.getAccount(kp.publicKey()); + return kp; + } catch { + await new Promise((r) => setTimeout(r, 1000)); + } + } + throw new Error('fee account never materialized'); +} + +function externalSigner(verifier: string, pubkey: Uint8Array): xdr.ScVal { + return xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol('External'), + Address.fromString(verifier).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(pubkey)), + ]); +} +// (exported for the deploy script's constructor args) +export { externalSigner }; + +async function poll(hash: string): Promise { + return server.pollTransaction(hash, { attempts: 15, sleepStrategy: () => 2000 }); +} + +/** + * Invoke board. authorized by the perch-governed account. + * `reuseSorobanData` lets the deny case borrow the allow case's footprint. + */ +export async function invokeBoardCall( + feeKp: Keypair, + fn: Fn, + message: string | null, + reuseSorobanData?: xdr.SorobanTransactionData, + note: Progress = () => {}, +): Promise { + const account = CONTRACTS.account; + const args = + fn === 'post' + ? [nativeToScVal(message ?? '', { type: 'string' }), Address.fromString(account).toScVal()] + : [Address.fromString(account).toScVal()]; + const op = Operation.invokeContractFunction({ contract: CONTRACTS.board, function: fn, args }); + + note('Recording-simulating…'); + const src = await server.getAccount(feeKp.publicKey()); + const tx = new TransactionBuilder(src, { fee: (Number(BASE_FEE) * 100).toString(), networkPassphrase: NETWORK }) + .addOperation(op) + .setTimeout(120) + .build(); + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) return { fn, ok: false, denied: false, reason: `simulation: ${sim.error}` }; + + const lastLedger = (await server.getLatestLedger()).sequence; + const assembled = rpc.assembleTransaction(tx, sim).build(); + + note('Signing the account’s authorization with the local secp256r1 key…'); + const entry = (assembled.operations[0] as Operation.InvokeHostFunction).auth![0]!; + const authDigest = computeAuthDigest(buildAuthHash(entry, NETWORK, lastLedger), [RULE_ID]); + const a = buildSyntheticAssertion(poster.secretKey, authDigest); + injectPasskeySignature( + assembled, + { authenticatorData: a.authenticatorData, clientDataJson: a.clientDataJSON, signature: a.signature }, + CONTRACTS.verifier, + poster.publicKey, + lastLedger, + undefined, + [RULE_ID], + ); + + note('Enforcing re-simulation (runs __check_auth: verifier + perch on-chain)…'); + let sorobanData = reuseSorobanData; + const sim2 = await server.simulateTransaction(assembled); + if (rpc.Api.isSimulationError(sim2)) { + const reason = perchReason(sim2.error); + if (!reuseSorobanData) return { fn, ok: false, denied: true, reason }; + // else: land a real failed tx with the borrowed footprint. + } else { + sorobanData = sim2.transactionData.build(); + } + const resourceFee = Number((sim2 as rpc.Api.SimulateTransactionSuccessResponse).minResourceFee ?? 0); + + note('Submitting to testnet…'); + const finalTx = TransactionBuilder.cloneFrom(assembled, { fee: (resourceFee + 2_000_000).toString() }) + .setSorobanData(sorobanData!) + .build(); + finalTx.sign(feeKp); + const sent = await server.sendTransaction(finalTx); + if (sent.status === 'ERROR') return { fn, ok: false, denied: fn === 'clear', hash: sent.hash, reason: 'send error', sorobanData }; + const final = await poll(sent.hash); + const ok = final.status === 'SUCCESS'; + return { fn, ok, denied: !ok && fn === 'clear', hash: sent.hash, reason: ok ? undefined : 'reverted in __check_auth (perch Denied)', sorobanData }; +} + +/** The three secp256r1 co-signers of the 2-of-3 account (deterministic seeds). */ +const thresholdKeys = THRESHOLD.signers.map((s) => secp256r1Keypair(s.seed)); + +export interface ThresholdOutcome { + keyCount: number; + ok: boolean; + denied: boolean; + hash?: string; + reason?: string; + sorobanData?: xdr.SorobanTransactionData; +} + +/** + * Drive the 2-of-3 account's Default rule live: `post` signed by `keyCount` of + * the three co-signers. `keyCount >= threshold` → the multisig policy passes; + * fewer → it denies. Same proven flow as `invokeBoardCall`, but M assertions + * over the one auth digest go into a single AuthPayload (`injectSignedAuthPayload`). + * `reuseSorobanData` lets the below-threshold case borrow the passing footprint + * so it still lands a real, cleanly-failed on-chain tx. + */ +export async function proveThreshold( + feeKp: Keypair, + keyCount: number, + reuseSorobanData?: xdr.SorobanTransactionData, + note: Progress = () => {}, +): Promise { + const account = THRESHOLD.account; + const message = + keyCount >= THRESHOLD.threshold ? `${keyCount}-of-3 authorized this on-chain` : `${keyCount} signer is below the 2-of-3 threshold`; + const op = Operation.invokeContractFunction({ + contract: CONTRACTS.board, + function: 'post', + args: [nativeToScVal(message, { type: 'string' }), Address.fromString(account).toScVal()], + }); + + note('Recording-simulating…'); + const src = await server.getAccount(feeKp.publicKey()); + const tx = new TransactionBuilder(src, { fee: (Number(BASE_FEE) * 100).toString(), networkPassphrase: NETWORK }) + .addOperation(op) + .setTimeout(120) + .build(); + const sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) return { keyCount, ok: false, denied: false, reason: `simulation: ${sim.error}` }; + + const lastLedger = (await server.getLatestLedger()).sequence; + const assembled = rpc.assembleTransaction(tx, sim).build(); + + note(`Signing with ${keyCount} of 3 co-signers…`); + const entry = (assembled.operations[0] as Operation.InvokeHostFunction).auth![0]!; + const authDigest = computeAuthDigest(buildAuthHash(entry, NETWORK, lastLedger), [RULE_ID]); + const signed = thresholdKeys.slice(0, keyCount).map((kp) => { + const a = buildSyntheticAssertion(kp.secretKey, authDigest); + return { + kind: 'external' as const, + verifierAddress: CONTRACTS.verifier, + publicKey: kp.publicKey, + passkeySignature: { authenticatorData: a.authenticatorData, clientDataJson: a.clientDataJSON, signature: a.signature }, + }; + }); + injectSignedAuthPayload(assembled, signed, lastLedger, undefined, [RULE_ID]); + + note('Enforcing re-simulation (runs __check_auth: multisig threshold on-chain)…'); + let sorobanData = reuseSorobanData; + const sim2 = await server.simulateTransaction(assembled); + if (rpc.Api.isSimulationError(sim2)) { + const reason = thresholdReason(sim2.error); + if (!reuseSorobanData) return { keyCount, ok: false, denied: true, reason }; + } else { + sorobanData = sim2.transactionData.build(); + } + const resourceFee = Number((sim2 as rpc.Api.SimulateTransactionSuccessResponse).minResourceFee ?? 0); + + note('Submitting to testnet…'); + const finalTx = TransactionBuilder.cloneFrom(assembled, { fee: (resourceFee + 2_000_000).toString() }) + .setSorobanData(sorobanData!) + .build(); + finalTx.sign(feeKp); + const sent = await server.sendTransaction(finalTx); + const belowThreshold = keyCount < THRESHOLD.threshold; + if (sent.status === 'ERROR') return { keyCount, ok: false, denied: belowThreshold, hash: sent.hash, reason: 'send error', sorobanData }; + const final = await poll(sent.hash); + const ok = final.status === 'SUCCESS'; + return { keyCount, ok, denied: !ok && belowThreshold, hash: sent.hash, reason: ok ? undefined : 'reverted in __check_auth (threshold not met)', sorobanData }; +} + +function thresholdReason(err: string): string { + if (/InvalidAction|Threshold|InsufficientSigners|Denied|Auth|#\d+/i.test(err)) + return 'multisig enforce → threshold not met (need 2 of 3 signatures)'; + return err.split('\n')[0] ?? err; +} + +/** Map a raw enforcing-sim error to a human line — perch's Denied is contract error #1. */ +function perchReason(err: string): string { + if (/InvalidAction|Denied|#1\b|contract, code:? ?1|Auth/i.test(err)) + return 'perch enforce → Denied (function not in the policy’s allowed set)'; + return err.split('\n')[0] ?? err; +} diff --git a/examples/perch-status-onchain/src/policyModel.ts b/examples/perch-status-onchain/src/policyModel.ts new file mode 100644 index 00000000..2f8d53f7 --- /dev/null +++ b/examples/perch-status-onchain/src/policyModel.ts @@ -0,0 +1,127 @@ +// The account the tour visualizes: signers across every verifier (secp256r1, +// post-quantum ML-DSA-65, and a Delegated "another account"), and its full +// policy as several rules — showing how perch composes with OZ-native policies. +// The CI key's rule is the one actually enforced on-chain in Act 5. +import { contract, isSelf, rule, secp256r1Keypair, mlDsa65Keypair, TESTNET_PASSPHRASE, type PolicyDoc } from '@nidohq/testkit'; +import { CONTRACTS, THRESHOLD } from './config.js'; +import { poster } from './perchOnchain.js'; + +const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +const short = (s: string) => (s.length > 18 ? `${s.slice(0, 9)}…${s.slice(-6)}` : s); + +export const POSTER_KEY_HEX = hex(poster.publicKey); +// Distinct, deterministic keys so the account is stable/reproducible. +const owner = secp256r1Keypair(new Uint8Array(32).fill(3)); +const pq = mlDsa65Keypair(new Uint8Array(32).fill(9)); +// A real testnet G-account, used illustratively as a delegated co-signer. +export const TREASURY_G = 'GA327GGWT6747B57DRWJJ3SWBVIQ354TTDRHR76CVAWO6OBPZ4Z57YGA'; + +export type VerifierKind = 'secp256r1' | 'ml-dsa-65' | 'delegated'; +export interface SignerView { + id: string; + label: string; + verifier: VerifierKind; + kind: 'External' | 'Delegated'; + detail: string; + status: 'live' | 'sim'; + note?: string; +} + +/** Every signer type a Nido account can hold. */ +export const SIGNERS: SignerView[] = [ + { id: 'owner', label: 'Owner passkey', verifier: 'secp256r1', kind: 'External', detail: `key ${short(hex(owner.publicKey))}`, status: 'live', note: 'WebAuthn / passkey — the human owner' }, + { id: 'ci', label: 'CI key', verifier: 'secp256r1', kind: 'External', detail: `key ${short(POSTER_KEY_HEX)}`, status: 'live', note: 'scoped by perch → enforced on-chain in Act 5' }, + { id: 'pq', label: 'Post-quantum key', verifier: 'ml-dsa-65', kind: 'External', detail: `key ${short(hex(pq.publicKey))} (${pq.publicKey.length} B)`, status: 'sim', note: 'ML-DSA-65 — quantum-safe; verifier groundwork #143' }, + { id: 'treasury', label: 'Treasury account', verifier: 'delegated', kind: 'Delegated', detail: `account ${short(TREASURY_G)}`, status: 'live', note: 'Delegated → another G-account authorizes on the account’s behalf' }, +]; + +export type PolicyKind = 'policy-free' | 'perch' | 'spending-limit' | 'm-of-n'; +export interface RuleView { + name: string; + signers: string[]; + scope: string; + policy: PolicyKind; + reach: string; + status: 'live' | 'sim'; + onchain?: boolean; +} + +/** The account's full policy: perch composed with OZ-native policies. */ +export const RULES: RuleView[] = [ + { name: 'owner-root', signers: ['owner'], scope: 'self-admin', policy: 'policy-free', reach: 'any admin op — rides OZ’s audited signer check (INV-2)', status: 'live' }, + { name: 'ci-can-publish', signers: ['ci'], scope: 'status board', policy: 'perch', reach: 'post() · author = self', status: 'live', onchain: true }, + { name: 'pq-cosign-admin', signers: ['pq'], scope: 'self-admin', policy: 'policy-free', reach: 'admin, post-quantum signature', status: 'sim' }, + { name: 'treasury-cap', signers: ['treasury'], scope: 'XLM token', policy: 'spending-limit', reach: 'transfer() ≤ 100 XLM / day', status: 'sim' }, +]; + +/** Everything the Act-5 builder can vary about the CI key's single rule. */ +export interface BuildConfig { + /** Allowed functions. Empty ⇒ the rule OMITS `functions`, which means *any* + * function — broader, not narrower (perch rejects an explicit empty list). */ + functions: string[]; + /** Attach `args[1] = self`, so the key can only post as the account itself. */ + selfArg: boolean; + /** OZ `valid_until` expiry (perch `not-after-ledger`); null ⇒ never expires. */ + notAfterLedger: number | null; +} + +/** The status board's write functions the CI key can be granted. `clear` is the + * dangerous one (wipes history) — the over-broad grant to narrow away. */ +export const BOARD_FUNCTIONS: { name: string; risky?: boolean }[] = [ + { name: 'post' }, + { name: 'clear', risky: true }, +]; + +/** The tour opens over-broad (post + clear) so narrowing has something to do. */ +export const DEFAULT_BUILD: BuildConfig = { functions: ['post', 'clear'], selfArg: true, notAfterLedger: null }; + +// --- Act 6: adding signers and an M-of-N quorum ------------------------------ +// +// The 2-of-3 account (deployed by scripts/prove-threshold.ts) — three secp256r1 +// co-signers on the Default rule, gated by Nido's multisig policy at threshold 2. + +/** The three co-signers of the 2-of-3 account, derived from their seeds. */ +export const MOFN_SIGNERS: SignerView[] = THRESHOLD.signers.map((s, i) => { + const kp = secp256r1Keypair(s.seed); + const labels = ['Owner passkey', 'Backup key', 'Treasury key']; + const notes = ['the human owner', 'a recovery / co-sign device', 'a finance co-signer']; + return { + id: s.id, + label: labels[i] ?? s.id, + verifier: 'secp256r1', + kind: 'External', + detail: `key ${short(hex(kp.publicKey))}`, + status: 'live', + note: notes[i], + }; +}); + +/** The quorum rule: any of the three may propose, but 2 signatures are required. */ +export const MOFN_RULE: RuleView = { + name: 'ops-quorum', + signers: MOFN_SIGNERS.map((s) => s.id), + scope: 'any call', + policy: 'm-of-n', + reach: `${THRESHOLD.threshold}-of-${THRESHOLD.signers.length} must sign · any account op`, + status: 'live', + onchain: true, +}; + +/** The CI key's perch policy as a PolicyDoc, assembled from a builder config. */ +export function buildDoc(cfg: BuildConfig): PolicyDoc { + return { + version: 1, + network: TESTNET_PASSPHRASE, + signers: [{ id: 'ci', verifier: CONTRACTS.verifier, key: POSTER_KEY_HEX }], + rules: [ + rule({ + name: 'ci-can-publish', + scope: contract(CONTRACTS.board), + signedBy: ['ci'], + functions: cfg.functions.length ? cfg.functions : undefined, + args: cfg.selfArg ? [{ index: 1, pred: isSelf() }] : undefined, + notAfterLedger: cfg.notAfterLedger ?? undefined, + }), + ], + }; +} diff --git a/examples/perch-status-onchain/src/styles.css b/examples/perch-status-onchain/src/styles.css new file mode 100644 index 00000000..abd700a9 --- /dev/null +++ b/examples/perch-status-onchain/src/styles.css @@ -0,0 +1,201 @@ +/* Nido "Warm Nest" design system, reproduced for the perch guided tour. + Light-only by design (nido has no dark mode). Fonts load from Google in + index.html: Fraunces (display), Hanken Grotesk (body), JetBrains Mono. */ +:root { + color-scheme: only light; + --coral: #f25c2a; --honey: #f5a623; --teal: #0e9aa8; --espresso: #2a1a12; --cream: #fff8f0; + --paper: #fff8f0; --paper-2: #ffffff; --paper-3: #fbefe0; --chip: #f4e8d7; + --ink: #2a1a12; --ink-soft: #6b5446; --mut: #7a6655; + --line: #efe2d2; --line-soft: #f4ebde; + --acc: #f25c2a; --acc-ink: #b83e14; --acc-btn: #cf4a1e; --acc-soft: #fce3d6; --acc-glow: rgba(242,92,42,.28); + --good: #0e9aa8; --good-ink: #0a6b75; --good-soft: #dbf0f1; + --warn: #815a14; --warn-soft: #f8ecd4; + --danger: #aa3a26; --danger-soft: #fbe7e2; --danger-line: #f1c9c0; + --disp: "Fraunces", ui-serif, Georgia, serif; + --body: "Hanken Grotesk", ui-sans-serif, system-ui, sans-serif; + --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; + --r-xl: 26px; --r-lg: 20px; --r-md: 14px; --r-sm: 10px; --r-pill: 999px; + --sh-1: 0 1px 2px rgba(42,26,18,.05); + --sh-2: 0 10px 34px rgba(42,26,18,.10), 0 2px 6px rgba(42,26,18,.05); + --sh-3: 0 28px 70px rgba(42,26,18,.16), 0 6px 16px rgba(42,26,18,.07); + --sh-acc: 0 10px 26px rgba(42,26,18,.14), 0 2px 6px rgba(42,26,18,.06); +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--body); font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; } +.disp { font-family: var(--disp); font-weight: 800; letter-spacing: -.01em; } +.mono { font-family: var(--mono); } +a { color: var(--acc-ink); font-weight: 700; text-decoration: none; } +a:hover { text-decoration: underline; } + +.stage { max-width: 1120px; margin: 0 auto; padding: clamp(1rem,3vw,2.2rem) clamp(1rem,3vw,1.6rem) 4rem; } + +/* ---- top bar: brand + progress rail ---- */ +.topbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.brand { display: flex; align-items: center; gap: .6rem; } +.brand .word { font-family: var(--disp); font-weight: 800; font-size: 22px; letter-spacing: -.02em; } +.brand .word b { color: var(--acc); font-weight: 800; } +.brand .sep { color: var(--mut); } +.brand .tag { font-family: var(--mono); font-size: 12px; color: var(--mut); } + +.rail { display: flex; align-items: center; gap: .3rem; flex-wrap: wrap; margin-top: 1.4rem; padding: .55rem .7rem; background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--r-pill); box-shadow: var(--sh-1); } +.rail .st { display: flex; align-items: center; gap: .5rem; padding: .35rem .7rem; border-radius: var(--r-pill); cursor: pointer; color: var(--mut); font-size: 13.5px; font-weight: 700; border: 1px solid transparent; } +.rail .st .n { font-family: var(--disp); font-weight: 800; font-size: 15px; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; background: var(--chip); color: var(--ink-soft); } +.rail .st.active { background: var(--acc-soft); border-color: color-mix(in srgb, var(--acc) 30%, var(--line)); color: var(--acc-ink); } +.rail .st.active .n { background: var(--acc-btn); color: #fff; } +.rail .st.done .n { background: var(--good-soft); color: var(--good-ink); } +.rail .st .lbl { white-space: nowrap; } +@media (max-width: 760px) { .rail .st .lbl { display: none; } .rail { justify-content: space-between; } } + +/* ---- act shell ---- */ +.act { margin-top: 1.4rem; } +.eyebrow { font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--mut); } +.act h1 { font-family: var(--disp); font-weight: 800; font-size: clamp(30px, 4.4vw, 46px); line-height: 1.02; letter-spacing: -.02em; margin: .5rem 0 .5rem; text-wrap: balance; } +.act h1 i { font-style: italic; color: var(--acc); } +.act .lede { color: var(--ink-soft); font-size: 17px; max-width: 62ch; margin: 0; } +.act .lede b { color: var(--ink); } + +.card { background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--sh-2); padding: 1.2rem 1.3rem; } +.card + .card, .stack > * + * { margin-top: 1.1rem; } +.stack { margin-top: 1.3rem; } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; align-items: start; } +@media (max-width: 820px) { .grid2 { grid-template-columns: 1fr; } } +.card h3 { font-family: var(--disp); font-weight: 800; font-size: 19px; margin: 0 0 .5rem; letter-spacing: -.01em; } +.card .sub { color: var(--mut); font-size: 14px; margin: 0 0 .8rem; } +.section-label { font-size: 12px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: var(--mut); margin-bottom: .5rem; } + +/* ---- buttons ---- */ +.btn { font-family: var(--body); font-weight: 700; font-size: 15.5px; height: 52px; padding: 0 22px; border-radius: var(--r-md); border: 1px solid transparent; display: inline-flex; align-items: center; justify-content: center; gap: 9px; cursor: pointer; transition: transform .14s cubic-bezier(.2,.7,.3,1), box-shadow .18s, background .15s, filter .15s; background: var(--ink); color: var(--paper); } +.btn:hover { filter: brightness(.93); } +.btn:active { transform: scale(.975); } +.btn.acc { background: var(--acc-btn); color: #fff; box-shadow: var(--sh-acc); } +.btn.acc:hover { background: #b83e14; filter: none; } +.btn.soft { background: var(--chip); color: var(--ink); } +.btn.soft:hover { background: #e4ddcd; filter: none; } +.btn.ghost { background: transparent; color: var(--ink); border-color: var(--line); } +.btn.ghost:hover { background: var(--paper-3); filter: none; } +.btn.sm { height: 42px; font-size: 14px; padding: 0 16px; border-radius: var(--r-sm); } +.btn:disabled { background: var(--chip); color: var(--mut); box-shadow: none; cursor: not-allowed; filter: none; } +.nav { display: flex; justify-content: space-between; gap: .8rem; margin-top: 1.5rem; } + +/* ---- chips / badges / pills ---- */ +.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; padding: 6px 12px; border-radius: var(--r-pill); background: var(--chip); color: var(--ink-soft); } +.chip.acc { background: var(--acc-soft); color: var(--acc-ink); } +.chip.good { background: var(--good-soft); color: var(--good-ink); } +.chip.warn { background: var(--warn-soft); color: var(--warn); } +.chip.bad { background: var(--danger-soft); color: var(--danger); } +.chip.mono { font-family: var(--mono); font-size: 11.5px; font-weight: 500; } +.addr { font-family: var(--mono); font-size: 12px; color: var(--ink-soft); background: var(--chip); padding: 4px 9px; border-radius: var(--r-pill); word-break: break-all; } + +/* ---- alerts / result ---- */ +.alert { border-radius: var(--r-md); padding: 12px 14px; font-size: 14px; font-weight: 600; display: flex; gap: .55rem; align-items: flex-start; border: 1px solid; } +.alert .ic { font-weight: 800; flex: none; } +.alert.good { background: var(--good-soft); border-color: color-mix(in srgb, var(--good) 30%, transparent); color: var(--good-ink); } +.alert.danger { background: var(--danger-soft); border-color: var(--danger-line); color: var(--danger); } +.alert.warn { background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 28%, transparent); color: var(--warn); } +.alert.info { background: var(--acc-soft); border-color: color-mix(in srgb, var(--acc) 26%, transparent); color: var(--acc-ink); } +.alert a { color: inherit; } +.alert.danger.shake { animation: shake .32s ease; } +@keyframes shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)} } + +/* ---- "what can this key do" lists ---- */ +.caplist { list-style: none; margin: .3rem 0 0; padding: 0; display: flex; flex-direction: column; gap: .45rem; } +.caplist li { display: flex; align-items: center; gap: .6rem; padding: .55rem .7rem; border-radius: var(--r-sm); background: var(--paper-3); font-size: 14px; } +.caplist li .mk { font-family: var(--mono); font-weight: 700; flex: none; } +.caplist.deny li { background: var(--danger-soft); color: var(--danger); } +.caplist.deny li .mk { color: var(--danger); } +.caplist.allow li.yes { background: var(--good-soft); color: var(--good-ink); } +.caplist.allow li.yes .mk { color: var(--good); } +.caplist.allow li.no { background: var(--danger-soft); color: var(--danger); } +.caplist.allow li.no .mk { color: var(--danger); } + +/* ---- rules / signer cards ---- */ +.rule { position: relative; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--paper-2); padding: .85rem 1rem .95rem 1.1rem; overflow: hidden; } +.rule::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--good); } +.rule .rn { font-family: var(--disp); font-weight: 800; font-size: 17px; } +.rrow { display: flex; flex-wrap: wrap; gap: .35rem .5rem; align-items: center; margin-top: .45rem; } +.rk { font-family: var(--mono); font-size: 11.5px; color: var(--mut); text-transform: uppercase; letter-spacing: .06em; min-width: 4.5em; } +.signer { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--paper-2); padding: .8rem .95rem; } +.signer .top { display: flex; align-items: center; justify-content: space-between; gap: .5rem; } +.signer .id { font-weight: 700; } +.signer .note { font-size: 12.5px; color: var(--mut); margin-top: .4rem; } +.sig { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; } +@media (max-width: 720px) { .sig { grid-template-columns: 1fr; } } +.rulelist { display: flex; flex-direction: column; gap: .7rem; } +.rule.free::before { background: var(--mut); } +.rule.perch::before { background: var(--acc); } +.rule.cap::before { background: var(--honey); } +.rule.sim { opacity: .92; } +.reach { font-size: 13.5px; color: var(--ink-soft); margin-top: .4rem; } +.reach b { color: var(--ink); } +.on-mark { background: var(--good); color: #fff; font-family: var(--mono); font-size: 10.5px; font-weight: 700; padding: 2px 8px; border-radius: var(--r-pill); text-transform: uppercase; letter-spacing: .04em; } + +/* ---- contrast device (hand-written vs perch) ---- */ +.contrast { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 820px) { .contrast { grid-template-columns: 1fr; } } +.codebox { background: #221610; color: #f6e8d8; border-radius: var(--r-md); padding: .9rem 1rem; font-family: var(--mono); font-size: 12.5px; line-height: 1.6; overflow-x: auto; border: 1px solid #3a281c; white-space: pre; tab-size: 2; } +.codebox .c { color: #b79d86; } .codebox .k { color: var(--honey); } .codebox .s { color: #7fd3b0; } .codebox .b { color: #ff9a6b; font-weight: 700; } +.codecap { font-size: 13px; color: var(--mut); margin-top: .5rem; } + +/* ---- flow diagram (check_auth) ---- */ +.flow { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; } +.flow .node { border: 1px solid var(--line); background: var(--paper-3); border-radius: var(--r-sm); padding: .5rem .7rem; font-size: 13.5px; } +.flow .node.acc { background: var(--acc-soft); border-color: color-mix(in srgb,var(--acc) 30%, var(--line)); color: var(--acc-ink); font-weight: 700; } +.flow .arw { color: var(--mut); } + +/* ---- inputs ---- */ +.input, select { font-family: var(--mono); font-size: 13.5px; height: 46px; padding: 0 13px; border: 1px solid var(--line); border-radius: var(--r-md); background: var(--paper-2); color: var(--ink); width: 100%; } +.input:focus-visible, select:focus-visible, .btn:focus-visible, .rail .st:focus-visible { outline: none; border-color: var(--acc); box-shadow: 0 0 0 3px var(--acc-soft); } +.fld { display: flex; flex-direction: column; gap: .3rem; } +.fld label { font-size: 12.5px; font-weight: 700; color: var(--ink-soft); } + +/* ---- policy builder (Act 5 ①) ---- */ +.note2 { font-size: 12px; color: var(--mut); } +.toggles { display: flex; flex-wrap: wrap; gap: .45rem; } +.tog { font-family: var(--mono); font-size: 12.5px; font-weight: 700; padding: .42rem .7rem .42rem .5rem; border-radius: var(--r-pill); border: 1px solid var(--line); background: var(--paper-2); color: var(--mut); cursor: pointer; display: inline-flex; align-items: center; gap: .45rem; transition: border-color .12s, background .12s, color .12s; } +.tog:hover { border-color: var(--acc); } +.tog .box { width: 15px; height: 15px; border-radius: 5px; border: 1.5px solid var(--line); display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; color: #fff; } +.tog.on { background: var(--acc-soft); border-color: color-mix(in srgb, var(--acc) 40%, var(--line)); color: var(--acc-ink); } +.tog.on .box { background: var(--acc); border-color: var(--acc); } +.tog.on.risk { background: var(--danger-soft); border-color: var(--danger-line); color: var(--danger); } +.tog.on.risk .box { background: var(--danger); border-color: var(--danger); } + +/* ---- before/now + payoff ---- */ +.bn { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } +@media (max-width: 760px) { .bn { grid-template-columns: 1fr; } } +.bn .col { border: 1px solid var(--line); border-radius: var(--r-lg); padding: 1.1rem 1.2rem; background: var(--paper-2); } +.bn .col.now { background: linear-gradient(180deg, var(--acc-soft), var(--paper-2) 60%); border-color: color-mix(in srgb, var(--acc) 22%, var(--line)); } +.bn ul { margin: .5rem 0 0; padding-left: 1.1rem; color: var(--ink-soft); font-size: 14.5px; } +.bn li { margin: .35rem 0; } .bn li b { color: var(--ink); } + +.halo { position: relative; } +.halo::before { content: ""; position: absolute; inset: -40% 20% auto; height: 340px; background: radial-gradient(closest-side, var(--acc-glow), transparent 70%); z-index: -1; filter: blur(10px); pointer-events: none; } + +footer { margin-top: 2.4rem; color: var(--mut); font-size: 13px; text-align: center; } +footer .mono { color: var(--ink-soft); } +@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } +@media (prefers-reduced-motion: no-preference) { .act { animation: rise .4s cubic-bezier(.2,.8,.2,1) both; } } +@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + +/* ---- policy visualization: signer legend + rules matrix (Acts 5–6) ---- */ +.policy-signers { display: flex; flex-wrap: wrap; gap: .5rem; } +.psig { display: inline-flex; align-items: center; gap: .5rem; padding: .4rem .7rem; background: var(--paper-3); border: 1px solid var(--line); border-radius: var(--r-pill); } +.psig-id { font-weight: 700; font-size: 13px; } +.psig.sim { opacity: .72; } +.policy-matrix { display: grid; grid-template-columns: minmax(9rem,1.1fr) minmax(11rem,1.4fr) minmax(8rem,.9fr) minmax(10rem,1.5fr); border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; background: var(--paper-2); } +.pm-row { display: contents; } +.pm-c { padding: .7rem .8rem; border-top: 1px solid var(--line-soft); display: flex; align-items: center; gap: .35rem; flex-wrap: wrap; min-width: 0; } +.pm-head .pm-c { border-top: none; background: var(--paper-3); font-family: var(--mono); font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--mut); font-weight: 700; } +.pm-row.on .pm-c { background: color-mix(in srgb, var(--good-soft) 40%, transparent); } +.pm-row.sim .pm-c { opacity: .72; } +.pm-name b { font-weight: 800; } +.pm-name .star { color: var(--acc); font-size: 13px; } +.pm-reach { color: var(--ink-soft); font-size: 13.5px; } +.pm-who .quorum { font-family: var(--mono); font-size: 12px; font-weight: 800; color: var(--acc-ink); } +.on-mark { font-family: var(--mono); font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: var(--r-pill); background: var(--good-soft); color: var(--good-ink); } +@media (max-width: 720px) { + .policy-matrix { grid-template-columns: 1fr 1fr; } + .pm-head { display: none; } + .pm-c { border-top: none; } + .pm-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border-top: 1px solid var(--line-soft); } + .pm-row .pm-name, .pm-row .pm-reach { grid-column: 1 / -1; } +} diff --git a/examples/perch-status-onchain/tests/e2e.spec.ts b/examples/perch-status-onchain/tests/e2e.spec.ts new file mode 100644 index 00000000..872a09e5 --- /dev/null +++ b/examples/perch-status-onchain/tests/e2e.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +// LIVE testnet test: walks all five acts of the tour, exercises attenuation +// (offline), then drives REAL testnet transactions (post + clear) in Act 5. +// Writes artifacts/*.png. Needs network + testnet, so it is NOT in the offline +// CI lane — run it explicitly. +test('perch tour: five acts, attenuation, and live on-chain enforce', async ({ page }) => { + test.setTimeout(180_000); + await page.goto('/'); + + // Act 1 — the problem + await expect(page.getByRole('heading', { name: /total power/i })).toBeVisible(); + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/01-act1-problem.png', fullPage: true }); + + // Act 2 — smart account + await page.getByRole('button', { name: /^Next/ }).click(); + await expect(page.getByRole('heading', { name: /auth becomes code/i })).toBeVisible(); + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/02-act2-smart-account.png', fullPage: true }); + + // Act 3 — OZ model + contrast device + await page.getByRole('button', { name: /^Next/ }).click(); + await expect(page.getByText(/you write the policy/i)).toBeVisible(); + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/03-act3-oz-model.png', fullPage: true }); + + // Act 4 — connect a Nido account + await page.getByRole('button', { name: /^Next/ }).click(); + await page.getByRole('button', { name: /Connect Nido account/ }).click(); + await expect(page.getByText(/ML-DSA-65 · post-quantum/)).toBeVisible(); // the post-quantum signer + await expect(page.getByText('Treasury account')).toBeVisible(); // the Delegated "another account" + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/04-act4-nido.png', fullPage: true }); + + // Act 5 — perch: describe → attenuate → enforce + await page.getByRole('button', { name: /^Next/ }).click(); + await expect(page.getByRole('heading', { name: /Watch it enforce/i })).toBeVisible(); + await expect(page.getByText(/full policy/i)).toBeVisible(); // the composed multi-rule view + await expect(page.getByText('perch interpreter').first()).toBeVisible(); + + // ① the policy builder is live: the default grant is over-broad, and toggling + // clear() off re-derives the document + safety read to "tightly scoped". + await expect(page.locator('#doc-hash')).toContainText('doc_hash'); + await expect(page.getByText(/Over-broad/)).toBeVisible(); + await page.getByRole('button', { name: /clear\(\)/ }).click(); + await expect(page.getByText(/Tightly scoped/)).toBeVisible(); + await page.getByRole('button', { name: /clear\(\)/ }).click(); // restore over-broad for the narrow step below + await expect(page.getByText(/Over-broad/)).toBeVisible(); + + await page.locator('#narrow').click(); + await expect(page.getByText(/Verified narrowing/)).toBeVisible(); + await page.locator('#widen').click(); + await expect(page.getByText(/Refused — not a narrowing/)).toBeVisible(); + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/05-act5-attenuate.png', fullPage: true }); + + // On-chain: publish (allow) then wipe (deny) — real testnet. + await page.locator('#on-post').click(); + await expect(page.locator('#res-post .alert.good')).toBeVisible({ timeout: 120_000 }); + await expect(page.locator('#res-post a')).toHaveAttribute('href', /stellar\.expert\/explorer\/testnet\/tx/); + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/06-post-allowed.png', fullPage: true }); + + await page.locator('#on-clear').click(); + await expect(page.locator('#res-clear .alert.danger')).toBeVisible({ timeout: 120_000 }); + await page.waitForTimeout(550); // let the act entrance animation settle + await page.screenshot({ path: 'artifacts/07-clear-denied.png', fullPage: true }); + + // Act 6 — add signers, M-of-N. The policy panel gains a 2-of-3 quorum rule, + // and the threshold is PROVEN live: 2 co-signers pass, 1 alone is denied. + await page.getByRole('button', { name: /^Next/ }).click(); + await expect(page.getByRole('heading', { name: /Require a quorum/i })).toBeVisible(); + await expect(page.getByText('ops-quorum')).toBeVisible(); // the M-of-N rule in the policy panel + await expect(page.getByText(/OZ multisig · M-of-N/)).toBeVisible(); + await page.waitForTimeout(550); + await page.screenshot({ path: 'artifacts/08-act6-quorum.png', fullPage: true }); + + await page.locator('#mofn-2').click(); // 2 of 3 → meets threshold + await expect(page.locator('#res-mofn-2 .alert.good')).toBeVisible({ timeout: 120_000 }); + await expect(page.locator('#res-mofn-2 a')).toHaveAttribute('href', /stellar\.expert\/explorer\/testnet\/tx/); + await page.waitForTimeout(550); + await page.screenshot({ path: 'artifacts/09-mofn-2of3-allowed.png', fullPage: true }); + + await page.locator('#mofn-1').click(); // 1 of 3 → below threshold + await expect(page.locator('#res-mofn-1 .alert.danger')).toBeVisible({ timeout: 120_000 }); + await page.waitForTimeout(550); + await page.screenshot({ path: 'artifacts/10-mofn-1of3-denied.png', fullPage: true }); +}); diff --git a/examples/perch-status-onchain/tsconfig.json b/examples/perch-status-onchain/tsconfig.json new file mode 100644 index 00000000..8abbc30e --- /dev/null +++ b/examples/perch-status-onchain/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/perch-status-onchain/vite.config.ts b/examples/perch-status-onchain/vite.config.ts new file mode 100644 index 00000000..f9d81ffc --- /dev/null +++ b/examples/perch-status-onchain/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +// base defaults to '/' for local dev + apex previews; the GitHub Pages build +// passes --base explicitly (see build:pages / build:preview). +export default defineConfig({ + plugins: [ + // @stellar/stellar-sdk (and @nidohq/*) expect Buffer + process globals. + nodePolyfills({ include: ['buffer'], globals: { Buffer: true, process: true } }), + ], + build: { target: 'esnext' }, +}); diff --git a/package-lock.json b/package-lock.json index d944df8d..30153575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "packages/*", "packages/contract-bindings/*", "examples/status-message-dapp", - "examples/status-message-dapp/packages/*" + "examples/status-message-dapp/packages/*", + "examples/perch-status-onchain" ], "devDependencies": { "@noble/curves": "^2.2.0", @@ -19,6 +20,111 @@ "vitest": "^4.1.7" } }, + "examples/perch-status-onchain": { + "version": "0.1.0", + "dependencies": { + "@nidohq/passkey-sdk": "*", + "@nidohq/testkit": "*", + "@stellar/stellar-sdk": "^15.1.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "tsx": "^4.19.0", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vite-plugin-node-polyfills": "^0.25.0" + } + }, + "examples/perch-status-onchain/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "examples/perch-status-onchain/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "examples/status-message-dapp": { "version": "0.1.0", "dependencies": { @@ -17592,6 +17698,10 @@ "node": ">= 0.10" } }, + "node_modules/perch-status-onchain": { + "resolved": "examples/perch-status-onchain", + "link": true + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -20032,6 +20142,497 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", @@ -22720,7 +23321,8 @@ "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", - "@noble/post-quantum": "^0.7.0" + "@noble/post-quantum": "^0.7.0", + "@stellar/stellar-sdk": "^15.1.0" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/package.json b/package.json index 28751616..686de78c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "packages/*", "packages/contract-bindings/*", "examples/status-message-dapp", - "examples/status-message-dapp/packages/*" + "examples/status-message-dapp/packages/*", + "examples/perch-status-onchain" ], "scripts": { "build:packages": "npm run build -w @nidohq/smart-account -w @nidohq/multisig-policy -w @nidohq/spending-limit-policy -w @nidohq/webauthn-verifier -w @nidohq/factory -w @nidohq/status-message -w @nidohq/zk-recovery -w @nidohq/passkey-sdk -w @nidohq/stellar-wallets-kit-module",