diff --git a/.changeset/derived-inputs.md b/.changeset/derived-inputs.md new file mode 100644 index 0000000..c8a1897 --- /dev/null +++ b/.changeset/derived-inputs.md @@ -0,0 +1,27 @@ +--- +'@provablehq/aleo-types': minor +'@provablehq/aleo-wallet-standard': minor +'@provablehq/aleo-wallet-adaptor-core': minor +'@provablehq/aleo-wallet-adaptor-leo': minor +'@provablehq/aleo-wallet-adaptor-fox': minor +'@provablehq/aleo-wallet-adaptor-soter': minor +'@provablehq/aleo-wallet-adaptor-puzzle': minor +'@provablehq/aleo-wallet-adaptor-shield': minor +'@provablehq/aleo-wallet-adaptor-react': minor +--- + +Add `type: "derived"` InputRequest for wallet-evaluated cryptographic algorithms + +A new `InputRequest` variant lets a dapp ask the wallet to compute a value by running a named cryptographic algorithm over the wallet's own state (view key, wallet-maintained counters, etc.) plus dapp-supplied `args`, and substitute the result into a transaction input slot. The dapp never observes the wallet-side inputs — only the output. + +Strictly opt-in: a new `algorithmsAllowed?: AlgorithmGrant[]` field on `ConnectOptions` authorizes derived inputs at exact `(algorithm, program, function, inputPosition)` call sites. All four fields are required and exact-match; there is no broad default. The wallet refuses every derived request whose tuple is not present. + +A new adapter method `algorithmsSupported(): Promise` lets a dapp discover which algorithms a wallet implements before populating `algorithmsAllowed`. Wallets without derived-input support return `[]` (the base implementation's default). + +Derived-input `args` are a general `Record` map; `AlgorithmArg.type` is `ArgType = LiteralType | "string"` (the `"string"` widening carries non-Aleo-literal args). `ALGORITHM_SCHEMAS` declares each arg's type plus optional `possibleValues`/`optional`. `AlgorithmGrant` gains an optional generic `argConstraints?: Record` to pin per-arg values at connect time. + +Inaugural algorithms (program-scoped blinding, two-stage): `program-scoped-blinding-factor` (output `field`) and `program-scoped-blinded-address` (output `address`), filling a private `blinding_factor` and a public `blinded_address` input from the same wallet-maintained counter. Shared args: `mode` (`"issue"` advances the counter for a swap; `"resolve"` reuses a past counter for a claim, selected by the public `targetAddress` — the counter never leaves the wallet), `membershipProgram`/`membershipMapping` (where the wallet probes used-address state), and `targetAddress` (resolve only). + +The `` React component accepts a new optional `algorithmsAllowed` prop and forwards it on connect; the `useWallet()` context exposes `algorithmsSupported`. Existing usages without these are unaffected. + +See `docs/adapter-privacy-extension.md` § "Derived inputs" for the full spec, and `docs/dapp-privacy-quickstart.md` for an implementor's guide. diff --git a/.changeset/wallet-specified-inputs.md b/.changeset/wallet-specified-inputs.md new file mode 100644 index 0000000..cf3e399 --- /dev/null +++ b/.changeset/wallet-specified-inputs.md @@ -0,0 +1,23 @@ +--- +'@provablehq/aleo-types': minor +'@provablehq/aleo-wallet-standard': minor +'@provablehq/aleo-wallet-adaptor-core': minor +'@provablehq/aleo-wallet-adaptor-leo': minor +'@provablehq/aleo-wallet-adaptor-fox': minor +'@provablehq/aleo-wallet-adaptor-soter': minor +'@provablehq/aleo-wallet-adaptor-puzzle': minor +'@provablehq/aleo-wallet-adaptor-shield': minor +'@provablehq/aleo-wallet-adaptor-react': minor +--- + +Add wallet-specified input requests and structured permission grants + +`TransactionOptions.inputs` is now `TransactionInput[]` (= `(string | InputRequest)[]`). Dapps can place an `InputRequest` in any slot to ask the wallet to fill in the active address or auto-select an owned record matching dapp-supplied filters. Passing literal `string[]` continues to work — `string` is a subtype of `TransactionInput`. + +Adapters that do not yet implement fulfillment (leo, fox, soter, puzzle) throw `WalletInputRequestNotSupportedError` when an `InputRequest` is encountered. Shield forwards inputs to the extension, which is expected to support them. + +`connect()` accepts a new optional `options?: ConnectOptions` parameter carrying `recordAccess` and `readAddress`. When `readAddress: false`, the toolkit short-circuits `decrypt`, `requestRecords`, `transitionViewKeys`, and `requestTransactionHistory` with `WalletAddressWithheldError`. Connections with `readAddress: false` are only valid alongside `decryptPermission: NoDecrypt`. Adapters other than shield throw `WalletConnectOptionsNotSupportedError` when these options are set. + +The `` React component accepts new props `recordAccess` and `readAddress` and forwards them on connect. Existing usages without these props are unaffected. + +If your code reads `TransactionOptions.inputs[i]` as a string, narrow with `typeof i === 'string'` (or use the exported `isLiteralInput` type guard) before passing it to a `string`-typed API. diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md new file mode 100644 index 0000000..2abba71 --- /dev/null +++ b/docs/adapter-privacy-extension.md @@ -0,0 +1,301 @@ +# Dapp input requests for `executeTransaction` + +## Goal + +Let dapps emit `TransactionOptions` whose `inputs` slots are not always literal Aleo values. Each non-literal slot is a **request** to the wallet — to prompt the user, to auto-select an owned record matching dapp-supplied criteria, or to compute a value by running a named cryptographic algorithm over the user's wallet state. The wallet fulfills the request before passing the transaction to the SDK. + +## Wire-level types + +```ts +type Input = string | InputRequest; + +type InputRequest = + | { type: "address"; label?: string } // Specification to fill the input field with the active address. Allowed in an input position with an aleo type of: `address, group, scalar, or field`. + | { type: "record"; program: string; recordname: string; filters?: RecordFilters; uid?: string } // Specification to use a record of type `program/recordname`. `recordname` is required so the gate matches the request on the same `(program, recordname, field)` triple the grant model uses; without it, filter keys that collide across record types in the same program would be ambiguous. When `uid` is present, it pins the exact record previously returned by `requestRecords` and `filters` is ignored. When absent, the wallet picks any unspent record of `recordname` matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`. + | { type: "derived"; algorithm: AlgorithmName; args: Record; label?: string }; // Specification to fill the input field with the output of a wallet-evaluated cryptographic algorithm. Each algorithm declares its expected `args` schema and its output Aleo type; the output type determines which input positions are valid. Strictly opt-in — the wallet refuses every derived request whose (algorithm, program, function, inputPosition) tuple is not in the connection's `algorithmsAllowed`. See "Derived inputs" below. + +type RecordFilters = Record; // keys are top-level record field names or dotted paths into struct fields, e.g. "amount" or "data.amount". +type RecordFieldFilter = { eq?: string, gte?: string, lte?: string, neq?: string, }; // potential matching conditions, AND-combined. + +interface RecordView { + fields: Record; // parsed structured form of the record's plaintext. Only fields the dapp has read access to are present; redacted fields are omitted (not present-with-undefined). +} + +// Re-exports the existing LiteralType enum from `@provablehq/aleo-types` (`packages/aleo-types/src/data.ts`). +type LiteralType = + | "address" | "bool" | "group" + | "u8" | "u16" | "u32" | "u64" | "u128" + | "i8" | "i16" | "i32" | "i64" | "i128" + | "field" | "scalar" | "signature"; + +// New algorithms are added to this literal union as they're standardized. The `(string & {})` permits +// unknown values for forward-compat — the wallet validates against its own `algorithmsSupported()` list at runtime. +type KnownAlgorithm = "program-scoped-blinding-factor" | "program-scoped-blinded-address"; +type AlgorithmName = KnownAlgorithm | (string & {}); + +// Arg-level type: an Aleo literal type, or "string" for non-literal args (enums, identifiers). +type ArgType = LiteralType | "string"; + +interface AlgorithmArg { + type: ArgType; // parsing directive — the wallet decodes `value` as an Aleo literal (if LiteralType) or treats it as a plain string (if "string") + value: string; // an Aleo literal in canonical string form (e.g. "12345field", "100u64", "true"), or a plain string identifier/enum value when type is "string" +} + +// A per-arg grant constraint: a fixed allowlist of acceptable values, or "any" (omitted ⇒ "any"). +type ArgConstraint = string[] | "any"; +``` + +`requestRecords` continues to return its existing wallet-defined record shape (e.g. Shield's `OwnedRecord` at `shield-extension/src/background/types/RecordScanner.ts:83`). Two optional fields are added additively to each returned record: + +- `recordView?: RecordView` — structured form of the plaintext fields. Populated whenever the wallet decrypted the record. Saves dapps from reparsing Aleo-plaintext strings and is the only path through which redacted fields can be filtered out structurally. Optional in the type because pre-spec wallets won't emit it; conforming wallets always do when decryption ran. +- `uid?: string` — wallet-issued opaque handle, stable for the lifetime of the connection. Pass back as `uid` in a `type: "record"` request to pin this exact record. Not derived from the record's commitment, nonce, or tag; the wallet may rotate UIDs across connections to prevent cross-session linkability. Optional in the type for the same pre-spec reason; conforming wallets always populate it on every returned record regardless of grant breadth. + +The remaining (legacy) fields — including `recordPlaintext`, `commitment`, `tag`, `transitionId`, `transactionId`, `owner`, `sender`, etc. — are exposed or stripped per the grant's breadth, defined in "Record read shape" below. + +**Backwards compatibility.** A dapp that connected before this spec sees no shape change: it defaults to `recordAccess: undefined` (broadest grant), all legacy fields are populated as today, and the new `recordView` / `uid` fields are simply additive optional keys it can ignore. A dapp that opts into field-narrowing is by definition a new dapp aware of `uid` and `recordView`, so the legacy-stripping behavior in the matrix below is opt-in by construction — nothing breaks for existing consumers. + +The `InputRequest` sends a request to the wallet (which is then authorized by the user) to do the following: +1. Input the user's address into a position where there's an address, group, scalar, or field input. +2. Use a record whose fields match the `filters` on specific record's members and filter for records that match them if applicable, returning an error if the condition cannot be applied or a record matching it cannot be found. +3. Run a named cryptographic algorithm over the wallet's state (view key, program-scoped counters, etc.) plus dapp-supplied arguments, and use the output as input. Authorized only at exact `(algorithm, program, function, inputPosition)` call sites. + +The wallet has the program's source, so it reads a function's parameter signature for input position `i` and renders the form control accordingly. `label` is UX-only. + +Adapters are ONLY allowed to successfully execute this if the user has authorized permission to do so. + +## Permission model + +### Today + +`ConnectHistory` (`src/app/common/types/IAdapterService.ts:92`) carries `decryptPermission` plus a flat `programs?: string[]` allowlist. Two gates consult `programs` via `programs.includes(target)`: `executeTransaction` at `AdapterService.ts:240` and `requestRecords` at `:494`. Permissions are scoped per-dapp via `siteInfo.origin`; every gated method runs `getMatchingConnectHistoryAndDecryptPermission` (`:412-437`) first, which loads exactly one `ConnectHistory` row keyed on `(origin, network, address)`. + +### Proposed + +Add two new fields to `ConnectHistory`, both additive. The existing `decryptPermission` and `programs?: string[]` are preserved exactly, and the `connect()` signature does not change. + +```ts +interface ConnectHistory { + // ...existing fields... + decryptPermission: DecryptPermission; // unchanged + programs?: string[]; // unchanged — program-level gate for both transaction execution and record operations + readAddress?: boolean; // new — opt-in address withholding; default true + recordAccess?: RecordAccessGrant; // new — opt-in record/field narrowing + algorithmsAllowed?: AlgorithmGrant[]; // new — strict opt-in derived-input allowlist; default undefined → every `type: "derived"` request is refused +} + +type RecordAccessGrant = + | { level: "none" } + | { level: "byProgram"; programs: ProgramGrant[] }; + +interface ProgramGrant { + program: string; + records?: RecordGrant[]; // undefined → all records of this program; present → only the listed records +} + +interface RecordGrant { + recordname: string; + fields?: FieldGrant[]; // undefined → all fields; present → only the listed fields +} + +interface FieldGrant { + name: string; // a record-body field name (e.g. "amount", "data.amount"), or a `$`-prefixed envelope-metadata name from the reserved set: `$commitment`, `$tag`, `$transitionId`, `$transactionId`, `$outputIndex`, `$transactionIndex`, `$transitionIndex`, `$owner`, `$sender`. The `$` prefix prevents collision with any body field named identically. + readAccess?: boolean; // undefined → true; false → field is usable as a filter key but plaintext is withheld on decrypt +} + +// Each grant authorizes one specific call site. `algorithm`, `program`, `function`, and `inputPosition` +// are required and exact-match; there is no wildcard. A dapp that wants to use the same algorithm at +// multiple call sites lists each one as its own entry. +interface AlgorithmGrant { + algorithm: AlgorithmName; // must appear in the wallet's `algorithmsSupported()` list + program: string; // must also appear in `programs` + function: string; // exact transition name within `program` + inputPosition: number; // 0-based index into the function's input slots + argConstraints?: Record; // optional per-arg allowlist: for each arg name, + // a fixed array of acceptable `AlgorithmArg.value` strings or "any". + // Omitted ⇒ "any" for that arg. Enforced by the wallet. +} +``` + +| Configuration | Meaning | +|---|---| +| `recordAccess: undefined` | Today's broad behavior — all records of programs allowed by `programs`, all fields. | +| `recordAccess: { level: "none" }` | Refuse every `requestRecords` call and every `type: "record"` request. Transaction execution with literal inputs is unaffected. | +| `recordAccess: { level: "byProgram", programs: [...] }`, `ProgramGrant.records` undefined | All records of the listed program; all fields. | +| `recordAccess: { level: "byProgram", programs: [...] }`, `RecordGrant.fields` undefined | Only the listed records; all fields within them. | +| `recordAccess: { level: "byProgram", programs: [...] }`, `fields` listed | Only the listed records, and only the listed fields within each. | + +### Backward compatibility + +The pre-existing dapp surface is preserved exactly: + +- **`connect()` signature unchanged**: still `(siteInfo, network, decryptPermission, programs?)`. A dapp that called `connect({ programs: ["foo.aleo"] })` before this change behaves identically after. +- **Existing gates unchanged**: the `programs.includes(program)` checks at `AdapterService.ts:240` and `:494` keep producing the same outcome for any connection where `recordAccess` is undefined. +- **`recordAccess` defaults to undefined**: the wallet never synthesizes a grant from the legacy `programs` list. `undefined` reads as "today's broad behavior." +- **Per-dapp scoping**: `recordAccess` lives on the `ConnectHistory` row keyed on `(origin, network, address)`; one dapp's grant never affects another's access. No change to today's scoping. + +A strict opt-in security model would require an explicit grant for any record access. Keeping the default broad here is a deliberate trade-off to avoid breaking dapps that connected before `recordAccess` existed. Dapps that want narrower scopes opt in by populating `recordAccess` at connect time. + +### Interaction rules + +When `recordAccess` is set, these rules apply on top of the unchanged `programs` allowlist: + +1. **Subset constraint**: every `recordAccess.programs[].program` must appear in `programs`. Connect-time validation rejects mismatches. +2. **Programs without record grants lose record access**: a program in `programs` but not in `recordAccess.programs[]` keeps transaction-execution access (literal inputs only). It cannot be queried via `requestRecords` and cannot be the target of a `type: "record"` request. +3. **Record narrowing**: when `ProgramGrant.records` is present, only the listed `recordname`s of that program are accessible. `undefined` → all records. +4. **Field narrowing**: when `RecordGrant.fields` is present, only the listed field names may be referenced as filter keys in a `type: "record"` request. `undefined` → all fields. Filter keys outside the listed fields are a permission error at the gate. Plaintext exposure is further controlled by `FieldGrant.readAccess`: when `readAccess` is `true` (or omitted, which defaults to `true`) the field's plaintext value is included in `requestRecords` decrypt output; when `false` the field remains usable as a filter key but its plaintext is redacted from decrypt results. +5. **`level: "none"`** refuses all record operations regardless of `programs`. Transaction execution with literal inputs is unaffected. + +### Record read shape + +`requestRecords` always emits `recordView` and `uid` on each returned record. Whether the wallet's pre-existing legacy fields (`recordPlaintext`, `commitment`, `tag`, `transitionId`, `transactionId`, `owner`, `sender`, indices, etc.) are populated depends on how narrowly the dapp's grant restricts access. The principle: legacy fields disclose record identity and ownership beyond what `recordView` would; once the grant says "you only get certain fields," disclosing the full plaintext or identity metadata would defeat the narrowing. + +| Grant on the queried program | Legacy fields | `recordView.fields` | +|---|---|---| +| `recordAccess: undefined` (today's broad behavior) | All populated, unchanged from today. | All fields parsed. | +| `recordAccess: { level: "byProgram", programs: [{ program /* no records */}] }` | All populated, unchanged from today. | All fields parsed. | +| `ProgramGrant.records` present, `RecordGrant.fields` absent | All populated for records whose `recordname` is in the list; non-listed records are not returned. | All fields parsed. | +| `RecordGrant.fields` present | `recordPlaintext` is always stripped. Envelope metadata (`commitment`, `tag`, `transitionId`, `transactionId`, `outputIndex`, `transactionIndex`, `transitionIndex`, `owner`, `sender`) is stripped by default; each entry the dapp opts into via a `$`-prefixed `FieldGrant.name` is exposed. Non-identifying envelope fields (`programName`, `recordName`, `blockHeight`, `blockTimestamp`, `spent`) are preserved. | Only the listed body fields whose `FieldGrant.readAccess` is true (or omitted). | + +Rationale: when the dapp asks for narrow field access, it has explicitly given up the broad read. Returning the unredacted `recordPlaintext` alongside a narrowed `recordView` would let the dapp re-parse the plaintext and bypass the narrowing — so legacy plaintext is always dropped here. Envelope metadata (`$commitment`, `$tag`, `$transitionId`, etc.) lets the dapp correlate records to on-chain state, so it is also opt-in: a privacy-preserving dapp lists only what it needs, and `uid` covers pinning even when no metadata is requested. A dapp that wants today's full disclosure simply lists the relevant `$`-prefixed names alongside its body-field grants. + +Under `readAddress: false`, the strip rules tighten further independently of the grant: `owner`, `sender`, `commitment`, `tag`, `transitionId`, `transactionId`, and `recordPlaintext` are always omitted, and any `recordView.fields` whose Aleo type is `address` and whose plaintext equals the active address is omitted. `uid` and `recordView` (with non-address fields) remain the dapp's only handles to the record. + +### Derived inputs + +A `type: "derived"` request asks the wallet to compute a value by running a named cryptographic algorithm over the wallet's own state (the view key, a per-(origin, program) counter, etc.) combined with the dapp's `args`, then substitute the result into the input slot. The dapp never observes the wallet-side inputs — only the final output. + +Derived inputs are **strictly opt-in**: the wallet refuses every derived request whose `(algorithm, program, function, inputPosition)` tuple is not present in `algorithmsAllowed`. There is no broad default. The four fields are required and exact-match — a grant for `(algorithm: X, program: "p.aleo", function: "f", inputPosition: 0)` does not authorize the same algorithm at `inputPosition: 1`, at a different function, or at a different program. A dapp that wants the same algorithm at multiple call sites lists each one as its own entry. + +#### Discovery + +Adapters expose `algorithmsSupported(): Promise`. A dapp calls this before connect to learn which algorithms a wallet implements, then requests a matching subset in `algorithmsAllowed`. Wallets that don't implement derived inputs at all return an empty array or throw `MethodNotImplementedError`. + +Every `algorithmsAllowed[].algorithm` is validated at connect time against the wallet's `algorithmsSupported()`. Unknown names are rejected. + +#### Output type and slot compatibility + +Each `KnownAlgorithm` has a fixed Aleo output type, declared in the catalog below. The wallet additionally validates at execute time that the function's signature at `inputs[i]` is a valid position for that output type — same rules as `type: "address"` (e.g. an `address`-typed output is valid in `address` / `group` / `scalar` / `field` slots). + +#### Algorithm catalog + +The program-scoped blinding scheme is two-stage and fills **two** circuit inputs from the same wallet-maintained counter: a private `blinding_factor` and a public `blinded_address` (which the contract re-derives and asserts). Each is produced by its own algorithm so a function can request only the slot it needs. Both consume the same `args`: + +| Arg | Type | Notes | +|---|---|---| +| `mode` | `string` | `possibleValues: ["issue", "resolve"]`. `issue` (swap) advances the wallet's counter; `resolve` (claim) reuses the counter of a past derivation, selected by `targetAddress`. | +| `membershipProgram` | `string` | program holding the used-address mapping (usually the called program) | +| `membershipMapping` | `string` | mapping recording used blinded addresses; where the wallet probes to find a free counter (`issue`) or confirm existence (`resolve`) | +| `targetAddress` | `address` | **optional**; required only for `resolve` — the public blinded address being reclaimed | + +The counter is wallet-owned and never observed or controllable by the dapp; on `resolve` the wallet inverts the public `targetAddress` to its counter internally — the counter never leaves the wallet. + +##### `program-scoped-blinding-factor` + +Output type `field` (the blinding factor `r`). Valid input slot positions: `field`, `scalar`, `group`. + +##### `program-scoped-blinded-address` + +Output type `address` (the blinded address). Valid input slot positions: `address`, `group`, `scalar`, `field`. Derives `r` internally so both slots agree on one counter. + +Algorithm (pseudo, matching the wallet's reference implementation; `BF_DOMAIN`/`CS_DOMAIN` are fixed program-identifier field constants): + +``` +r = Poseidon8.hash([programAddrField, BF_DOMAIN, viewKeyField, counterField]) +blinded_addr = Address.fromGroup(Poseidon8.hashToGroup(bitpack252([programAddrField, CS_DOMAIN, signerField, r]))) +``` + +The dapp never observes the view key, the counter, or `r`. The blinded address's link to the active address is hidden by `r` (which needs the view key) — recovering the active address from the output is computationally infeasible without it. + +Future algorithms are added to `KnownAlgorithm` and documented under their own catalog subsection in this spec. + +#### Interaction with `readAddress: false` + +Derived inputs are allowed under `readAddress: false`. The dapp does not learn the active address through the derived flow — it only sees the algorithm's output. For `program-scoped-blinded-address` the output is itself a fresh blinded address, so it does not leak the active address even when the dapp inspects the resulting transaction. Algorithms whose output is the active address itself (or trivially reversible to it) must not be admitted to `KnownAlgorithm` for this reason. + +#### Interaction with `programs` + +`algorithmsAllowed[].program` must appear in `programs`. The wallet rejects mismatches at connect time. This mirrors the subset constraint already enforced for `recordAccess.programs[].program`. + +### Address exposure + +`readAddress?: boolean` controls whether the dapp learns the user's address. Defaults to `true` (undefined treated as `true`); `false` is opt-in for privacy-preserving dapps. + +#### `readAddress: undefined | true` — current behavior, with notification + +The dapp learns the address through the same paths as today. The only change is **UX**: the connect dialog adds an explicit line item disclosing that the dapp will see the address. The user already approves the connection itself; this surfaces what the approval implies. No API or return-type change. + +#### `readAddress: false` — withholding + +The dapp transacts on the user's behalf without learning the address. Every direct-exposure path is closed and every operation that would let the dapp enumerate, decrypt, or otherwise derive the address from wallet-mediated state is refused. + +| Surface | Behavior under `readAddress: false` | +|---|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `connect()` return value (`Account.address`) | `""` (empty string) — type stays `string` | +| `provider._publicKey` getter | returns `""` regardless of connection state | +| `init` message handler | must not populate address; today's `undefined` already complies | +| `decrypt(record)` | refused with permission error | +| `requestRecords(program, includePlaintext?)` | allowed; address-leaking legacy fields and address-typed plaintext that match the wallet's address are stripped per "Record read shape". The dapp pins returned records via `uid` in a `type: "record"` request without ever learning the owner. | +| `transitionViewKeys(txid)` | refused with permission error (view keys derive the address via `address = view_key · G`) | +| `requestTransactionHistory(program)` | refused with permission error (same view-key-derivation reasoning) | +| `executeTransaction` with literal inputs | allowed | +| `executeTransaction` with `type: "address"` slot | allowed — the wallet injects the active address into the transaction; the dapp never observes it | +| `signMessage(message)` | allowed — the signature plus message reveals the signer's public key, which is the address. The privacy guarantee leaks here by design; dapps that need a strict guarantee should not call `signMessage` under `readAddress: false`. | + +#### Compatibility constraint with `decryptPermission` + +Because every plaintext-bearing decrypt operation is refused under `readAddress: false`, the only coherent `decryptPermission` value is `NoDecrypt`. Connecting with `readAddress: false` together with `UponRequest`, `AutoDecrypt`, or `OnChainHistory` is a connect-time error. + +#### Backward compatibility + +The default (`undefined` or `true`) preserves today's behavior verbatim at every API surface. The connect-dialog notification is a UI-only change visible to the user, not the dapp. No existing dapp's wire calls or return values change. + +## Fulfillment flow + +```mermaid +flowchart TD + A["dapp: executeTransaction
inputs: Input[]"] --> B["validation.ts
schema accepts string | InputRequest"] + B --> C{"AdapterService
permission gate"} + C -- "violates recordAccess" --> X["error to dapp"] + C -- ok --> D["ExecuteTransaction page"] + D --> R["fulfillInputRequests.ts"] + R -- "type: record" --> R1["filter unspent records
by where clause"] + R -- "type: user" --> R3["render typed form
from program signature"] + R -- "type: derived" --> R4["run algorithm via SDK
using wallet-derived secrets"] + R1 --> F["confirm screen
shows every fulfilled value"] + R3 --> F + R4 --> F + F -- user confirms --> G["initializeGenericTransaction
(fulfilled string[], lockedRecords)"] + G ===> H["worker.ts → SDK
UNCHANGED"] + + classDef boundary stroke-dasharray: 5 5; + class H boundary; +``` + +The worker boundary still receives `string[]`. All fulfillment is wallet-side; the SDK call sites and the `imports` path are untouched. + +## Failure modes + +| Request | Condition | Result | +|---|---|---| +| `type: "record"` | zero matches for `where` | fail loudly (matches `imports` precedent) | +| `type: "record"` | filter key does not resolve to a field in the record's signature (including dotted struct paths) | validation error before gate | +| `type: "record"` | operator illegal for the field's Aleo type (e.g. `gte` on `boolean`) | validation error before gate | +| `type: "record"` | filter value does not parse as a literal of the field's Aleo type | validation error before gate | +| `type: "record"` | `gte` and `lte` form an empty range, or `eq` contradicts `neq` | validation error before gate | +| `type: "record"` | field outside `RecordGrant.fields` | permission error at gate | +| `type: "record"` | `uid` present but does not match a record previously returned to this dapp on this connection | permission error at gate | +| `type: "record"` | `uid` present and matches, but the record is now spent | fail loudly (matches `imports` precedent) | +| `type: "record"` | both `uid` and `filters` provided | validation error before gate (`uid` is exclusive) | +| `connect()` | `FieldGrant.name` uses an unknown `$`-prefixed token (not in the reserved set) | connect-time validation error | +| `connect()` | `FieldGrant.name` references a body field that does not exist in the record's signature | connect-time validation error | +| `connect()` | `FieldGrant` lists `$owner`, `$sender`, `$commitment`, `$tag`, `$transitionId`, or `$transactionId` while `readAddress: false` | connect-time validation error (these would re-leak the address or let the dapp link to on-chain state that does) | +| `connect()` | `recordAccess.programs[].program` not present in `programs` | connect-time validation error | +| `requestRecords` | called against a program in `programs` but absent from `recordAccess.programs[]` (when `recordAccess` is set) | permission error at gate | +| `requestRecords` | `includePlaintext: true` while `decryptPermission: NoDecrypt` | permission error at gate (today's behavior, restated) | +| `type: "derived"` | `(algorithm, program, function, inputPosition)` tuple not in `algorithmsAllowed` | permission error at gate | +| `type: "derived"` | `algorithm` not in the wallet's `algorithmsSupported()` list | permission error at gate (also caught at connect time if the dapp listed it in `algorithmsAllowed`) | +| `type: "derived"` | `args` missing a key required by the algorithm's schema, has an extra key, or an `AlgorithmArg.type` mismatches the algorithm's expected type for that key | validation error before gate | +| `type: "derived"` | `AlgorithmArg.value` does not parse as a literal of `AlgorithmArg.type` | validation error before gate | +| `type: "derived"` | algorithm output type incompatible with the function signature at `inputs[i]` (e.g. an `address`-producing algorithm used in a `u64` slot) | validation error before gate | +| `connect()` | `algorithmsAllowed[].program` not present in `programs` | connect-time validation error | +| `connect()` | `algorithmsAllowed[].algorithm` not in the wallet's `algorithmsSupported()` | connect-time validation error | diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md new file mode 100644 index 0000000..9cb49a9 --- /dev/null +++ b/docs/dapp-privacy-quickstart.md @@ -0,0 +1,256 @@ +# Privacy-extension dapp quickstart + +How to use the wallet-adapter's new privacy features from a dapp. For the full spec see [`adapter-privacy-extension.md`](./adapter-privacy-extension.md). For a working reference, see `examples/react-app/src/components/functions/PrivateInputs.tsx`. + +## What's new + +Three connect-time grants and three transaction-input request types: + +| Grant | Type | Effect | +|---|---|---| +| `readAddress` | `boolean` (default `true`) | When `false`, the dapp transacts without learning the active address. Requires `decryptPermission: NoDecrypt`. | +| `recordAccess` | `RecordAccessGrant` (default `undefined` = broad) | Per-program / per-record / per-field narrowing of record reads. | +| `algorithmsAllowed` | `AlgorithmGrant[]` (default `undefined` = none) | Strict opt-in allowlist for `type: "derived"` requests. Each entry authorizes one exact `(algorithm, program, function, inputPosition)` call site. | + +| `InputRequest` slot type | Shape | Valid in | +|---|---|---| +| `{ type: "address" }` | wallet injects active address | `address`, `group`, `scalar`, `field` | +| `{ type: "record", program, recordname, uid }` | pin specific record by handle | `record`, `dynamic_record`, `external_record` | +| `{ type: "record", program, recordname, filters }` | wallet auto-selects matching record of `recordname` | same | +| `{ type: "derived", algorithm, args }` | wallet runs a named crypto algorithm | depends on algorithm — see catalog | + +## Wiring connect-time options + +In React, pass to `AleoWalletProvider`: + +```tsx +import { AleoWalletProvider } from '@provablehq/aleo-wallet-adaptor-react'; +import { DecryptPermission } from '@provablehq/aleo-wallet-adaptor-core'; + + + {children} + +``` + +Grants are bound at connect time. To change them, update the props and reconnect — the provider auto-disconnects when these change. + +### Grant shape rules + +- `recordAccess` omitted → today's broad behavior on the `programs` allowlist. +- `RecordAccessGrant.level: "none"` → refuse all record operations. +- `ProgramGrant.records` omitted → all records of the program; present → only the listed records. +- `RecordGrant.fields` omitted → all fields visible; `[]` → record usable for `uid` pinning, zero plaintext exposed; populated → only the listed fields. +- `FieldGrant.readAccess: false` → field is usable as a filter key but plaintext is withheld. +- `FieldGrant.name` accepts body field names (`"microcredits"`, `"data.amount"`) and `$`-prefixed envelope-metadata tokens: `$commitment`, `$tag`, `$transitionId`, `$transactionId`, `$outputIndex`, `$transactionIndex`, `$transitionIndex`, `$owner`, `$sender`. + +### `readAddress: false` constraints + +- `decryptPermission` must be `NoDecrypt` — the wallet rejects connect otherwise. +- Granting any of `$owner`/`$sender`/`$commitment`/`$tag`/`$transitionId`/`$transactionId` is rejected (would re-leak the address). +- `decrypt`, `requestRecords` with `includePlaintext: true`, `transitionViewKeys`, and `requestTransactionHistory` throw `WalletAddressWithheldError` on this connection. + +## Reading records + +`requestRecords` now returns `RecordEnvelope[]`: + +```ts +import type { RecordEnvelope } from '@provablehq/aleo-types'; + +const records = (await requestRecords('credits.aleo', true, 'unspent')) as RecordEnvelope[]; + +for (const rec of records) { + rec.uid; // opaque pinning handle (stable for this connection) + rec.recordView?.fields; // { microcredits: "100u64", ... } — only granted fields + rec.recordName; // 'credits' + rec.programName; // 'credits.aleo' + rec.spent; // boolean + // legacy fields (commitment, tag, owner, sender, recordPlaintext, ...) are + // present only when the grant is broad enough — see the spec's "Record read shape" + // matrix for the rules. +} +``` + +`uid` is a wallet-issued opaque handle, **not** the record's commitment. The wallet may rotate uids across connections to prevent cross-session linkability — don't persist them across sessions. + +## Composing transaction inputs + +`executeTransaction({ inputs })` accepts a mix of literals and `InputRequest` objects: + +```ts +await executeTransaction({ + program: 'credits.aleo', + function: 'transfer_private', + inputs: [ + // recordname is REQUIRED on every type: "record" slot (see note below) + { type: 'record', program: 'credits.aleo', recordname: 'credits', uid: chosen.uid! }, // pin by uid + { type: 'address' }, // wallet injects active address + '100u64', // literal + ], +}); +``` + +### Pick the right `type: "record"` shape + +- **`program` + `recordname` are always required.** Every `type: "record"` slot names the record type as `program/recordname` (e.g. `credits.aleo/credits`). The wallet matches the request against your `recordAccess` grant on the same `(program, recordname, field)` triple, so without `recordname` filter keys that collide across record types in the same program would be ambiguous. Omitting it is a client-side validation error before the request reaches the wallet. +- **`{ uid }`** — you've called `requestRecords`, you want **this exact record**. Use uid. +- **`{ filters }`** — you want the wallet to pick any unspent record matching conditions. Use filters. +- **Both is an error**: `WalletInputRequestInvalidError` is thrown client-side. + +```ts +// Filters: AND-combined per field +const filters = { + microcredits: { gte: '100u64', lte: '1000u64' }, // range on a body field + 'data.tier': { eq: '2u8' }, // dotted path into struct fields +}; + +await executeTransaction({ + program: 'credits.aleo', + function: 'transfer_private', + inputs: [ + { type: 'record', program: 'credits.aleo', recordname: 'credits', filters }, + { type: 'address' }, + '100u64', + ], +}); +``` + +## Derived inputs (`type: "derived"`) + +A `type: "derived"` slot tells the wallet to compute a value by running a named cryptographic algorithm over its own state (view key, wallet-maintained counters, etc.) plus your `args`, and substitute the result. **You never see the wallet-side inputs — only the output.** + +Strictly opt-in via `algorithmsAllowed` at connect time. Each grant authorizes exactly one `(algorithm, program, function, inputPosition)` call site. Optionally pin per-arg values with `argConstraints` (a fixed allowlist of acceptable values, or `"any"`; omitted ⇒ any) so a later call can't even use a different value: + +```ts +import { ALGORITHM_SCHEMAS } from '@provablehq/aleo-types'; + + +``` + +Discovery: call `useWallet().algorithmsSupported()` (no connection required) to see which algorithms the active adapter implements. Wallets without derived-input support return `[]`. + +At execute time, pass `{ type: "derived", algorithm, args }` in the matching slots. `args` is a general `Record` map; each algorithm documents the keys it consumes. The two blinding algorithms share the same `args` aside from the per-slot algorithm name — `mode` (`"issue"` advances the wallet's counter for a swap; `"resolve"` reuses a past counter selected by the public `targetAddress` for a claim — the counter never leaves the wallet), `membershipProgram`/`membershipMapping` (where the wallet probes used-address state), and `targetAddress` (resolve only): + +```ts +await executeTransaction({ + program: 'amm_v3.aleo', + function: 'swap_private', + inputs: [ + // ...token_in_record slot... + { type: 'derived', + algorithm: 'program-scoped-blinding-factor', // → private blinding_factor + args: { + mode: { type: 'string', value: 'issue' }, + membershipProgram: { type: 'string', value: 'amm_v3.aleo' }, + membershipMapping: { type: 'string', value: 'used_blinded_addresses' }, + }, + }, + { type: 'derived', + algorithm: 'program-scoped-blinded-address', // → public blinded_address + args: { + mode: { type: 'string', value: 'issue' }, + membershipProgram: { type: 'string', value: 'amm_v3.aleo' }, + membershipMapping: { type: 'string', value: 'used_blinded_addresses' }, + }, + }, + // ...rest of the function's inputs + ], +}); +``` + +### Claiming a past swap (`mode: "resolve"`) + +The swap above ran in `mode: "issue"` — the wallet picked the next free counter and advanced it. To later **claim** that swap's output (`claim_swap_output_private`), run the same two algorithms in `mode: "resolve"`. Instead of advancing the counter, the wallet re-derives the counter of the *existing* swap by inverting its public blinded address, then reproduces the matching `(blinding_factor, blinded_address)` pair. The counter still never leaves the wallet. + +The only differences from the issue call: `mode` is `"resolve"`, and you add `targetAddress` — the public blinded address of the swap you're claiming (the value the issue call's `program-scoped-blinded-address` slot produced and the contract recorded in `used_blinded_addresses`). Both slots must carry the same `targetAddress`, or the wallet rejects the call before deriving: + +```ts +const targetAddress = 'aleo1…'; // public blinded address of the swap being claimed + +await executeTransaction({ + program: 'amm_v3.aleo', + function: 'claim_swap_output_private', + inputs: [ + { type: 'derived', + algorithm: 'program-scoped-blinding-factor', // → private blinding_factor of the past swap + args: { + mode: { type: 'string', value: 'resolve' }, + membershipProgram: { type: 'string', value: 'amm_v3.aleo' }, + membershipMapping: { type: 'string', value: 'used_blinded_addresses' }, + targetAddress: { type: 'address', value: targetAddress }, + }, + }, + { type: 'derived', + algorithm: 'program-scoped-blinded-address', // → the same public blinded_address + args: { + mode: { type: 'string', value: 'resolve' }, + membershipProgram: { type: 'string', value: 'amm_v3.aleo' }, + membershipMapping: { type: 'string', value: 'used_blinded_addresses' }, + targetAddress: { type: 'address', value: targetAddress }, + }, + }, + // ...rest of the function's inputs + ], +}); +``` + +If you pinned operational args with `argConstraints` at connect time, remember the `resolve` call sites need their own grants — a grant whose `argConstraints.mode` is `['issue']` will refuse this call. Grant `mode: ['issue', 'resolve']` (or omit the `mode` constraint) on the relevant `(program, function, inputPosition)` tuples, and `claim_swap_output_private` is a different `function` than `swap_private`, so it needs grants of its own regardless. + +`ALGORITHM_SCHEMAS` from `@provablehq/aleo-types` ships each algorithm's args schema (type + `possibleValues`/`optional`), output type, and valid slot positions — use it to render correct forms or pre-validate shapes. Full algorithm catalog: see [`adapter-privacy-extension.md`](./adapter-privacy-extension.md) § "Algorithm catalog". + +## Error classes + +Imported from `@provablehq/aleo-wallet-adaptor-core`: + +| Error | When | +|---|---| +| `WalletInputRequestInvalidError` | `type: "record"` with both `uid` and `filters` (client-side, before wallet) | +| `WalletAddressWithheldError` | calling a withhold-blocked method on a `readAddress: false` connection | +| `WalletConnectOptionsNotSupportedError` | wallet doesn't implement the new options (legacy wallets) | +| `WalletInputRequestNotSupportedError` | wallet doesn't support `InputRequest` slots — pass literals only | + +## Migration cheatsheet + +If your existing dapp: + +- **Doesn't use any of the new fields** — no change. `AleoWalletProvider`'s new props default to `undefined` / "broad" behavior. Existing calls work identically. +- **Reads records via `requestRecords`** — return shape is unchanged when no grant is set. The new `recordView` / `uid` fields are additive optional keys you can ignore. +- **Composes `TransactionOptions.inputs` as plain strings** — keep doing that. The new `InputRequest` shapes are opt-in per-slot. +- **Wants to adopt narrowed grants** — populate `recordAccess` at the provider level; use `RecordGrant.fields: []` to mint records usable purely for `uid` pinning with zero plaintext leakage. +- **Wants to use derived inputs** — populate `algorithmsAllowed` with one grant per call site, then place `{ type: "derived", algorithm, args }` in the corresponding `inputs[i]`. There is no broad default — empty `algorithmsAllowed` refuses every derived request. diff --git a/docs/superpowers/plans/2026-04-28-dynamic-dispatch-execute-integration.md b/docs/superpowers/plans/2026-04-28-dynamic-dispatch-execute-integration.md new file mode 100644 index 0000000..fb0b700 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-dynamic-dispatch-execute-integration.md @@ -0,0 +1,1158 @@ +# Dynamic Dispatch — Execute Tab Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate dynamic-dispatch UX (`imports` field, curated registry, auto-detection, target-input auto-populate, dismissible explainer) into the existing `Execute Transaction` page on `examples/react-app`, and remove the standalone `Dynamic Dispatch` tab. + +**Architecture:** Pure-data registry (`KNOWN_DISPATCH_PROGRAMS`) overlays the network's program index. The `parseLeoProgramFunctions` parser is extended with a body-level `call.dynamic` scan so the Imports field can appear for unknown dispatch programs too. Auto-population of a function's target-program input uses the existing hand-maintained `programIdField.ts` lookup. UI work is contained in `ExecuteTransaction.tsx` plus a small, opt-in `programIdAllowlist` prop on `ProgramAutocomplete`. + +**Tech Stack:** React 18, TypeScript, Vite, Jotai, TanStack Query, Radix UI (Tooltip already used elsewhere in the app), `@provablehq/aleo-wallet-adaptor-react`. No test framework in the example app — verification uses `npm run lint`, `npm run build`, and manual browser testing per the `testing-wallet-adapter-changes` skill. + +**Reference spec:** `docs/superpowers/specs/2026-04-28-dynamic-dispatch-execute-integration-design.md`. + +--- + +## File map + +### Create +- `examples/react-app/src/lib/dispatchPrograms.ts` — curated registry + lookup helpers. + +### Modify +- `examples/react-app/src/lib/utils.ts` — extend `parseLeoProgramFunctions` to populate `usesDynamicCall` per function; widen `FunctionInfo`. +- `examples/react-app/src/components/ProgramAutocomplete.tsx` — accept optional `programIdAllowlist` prop and intersect rendered list with it. +- `examples/react-app/src/components/functions/ExecuteTransaction.tsx` — add filter checkbox, dispatch alert, conditional Imports field, auto-populate logic, plumb `imports` to `executeTransaction`. +- `examples/react-app/src/lib/codeExamples.ts` — add commented `imports` line to `executeTransaction` example; remove `dynamicDispatch` block and dispatch-only placeholders (`TARGET_PROGRAM`, `FROM`, `TO`, `AMOUNT`, `MINT_AMOUNT`). +- `examples/react-app/src/routes.tsx` — remove `dynamic-dispatch` route + import. +- `examples/react-app/src/components/layout/Sidebar.tsx` — remove `Dynamic Dispatch` nav item + `Workflow` icon import if unused elsewhere. +- `examples/react-app/src/pages/index.ts` — remove `DynamicDispatchPage` export. + +### Delete +- `examples/react-app/src/pages/DynamicDispatchPage.tsx` +- `examples/react-app/src/components/functions/DynamicDispatch.tsx` + +### Keep (do not delete) +- `examples/react-app/src/lib/programIdField.ts` — required by Task 6 for target-input auto-population. + +--- + +## Verification commands (referenced throughout) + +All commands run from `examples/react-app`: + +- Lint: `npm run lint` +- Build / type-check: `npm run build` +- Dev server: `npm run dev` (open `http://localhost:5173`, sign in to a test wallet) + +The example app has no Vitest/Jest setup. Where tasks include "smoke test", that means: run `npm run dev`, open the relevant page in the browser, and exercise the described interaction. Do not add a test framework to satisfy this plan. + +--- + +## Task 1: Curated dispatch registry + +**Files:** +- Create: `examples/react-app/src/lib/dispatchPrograms.ts` + +- [ ] **Step 1: Create the registry module** + +Create `examples/react-app/src/lib/dispatchPrograms.ts` with: + +```ts +export interface DispatchFunctionEntry { + name: string; + /** + * Index of the function input that receives the target program as a `field` + * literal (i.e. the value passed to `call.dynamic`). For `route_transfer`, + * `route_deposit`, and `route_withdraw` on `token_router.aleo`, this is 0. + */ + targetInputIndex: number; +} + +export interface DispatchProgramEntry { + program: string; + knownTargets: string[]; + dispatchFunctions: DispatchFunctionEntry[]; + description?: string; +} + +export const KNOWN_DISPATCH_PROGRAMS: DispatchProgramEntry[] = [ + { + program: 'token_router.aleo', + knownTargets: ['toka_token.aleo', 'tokb_token.aleo'], + dispatchFunctions: [ + { name: 'route_transfer', targetInputIndex: 0 }, + { name: 'route_deposit', targetInputIndex: 0 }, + { name: 'route_withdraw', targetInputIndex: 0 }, + ], + description: + 'Token router that uses call.dynamic to forward transfers, deposits, and ' + + 'withdrawals to whichever target token program is supplied via `imports`.', + }, +]; + +export function getKnownDispatchProgram( + programId: string, +): DispatchProgramEntry | undefined { + return KNOWN_DISPATCH_PROGRAMS.find(p => p.program === programId); +} + +export function isKnownDispatchProgram(programId: string): boolean { + return getKnownDispatchProgram(programId) !== undefined; +} + +export function getKnownDispatchFunction( + programId: string, + functionName: string, +): DispatchFunctionEntry | undefined { + return getKnownDispatchProgram(programId)?.dispatchFunctions.find( + f => f.name === functionName, + ); +} + +export const KNOWN_DISPATCH_PROGRAM_IDS: string[] = KNOWN_DISPATCH_PROGRAMS.map( + p => p.program, +); +``` + +- [ ] **Step 2: Type-check** + +Run from `examples/react-app`: `npm run build` + +Expected: build succeeds; no new type errors. (No callers yet, so this should compile in isolation.) + +- [ ] **Step 3: Commit** + +```bash +git add examples/react-app/src/lib/dispatchPrograms.ts +git commit -m "feat(react-app): add curated dispatch program registry" +``` + +--- + +## Task 2: Extend `parseLeoProgramFunctions` with `usesDynamicCall` + +The existing parser in `examples/react-app/src/lib/utils.ts:24` bails out of "in function" mode at the first non-`input` body statement. We need to keep scanning each function's body for `call.dynamic` while preserving the existing input-extraction behavior (which intentionally stops at the first non-input line). + +Aleo program source from the network has no closing braces — functions are delimited by the next top-level `function`, `closure`, or `finalize` keyword. + +**Files:** +- Modify: `examples/react-app/src/lib/utils.ts:14-89` + +- [ ] **Step 1: Replace `FunctionInfo` and `parseLeoProgramFunctions`** + +In `examples/react-app/src/lib/utils.ts`, replace the existing `FunctionInfo` interface and `parseLeoProgramFunctions` function body. Keep `FunctionInput` and the `cn` helper untouched. Replace from the `export interface FunctionInfo {` line down through the closing `}` of `parseLeoProgramFunctions`: + +```ts +export interface FunctionInfo { + name: string; + inputs: FunctionInput[]; + /** + * True if the function's body contains a `call.dynamic` instruction. Used by + * the Execute page to reveal the Imports field for any dispatch-using + * function, including programs not in the curated registry. + */ + usesDynamicCall: boolean; +} + +/** + * Parses Aleo program source and returns one entry per `function NAME:` block. + * + * Inputs are collected only from the leading `input … as …` lines of each + * function (the existing behavior). The full function body, up to the next + * top-level `function` / `closure` / `finalize` keyword, is also scanned for + * the `call.dynamic` instruction so we can flag dispatch-using functions. + */ +export function parseLeoProgramFunctions(programCode: string): FunctionInfo[] { + if (!programCode || typeof programCode !== 'string') { + return []; + } + + const lines = programCode.split('\n'); + const functions: FunctionInfo[] = []; + let currentFunction: FunctionInfo | null = null; + let inFunction = false; + let inFunctionInputs = false; + + // Matches `call.dynamic` as a whole word. Aleo source from the network has + // no comments, so a plain substring check is acceptable; word boundaries + // guard against future false positives like `call.dynamic_extra`. + const dynamicCallPattern = /\bcall\.dynamic\b/; + + // Top-level keywords that terminate the current function's body. + const isFunctionTerminator = (trimmed: string): boolean => + /^function\s+[a-zA-Z_]/.test(trimmed) || + /^closure\s+[a-zA-Z_]/.test(trimmed) || + trimmed.startsWith('finalize ') || + trimmed === 'finalize:' || + /^finalize\s+[a-zA-Z_]/.test(trimmed); + + for (const rawLine of lines) { + const trimmedLine = rawLine.trim(); + + const functionMatch = trimmedLine.match(/^function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*:/); + if (functionMatch) { + if (currentFunction) { + functions.push(currentFunction); + } + currentFunction = { + name: functionMatch[1], + inputs: [], + usesDynamicCall: false, + }; + inFunction = true; + inFunctionInputs = true; + continue; + } + + if (!inFunction || !currentFunction) { + continue; + } + + // Function body terminator: next top-level definition. Push current and stop. + if (isFunctionTerminator(trimmedLine)) { + functions.push(currentFunction); + currentFunction = null; + inFunction = false; + inFunctionInputs = false; + continue; + } + + // Input collection: only while we're still in the leading input block. + if (inFunctionInputs && trimmedLine.startsWith('input ')) { + const inputMatch = trimmedLine.match( + /input\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s+([a-zA-Z0-9_.]+)\.([a-zA-Z]+)/, + ); + if (inputMatch) { + const [, name, type, visibility] = inputMatch; + currentFunction.inputs.push({ + name, + type, + visibility: visibility as 'public' | 'private', + }); + } + continue; + } + + // First non-input, non-comment, non-empty line means we've left the input block. + if ( + inFunctionInputs && + trimmedLine.length > 0 && + trimmedLine !== '{' && + !trimmedLine.startsWith('//') + ) { + inFunctionInputs = false; + } + + // Scan body for call.dynamic regardless of input-block state. + if (dynamicCallPattern.test(trimmedLine)) { + currentFunction.usesDynamicCall = true; + } + } + + if (currentFunction) { + functions.push(currentFunction); + } + + return functions; +} +``` + +- [ ] **Step 2: Type-check** + +Run from `examples/react-app`: `npm run build` + +Expected: build fails with type errors at `ExecuteTransaction.tsx` because the existing code constructs `FunctionInfo` shapes implicitly via the parser and reads `currentFunction.inputs` — but does not yet pass `usesDynamicCall`. Because the field is added to the *parser output* (not constructed elsewhere), there should actually be no errors. If the build succeeds, you're good. If it fails, the failure is a real bug — fix before continuing. + +- [ ] **Step 3: Manual sanity check (optional but recommended)** + +Add a temporary `console.log` at the bottom of `parseLeoProgramFunctions` (`console.log('parsed', functions)`), run `npm run dev`, navigate to Execute, select `credits.aleo` (no dispatch) — confirm `usesDynamicCall: false` for every function. Then if `token_router.aleo` is on testnet, select it and confirm `route_transfer` shows `usesDynamicCall: true`. Remove the `console.log` before committing. + +- [ ] **Step 4: Lint** + +Run from `examples/react-app`: `npm run lint` + +Expected: no lint errors. + +- [ ] **Step 5: Commit** + +```bash +git add examples/react-app/src/lib/utils.ts +git commit -m "feat(react-app): detect call.dynamic in parsed Leo functions" +``` + +--- + +## Task 3: `ProgramAutocomplete` — `programIdAllowlist` prop + +Make the autocomplete optionally restrict its dropdown to a caller-supplied set of program ids. The autocomplete remains generic; the Execute page is responsible for deciding when to pass the allowlist. + +**Files:** +- Modify: `examples/react-app/src/components/ProgramAutocomplete.tsx` + +- [ ] **Step 1: Add the prop and apply it** + +In `examples/react-app/src/components/ProgramAutocomplete.tsx`: + +1. Update the `ProgramAutocompleteProps` interface (around line 9) to add `programIdAllowlist?: string[];`. +2. Destructure it in the component signature (the function declaration around line 17), defaulting to `undefined`. +3. After `const programs = searchResults?.programs || [];` (around line 33), filter: + +```ts +const programs = (searchResults?.programs || []).filter(p => + programIdAllowlist ? programIdAllowlist.includes(p.id) : true, +); +``` + +That is the only change. Do not alter any other behavior in this file. + +- [ ] **Step 2: Lint and build** + +Run from `examples/react-app`: + +```bash +npm run lint +npm run build +``` + +Expected: both succeed. No callers pass `programIdAllowlist` yet, so behavior is unchanged in production. + +- [ ] **Step 3: Commit** + +```bash +git add examples/react-app/src/components/ProgramAutocomplete.tsx +git commit -m "feat(react-app): add programIdAllowlist prop to ProgramAutocomplete" +``` + +--- + +## Task 4: `ExecuteTransaction` — derived dispatch state and helpers + +Wire up the *information* about whether the current selection is a dispatch program/function, before adding any new UI. This task introduces no visible UI changes; it preps state used by Tasks 5–8. + +**Files:** +- Modify: `examples/react-app/src/components/functions/ExecuteTransaction.tsx` + +- [ ] **Step 1: Add imports at the top of the file** + +Find the existing import block at `examples/react-app/src/components/functions/ExecuteTransaction.tsx:1-19`. Append these imports below the existing ones (do not remove anything): + +```ts +import { + getKnownDispatchProgram, + getKnownDispatchFunction, + KNOWN_DISPATCH_PROGRAM_IDS, +} from '@/lib/dispatchPrograms'; +``` + +- [ ] **Step 2: Compute derived dispatch state** + +Inside the component body, after `const currentFunction = useMemo(...)` (currently around line 64), add: + +```ts +const knownDispatchProgram = useMemo( + () => (program ? getKnownDispatchProgram(program) : undefined), + [program], +); + +const knownDispatchFunction = useMemo( + () => + program && functionName + ? getKnownDispatchFunction(program, functionName) + : undefined, + [program, functionName], +); + +const showImportsField = + Boolean(knownDispatchFunction) || Boolean(currentFunction?.usesDynamicCall); +``` + +These are the three values downstream tasks consume. Do not introduce any other UI yet. + +- [ ] **Step 3: Build to confirm types** + +Run from `examples/react-app`: `npm run build` + +Expected: succeeds. The new locals are unused for now (TS is fine with this; ESLint may warn — if so, add `// eslint-disable-next-line @typescript-eslint/no-unused-vars` immediately above each unused declaration **for this task only**, and remove the disables in subsequent tasks as the values are consumed). + +If `npm run lint` fails on unused vars and you cannot tolerate the disables, skip the lint check for this commit and rely on the next task's consumer to clear it. Note in the commit body if you do this. + +- [ ] **Step 4: Commit** + +```bash +git add examples/react-app/src/components/functions/ExecuteTransaction.tsx +git commit -m "refactor(react-app): derive dispatch state in ExecuteTransaction" +``` + +--- + +## Task 5: "Dynamic dispatch only" filter checkbox + +Add the filter affordance and wire it through to `ProgramAutocomplete`. + +**Files:** +- Modify: `examples/react-app/src/components/functions/ExecuteTransaction.tsx` + +- [ ] **Step 1: Add filter state** + +In the component, alongside the other `useState` declarations (around lines 31–45), add: + +```ts +const [filterToDispatch, setFilterToDispatch] = useState(false); +``` + +- [ ] **Step 2: Render checkbox and pass allowlist** + +Locate the Program ID field block in JSX — currently `` followed by the `
` containing `` (around lines 256–272). + +Wrap the existing label row with the new filter checkbox so the layout becomes (replacing the `
` of the Program ID `space-y-2` group), and BEFORE the Function Name `space-y-2` block (which currently starts with `