From 0dc4fb98d536ab1749abef70f62d520798048e50 Mon Sep 17 00:00:00 2001
From: Mike Turner
Date: Thu, 30 Apr 2026 12:40:16 -0700
Subject: [PATCH 01/18] Enhance InputRequest and permission model in docs
Update InputRequest type to include new request kinds and clarify wallet behavior. Revise permission model and fulfillment flow for better user input handling.
Signed-off-by: Mike Turner
---
docs/adapter-privacy-extension.md | 116 ++++++++++++++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 docs/adapter-privacy-extension.md
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
new file mode 100644
index 0000000..b08585e
--- /dev/null
+++ b/docs/adapter-privacy-extension.md
@@ -0,0 +1,116 @@
+# 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 derive a value from the user's view key. The wallet fulfills the request before passing the transaction to the SDK.
+
+## Wire-level types
+
+```ts
+type Input = string | InputRequest;
+
+type InputRequest =
+ | { kind: "user"; label?: string } // Provides a form for the user to enter inputs. Only applies to .private inputs.
+ | { kind: "address"; label?: string } // Specification to fill the input field with the active address, possible in an address, group, scalar, or field input.
+ | { kind: "record"; program: string; filters?: RecordFilters } // Specification to use a record from a specific program with given filters.
+ | { kind: "viewKey"; label?: string }; // Specification to fill the input field with the view key behind the active address, possible in a scalar or field input.
+
+type RecordFilters = Record;
+type RecordMatcher = { eq?: string, gte?: string, lte?: string, neq?: string, }; // potential matching conditions.
+```
+
+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. Input a view key if where there's a field or scalar input.
+3. 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.
+
+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 used at `AdapterService.ts:494` as `programs.includes(program)`.
+
+### Proposed
+
+Replace `programs?: string[]` with a structured grant; add a separate `viewKeyExposure`.
+
+```ts
+interface ConnectHistory {
+ // ...unchanged fields...
+ decryptPermission: DecryptPermission;
+ recordAccess?: RecordAccessGrant;
+ viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // default DENY
+}
+
+type RecordAccessGrant =
+ | { level: "none" }
+ | { level: "anyProgram" }
+ | { level: "byProgram"; programs: ProgramGrant[] };
+
+interface ProgramGrant {
+ program: string;
+ records?: RecordName[], // Optional list of records and their fields which can be read. Undefined means all records for the program.
+}
+
+interface RecordGrant {
+ recordname: string; // The name of the record in the program.
+ fields: FieldGrant[] // The name of the field which can be read.
+}
+
+interface FieldGrant {
+ name: string; // The name of the record field to require access to.
+ read: true; // Whether or not this field is readable or the dapp can only request
+}
+```
+
+| Level | Meaning | Maps to today |
+|---|---|---|
+| `none` | Refuse every `kind: "record"` request and `requestRecords` call. | New. |
+| `anyProgram` | Records from any program. | `programs === undefined` |
+| `byProgram` (no `records`) | Rcords only from listed programs. All records, all fields. | `programs: string[]` |
+| `byProgram` (with `records`) | Within each program, only named records and fields can be read or requested access to. Empty fields in `RecordGrant` means all fields. | New. |
+
+Legacy `programs: string[]` migrates to `{ level: "byProgram", programs: programs.map(p => ({ program: p })) }` once on read, in `DappStorage`.
+
+Dapps can request usage of records via filters, but without
+
+### Independent rule for `kind: "user"`
+
+A wallet only renders a user-input prompt for parameters declared `.private` in the program source. Public parameters must be supplied as literals by the dapp. Enforced statically in the resolver, not in `ConnectHistory`.
+
+## 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 or viewKeyExposure" --> X["error to dapp"]
+ C -- ok --> D["ExecuteTransaction page"]
+ D --> R["fulfillInputRequests.ts"]
+ R -- "kind: record" --> R1["filter unspent records by where clause"]
+ R -- "kind: viewKey" --> R2["derive value via SDK"]
+ R -- "kind: user" --> R3["render typed form from program signature"]
+ R1 --> F["confirm screen shows every fulfilled value"]
+ R2 --> F
+ R3 --> 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 |
+|---|---|---|
+| `kind: "record"` | zero matches for `where` | fail loudly (matches `imports` precedent) |
+| `kind: "record"` | field outside `ProgramGrant.fields` | permission error at gate |
+| `kind: "user"` | parameter declared `.public` | fulfillment error before prompting |
+| `kind: "viewKey"` | `viewKeyExposure: "DENY"` | permission error at gate |
From be4f23eddc203b16f074baf83950561e8bb1a7e2 Mon Sep 17 00:00:00 2001
From: Mike Turner
Date: Thu, 30 Apr 2026 12:49:41 -0700
Subject: [PATCH 02/18] Add readAddress field to ConnectHistory interface
Signed-off-by: Mike Turner
---
docs/adapter-privacy-extension.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index b08585e..820349f 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -42,6 +42,7 @@ Replace `programs?: string[]` with a structured grant; add a separate `viewKeyEx
interface ConnectHistory {
// ...unchanged fields...
decryptPermission: DecryptPermission;
+ readAddress: bool;
recordAccess?: RecordAccessGrant;
viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // default DENY
}
From c6ad4256e457bd2b41415c96c14ba2ef7ae9f41c Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Mon, 4 May 2026 20:50:42 -0400
Subject: [PATCH 03/18] Refine InputRequest discriminator and permission model
Rename `kind` to `type` and `RecordMatcher` to `RecordFieldFilter`. Reframe
the permission model as additive: `programs` and `decryptPermission` are
preserved exactly, with `recordAccess` and `viewKeyExposure` as new opt-in
fields. Drop `anyProgram` level. Document backward-compatibility guarantees,
interaction rules, and validation failure modes.
---
docs/adapter-privacy-extension.md | 91 +++++++++++++++++++------------
1 file changed, 56 insertions(+), 35 deletions(-)
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index 820349f..6634c98 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -10,13 +10,12 @@ Let dapps emit `TransactionOptions` whose `inputs` slots are not always literal
type Input = string | InputRequest;
type InputRequest =
- | { kind: "user"; label?: string } // Provides a form for the user to enter inputs. Only applies to .private inputs.
- | { kind: "address"; label?: string } // Specification to fill the input field with the active address, possible in an address, group, scalar, or field input.
- | { kind: "record"; program: string; filters?: RecordFilters } // Specification to use a record from a specific program with given filters.
- | { kind: "viewKey"; label?: string }; // Specification to fill the input field with the view key behind the active address, possible in a scalar or field input.
+ | { 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; filters?: RecordFilters } // Specification to use a record from a specific program with given filters. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
+ | { type: "viewKey"; label?: string }; // Specification to fill the input field with the view key behind the active address. Allowed in a input position with an aleo type of: `scalar or field`.
-type RecordFilters = Record;
-type RecordMatcher = { eq?: string, gte?: string, lte?: string, neq?: string, }; // potential matching conditions.
+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.
```
The `InputRequest` sends a request to the wallet (which is then authorized by the user) to do the following:
@@ -32,54 +31,72 @@ Adapters are ONLY allowed to successfully execute this if the user has authorize
### Today
-`ConnectHistory` (`src/app/common/types/IAdapterService.ts:92`) carries `decryptPermission` plus a flat `programs?: string[]` allowlist used at `AdapterService.ts:494` as `programs.includes(program)`.
+`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
-Replace `programs?: string[]` with a structured grant; add a separate `viewKeyExposure`.
+Add three new fields to `ConnectHistory`, all additive. The existing `decryptPermission` and `programs?: string[]` are preserved exactly, and the `connect()` signature does not change.
```ts
interface ConnectHistory {
- // ...unchanged fields...
- decryptPermission: DecryptPermission;
- readAddress: bool;
- recordAccess?: RecordAccessGrant;
- viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // default DENY
+ // ...existing fields...
+ decryptPermission: DecryptPermission; // unchanged
+ programs?: string[]; // unchanged — program-level gate for both transaction execution and record operations
+ readAddress: bool; // already on this branch
+ recordAccess?: RecordAccessGrant; // new — opt-in record/field narrowing
+ viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // new — default DENY
}
type RecordAccessGrant =
| { level: "none" }
- | { level: "anyProgram" }
| { level: "byProgram"; programs: ProgramGrant[] };
interface ProgramGrant {
program: string;
- records?: RecordName[], // Optional list of records and their fields which can be read. Undefined means all records for the program.
+ records?: RecordGrant[]; // undefined → all records of this program; present → only the listed records
}
interface RecordGrant {
- recordname: string; // The name of the record in the program.
- fields: FieldGrant[] // The name of the field which can be read.
+ recordname: string;
+ fields?: FieldGrant[]; // undefined → all fields; present → only the listed fields
}
interface FieldGrant {
- name: string; // The name of the record field to require access to.
- read: true; // Whether or not this field is readable or the dapp can only request
+ name: string;
}
```
-| Level | Meaning | Maps to today |
-|---|---|---|
-| `none` | Refuse every `kind: "record"` request and `requestRecords` call. | New. |
-| `anyProgram` | Records from any program. | `programs === undefined` |
-| `byProgram` (no `records`) | Rcords only from listed programs. All records, all fields. | `programs: string[]` |
-| `byProgram` (with `records`) | Within each program, only named records and fields can be read or requested access to. Empty fields in `RecordGrant` means all fields. | New. |
+| 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."
+- **`viewKeyExposure` defaults to `DENY`**: matches today's de-facto behavior, since no view-key-derived inputs were possible.
+- **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
-Legacy `programs: string[]` migrates to `{ level: "byProgram", programs: programs.map(p => ({ program: p })) }` once on read, in `DappStorage`.
+When `recordAccess` is set, these rules apply on top of the unchanged `programs` allowlist:
-Dapps can request usage of records via filters, but without
+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 (a) decrypted in plaintext via `requestRecords`, or (b) 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.
+5. **`level: "none"`** refuses all record operations regardless of `programs`. Transaction execution with literal inputs is unaffected.
-### Independent rule for `kind: "user"`
+### Independent rule for `type: "user"`
A wallet only renders a user-input prompt for parameters declared `.private` in the program source. Public parameters must be supplied as literals by the dapp. Enforced statically in the resolver, not in `ConnectHistory`.
@@ -92,9 +109,9 @@ flowchart TD
C -- "violates recordAccess or viewKeyExposure" --> X["error to dapp"]
C -- ok --> D["ExecuteTransaction page"]
D --> R["fulfillInputRequests.ts"]
- R -- "kind: record" --> R1["filter unspent records by where clause"]
- R -- "kind: viewKey" --> R2["derive value via SDK"]
- R -- "kind: user" --> R3["render typed form from program signature"]
+ R -- "type: record" --> R1["filter unspent records by where clause"]
+ R -- "type: viewKey" --> R2["derive value via SDK"]
+ R -- "type: user" --> R3["render typed form from program signature"]
R1 --> F["confirm screen shows every fulfilled value"]
R2 --> F
R3 --> F
@@ -111,7 +128,11 @@ The worker boundary still receives `string[]`. All fulfillment is wallet-side; t
| Request | Condition | Result |
|---|---|---|
-| `kind: "record"` | zero matches for `where` | fail loudly (matches `imports` precedent) |
-| `kind: "record"` | field outside `ProgramGrant.fields` | permission error at gate |
-| `kind: "user"` | parameter declared `.public` | fulfillment error before prompting |
-| `kind: "viewKey"` | `viewKeyExposure: "DENY"` | permission error at gate |
+| `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 `ProgramGrant.fields` | permission error at gate |
+| `type: "user"` | parameter declared `.public` | fulfillment error before prompting |
+| `type: "viewKey"` | `viewKeyExposure: "DENY"` | permission error at gate |
From 5067daf51697b32e9e953da657f9c0bef4e1ef03 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Mon, 4 May 2026 21:09:54 -0400
Subject: [PATCH 04/18] Specify readAddress as a permission
Make readAddress optional with default true. Add an Address exposure
subsection covering all four address-leakage surfaces (connect return,
_publicKey getter, init handler, plaintext-bearing methods) and their
behavior under readAddress: false. Document the decryptPermission:
NoDecrypt-only constraint and the deliberate signMessage leak.
---
docs/adapter-privacy-extension.md | 35 ++++++++++++++++++++++++++++++-
1 file changed, 34 insertions(+), 1 deletion(-)
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index 6634c98..eceaa65 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -42,7 +42,7 @@ interface ConnectHistory {
// ...existing fields...
decryptPermission: DecryptPermission; // unchanged
programs?: string[]; // unchanged — program-level gate for both transaction execution and record operations
- readAddress: bool; // already on this branch
+ readAddress?: boolean; // new — opt-in address withholding; default true
recordAccess?: RecordAccessGrant; // new — opt-in record/field narrowing
viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // new — default DENY
}
@@ -96,6 +96,39 @@ When `recordAccess` is set, these rules apply on top of the unchanged `programs`
4. **Field narrowing**: when `RecordGrant.fields` is present, only the listed field names may be (a) decrypted in plaintext via `requestRecords`, or (b) 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.
5. **`level: "none"`** refuses all record operations regardless of `programs`. Transaction execution with literal inputs is unaffected.
+### 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`) | `null` (type becomes `string \| null`) |
+| `provider._publicKey` getter | returns `""` regardless of connection state |
+| `init` message handler | must return `address: null` if ever extended to populate; today's `undefined` already complies |
+| `decrypt(record)` | refused with permission error |
+| `requestRecords(program, includePlaintext?)` | refused with permission error — use `type: "record"` requests for transaction inputs instead |
+| `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.
+
### Independent rule for `type: "user"`
A wallet only renders a user-input prompt for parameters declared `.private` in the program source. Public parameters must be supplied as literals by the dapp. Enforced statically in the resolver, not in `ConnectHistory`.
From fa653fe45decaec39316bad82634bdccae938d26 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Mon, 4 May 2026 22:10:15 -0400
Subject: [PATCH 05/18] Implement wallet-specified inputs and structured
permission grants
Adds the dapp-facing types and signature changes from
docs/adapter-privacy-extension.md.
aleo-types: new InputRequest, RecordFilters, RecordFieldFilter,
TransactionInput types with isLiteralInput / hasInputRequest helpers.
TransactionOptions.inputs widens from string[] to TransactionInput[].
aleo-wallet-standard: new ConnectOptions, RecordAccessGrant, ProgramGrant,
RecordGrant, FieldGrant (with readAccess), ViewKeyExposure, plus a
hasUnsupportedConnectOptions helper. ConnectFeature.connect and
WalletAdapterProps.connect accept an optional fourth options argument.
aleo-wallet-adaptor/core: BaseAleoWalletAdapter.connect accepts options,
enforces the readAddress: false / decryptPermission: NoDecrypt precondition,
tracks _readAddress, and short-circuits decrypt, requestRecords,
transitionViewKeys, requestTransactionHistory under address withholding.
New error classes: WalletInputRequestNotSupportedError,
WalletConnectOptionsNotSupportedError, WalletAddressWithheldError. The new
types are re-exported from core for dapp ergonomics.
Wallet adapters: leo, fox, soter, puzzle widen connect with options? and
throw WalletConnectOptionsNotSupportedError when any of the new options are
set; their executeTransaction throws WalletInputRequestNotSupportedError
when any input is an InputRequest. Shield forwards options to the
extension and tolerates an empty address under readAddress: false.
react: AleoWalletProvider accepts new optional props recordAccess,
viewKeyExposure, readAddress and forwards them on every adapter.connect
call. Dependency arrays updated.
Doc: clarified that Account.address returns "" (empty string) under
readAddress: false rather than null, and that FieldGrant.readAccess
controls plaintext exposure independently of filterability.
---
.changeset/wallet-specified-inputs.md | 23 +++++++
docs/adapter-privacy-extension.md | 7 +-
packages/aleo-types/src/transaction.ts | 60 +++++++++++++++-
.../aleo-wallet-adaptor/core/src/adapter.ts | 44 +++++++++++-
.../aleo-wallet-adaptor/core/src/errors.ts | 48 +++++++++++++
.../aleo-wallet-adaptor/core/src/types.ts | 16 +++++
.../react/src/WalletProvider.tsx | 36 ++++++++--
.../wallets/fox/src/FoxWalletAdapter.ts | 12 ++++
.../wallets/leo/src/LeoWalletAdapter.ts | 12 ++++
.../wallets/puzzle/src/PuzzleWalletAdapter.ts | 15 +++-
.../wallets/shield/src/ShieldWalletAdapter.ts | 7 +-
.../wallets/shield/src/types.ts | 2 +
.../wallets/soter/src/SoterWalletAdapter.ts | 12 ++++
packages/aleo-wallet-standard/src/adapter.ts | 4 +-
packages/aleo-wallet-standard/src/features.ts | 4 +-
packages/aleo-wallet-standard/src/wallet.ts | 69 +++++++++++++++++++
16 files changed, 355 insertions(+), 16 deletions(-)
create mode 100644 .changeset/wallet-specified-inputs.md
diff --git a/.changeset/wallet-specified-inputs.md b/.changeset/wallet-specified-inputs.md
new file mode 100644
index 0000000..703e18f
--- /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, derive a value from the user's view key, 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`, `viewKeyExposure`, 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`, `viewKeyExposure`, 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
index eceaa65..8aa036e 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -63,6 +63,7 @@ interface RecordGrant {
interface FieldGrant {
name: string;
+ readAccess?: boolean; // undefined → true; false → field is usable as a filter key but plaintext is withheld on decrypt
}
```
@@ -93,7 +94,7 @@ When `recordAccess` is set, these rules apply on top of the unchanged `programs`
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 (a) decrypted in plaintext via `requestRecords`, or (b) 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.
+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.
### Address exposure
@@ -110,9 +111,9 @@ The dapp transacts on the user's behalf without learning the address. Every dire
| Surface | Behavior under `readAddress: false` |
|---|---|
-| `connect()` return value (`Account.address`) | `null` (type becomes `string \| null`) |
+| `connect()` return value (`Account.address`) | `""` (empty string) — type stays `string` |
| `provider._publicKey` getter | returns `""` regardless of connection state |
-| `init` message handler | must return `address: null` if ever extended to populate; today's `undefined` already complies |
+| `init` message handler | must return `address: ""` if ever extended to populate; today's `undefined` already complies |
| `decrypt(record)` | refused with permission error |
| `requestRecords(program, includePlaintext?)` | refused with permission error — use `type: "record"` requests for transaction inputs instead |
| `transitionViewKeys(txid)` | refused with permission error (view keys derive the address via `address = view_key · G`) |
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index 6263f2f..1938eda 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -38,6 +38,61 @@ export interface Transaction {
data?: Record;
}
+/**
+ * Per-field comparison conditions on a record field. All present operators are AND-combined.
+ */
+export interface RecordFieldFilter {
+ eq?: string;
+ gte?: string;
+ lte?: string;
+ neq?: string;
+}
+
+/**
+ * Map from a record field name (or dotted struct path, e.g. "data.amount") to a filter.
+ * Multiple entries are AND-combined.
+ */
+export type RecordFilters = Record;
+
+/**
+ * A request the dapp emits in place of a literal input. The wallet fulfills the
+ * request before passing the transaction to the SDK. See
+ * `docs/adapter-privacy-extension.md` for the full specification.
+ */
+export type InputRequest =
+ | {
+ /** Fill the input slot with the active address. Allowed in `address`, `group`, `scalar`, or `field` positions. */
+ type: 'address';
+ label?: string;
+ }
+ | {
+ /** Auto-select an owned record from `program` matching `filters`. Allowed in `record`, `dynamic_record`, or `external_record` positions. */
+ type: 'record';
+ program: string;
+ filters?: RecordFilters;
+ }
+ | {
+ /** Fill the input slot with the view key behind the active address. Allowed in `scalar` or `field` positions. */
+ type: 'viewKey';
+ label?: string;
+ };
+
+/**
+ * One element of a transaction's `inputs` array. A literal Aleo value (string)
+ * or an `InputRequest` describing a value the wallet should supply.
+ */
+export type TransactionInput = string | InputRequest;
+
+/** Type guard for a literal input slot. */
+export function isLiteralInput(input: TransactionInput): input is string {
+ return typeof input === 'string';
+}
+
+/** Returns true if any element of `inputs` is an `InputRequest` rather than a literal. */
+export function hasInputRequest(inputs: TransactionInput[]): boolean {
+ return inputs.some(i => typeof i !== 'string');
+}
+
/**
* Transaction creation options
*/
@@ -53,9 +108,10 @@ export interface TransactionOptions {
function: string;
/**
- * The function inputs
+ * The function inputs. Each entry is either a literal Aleo value (string)
+ * or an `InputRequest` describing a value the wallet should supply.
*/
- inputs: string[];
+ inputs: TransactionInput[];
/**
* The transaction fee to pay
diff --git a/packages/aleo-wallet-adaptor/core/src/adapter.ts b/packages/aleo-wallet-adaptor/core/src/adapter.ts
index b3e277b..79c47f4 100644
--- a/packages/aleo-wallet-adaptor/core/src/adapter.ts
+++ b/packages/aleo-wallet-adaptor/core/src/adapter.ts
@@ -7,6 +7,7 @@ import {
} from '@provablehq/aleo-types';
import {
AleoChain,
+ ConnectOptions,
StandardWallet,
WalletAdapter,
WalletFeatureName,
@@ -18,7 +19,11 @@ import {
AleoDeployment,
RecordStatusFilter,
} from '@provablehq/aleo-wallet-standard';
-import { WalletFeatureNotAvailableError, WalletNotConnectedError } from './errors';
+import {
+ WalletAddressWithheldError,
+ WalletFeatureNotAvailableError,
+ WalletNotConnectedError,
+} from './errors';
import { WalletConnectionError } from './errors';
/**
@@ -91,28 +96,50 @@ export abstract class BaseAleoWalletAdapter
return !!this.account;
}
+ /**
+ * Tracks `options.readAddress` from the most recent successful connect.
+ * `false` means the dapp opted into address withholding; methods that would
+ * leak the address (decrypt, requestRecords, transitionViewKeys,
+ * requestTransactionHistory) short-circuit with `WalletAddressWithheldError`.
+ */
+ protected _readAddress: boolean = true;
+
/**
* Connect to the wallet
* @param network The network to connect to
* @param decryptPermission The decrypt permission
* @param programs The programs to connect to
+ * @param options Optional additive connect-time options
* @returns The connected account
*/
async connect(
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise {
if (!this._wallet) {
throw new WalletConnectionError('No wallet provider found');
}
+ // Precondition: readAddress: false is only coherent with NoDecrypt, since every
+ // plaintext-bearing decrypt operation reveals the owner address.
+ if (
+ options?.readAddress === false &&
+ decryptPermission !== WalletDecryptPermission.NoDecrypt
+ ) {
+ throw new WalletConnectionError(
+ 'readAddress: false is only valid with decryptPermission: NoDecrypt. ' +
+ 'Plaintext-bearing operations would leak the owner address.',
+ );
+ }
const feature = this._wallet.features[WalletFeatureName.CONNECT];
if (!feature || !feature.available) {
throw new WalletFeatureNotAvailableError(WalletFeatureName.CONNECT);
}
try {
- const account = await feature.connect(network, decryptPermission, programs);
+ const account = await feature.connect(network, decryptPermission, programs, options);
this.account = account;
+ this._readAddress = options?.readAddress !== false;
this.emit('connect', account);
return account;
} catch (err) {
@@ -135,6 +162,7 @@ export abstract class BaseAleoWalletAdapter
}
}
this.account = undefined;
+ this._readAddress = true;
this.emit('disconnect');
}
@@ -208,6 +236,9 @@ export abstract class BaseAleoWalletAdapter
if (!this._wallet || !this.account) {
throw new WalletNotConnectedError();
}
+ if (!this._readAddress) {
+ throw new WalletAddressWithheldError('decrypt');
+ }
const feature = this._wallet.features[WalletFeatureName.DECRYPT];
if (!feature || !feature.available) {
throw new WalletFeatureNotAvailableError(WalletFeatureName.DECRYPT);
@@ -223,6 +254,9 @@ export abstract class BaseAleoWalletAdapter
if (!this._wallet || !this.account) {
throw new WalletNotConnectedError();
}
+ if (!this._readAddress) {
+ throw new WalletAddressWithheldError('requestRecords');
+ }
const feature = this._wallet.features[WalletFeatureName.REQUEST_RECORDS];
if (!feature || !feature.available) {
throw new WalletFeatureNotAvailableError(WalletFeatureName.REQUEST_RECORDS);
@@ -246,6 +280,9 @@ export abstract class BaseAleoWalletAdapter
if (!this._wallet || !this.account) {
throw new WalletNotConnectedError();
}
+ if (!this._readAddress) {
+ throw new WalletAddressWithheldError('transitionViewKeys');
+ }
const feature = this._wallet.features[WalletFeatureName.TRANSITION_VIEWKEYS];
if (!feature || !feature.available) {
throw new WalletFeatureNotAvailableError(WalletFeatureName.TRANSITION_VIEWKEYS);
@@ -257,6 +294,9 @@ export abstract class BaseAleoWalletAdapter
if (!this._wallet || !this.account) {
throw new WalletNotConnectedError();
}
+ if (!this._readAddress) {
+ throw new WalletAddressWithheldError('requestTransactionHistory');
+ }
const feature = this._wallet.features[WalletFeatureName.REQUEST_TRANSACTION_HISTORY];
if (!feature || !feature.available) {
throw new WalletFeatureNotAvailableError(WalletFeatureName.REQUEST_TRANSACTION_HISTORY);
diff --git a/packages/aleo-wallet-adaptor/core/src/errors.ts b/packages/aleo-wallet-adaptor/core/src/errors.ts
index fe44ffb..9593953 100644
--- a/packages/aleo-wallet-adaptor/core/src/errors.ts
+++ b/packages/aleo-wallet-adaptor/core/src/errors.ts
@@ -133,3 +133,51 @@ export class MethodNotImplementedError extends WalletError {
super(`Method not implemented: ${method}`);
}
}
+
+/**
+ * Thrown by a wallet adapter that does not yet support `InputRequest` slots
+ * in `TransactionOptions.inputs`. The dapp should pass literal Aleo string
+ * values, or switch to a wallet that supports wallet-specified inputs.
+ */
+export class WalletInputRequestNotSupportedError extends WalletError {
+ name = 'WalletInputRequestNotSupportedError';
+
+ constructor(walletName: string) {
+ super(
+ `Wallet "${walletName}" does not yet support InputRequest inputs. ` +
+ 'Pass literal Aleo string values, or switch to a wallet that supports wallet-specified inputs.',
+ );
+ }
+}
+
+/**
+ * Thrown by a wallet adapter that does not yet honor the new `ConnectOptions`
+ * fields (`recordAccess`, `viewKeyExposure`, `readAddress: false`).
+ */
+export class WalletConnectOptionsNotSupportedError extends WalletError {
+ name = 'WalletConnectOptionsNotSupportedError';
+
+ constructor(walletName: string) {
+ super(
+ `Wallet "${walletName}" does not yet support ConnectOptions ` +
+ '(recordAccess, viewKeyExposure, readAddress). ' +
+ 'Connect without these options, or switch to a wallet that supports them.',
+ );
+ }
+}
+
+/**
+ * Thrown when a dapp tries to call a method that the wallet refuses while
+ * the connection was made with `readAddress: false` (e.g. `decrypt`,
+ * `requestRecords`, `transitionViewKeys`, `requestTransactionHistory`).
+ */
+export class WalletAddressWithheldError extends WalletError {
+ name = 'WalletAddressWithheldError';
+
+ constructor(method: string) {
+ super(
+ `"${method}" is not available when the connection was made with readAddress: false. ` +
+ 'Reconnect with readAddress: true to use this method.',
+ );
+ }
+}
diff --git a/packages/aleo-wallet-adaptor/core/src/types.ts b/packages/aleo-wallet-adaptor/core/src/types.ts
index f180e71..424bcea 100644
--- a/packages/aleo-wallet-adaptor/core/src/types.ts
+++ b/packages/aleo-wallet-adaptor/core/src/types.ts
@@ -1,3 +1,19 @@
import { WalletDecryptPermission } from '@provablehq/aleo-wallet-standard';
export { WalletDecryptPermission as DecryptPermission };
+export type {
+ ConnectOptions,
+ FieldGrant,
+ ProgramGrant,
+ RecordAccessGrant,
+ RecordGrant,
+ ViewKeyExposure,
+} from '@provablehq/aleo-wallet-standard';
+export { hasUnsupportedConnectOptions } from '@provablehq/aleo-wallet-standard';
+export type {
+ InputRequest,
+ RecordFieldFilter,
+ RecordFilters,
+ TransactionInput,
+} from '@provablehq/aleo-types';
+export { hasInputRequest, isLiteralInput } from '@provablehq/aleo-types';
diff --git a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
index 1860b92..86bc9e5 100644
--- a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
+++ b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
@@ -5,7 +5,10 @@ import {
WalletReadyState,
WalletAdapter,
AleoDeployment,
+ ConnectOptions,
+ RecordAccessGrant,
RecordStatusFilter,
+ ViewKeyExposure,
} from '@provablehq/aleo-wallet-standard';
import { Network, TransactionOptions } from '@provablehq/aleo-types';
import { Wallet, WalletContext } from './context';
@@ -29,6 +32,20 @@ export interface WalletProviderProps {
localStorageKey?: string;
decryptPermission?: DecryptPermission;
programs?: string[];
+ /**
+ * Opt-in record/field narrowing on top of `programs`. Forwarded to the
+ * wallet's connect call. Only honored by wallets that support it (e.g. shield).
+ */
+ recordAccess?: RecordAccessGrant;
+ /**
+ * View-key exposure preference. Defaults to `DENY` when omitted.
+ */
+ viewKeyExposure?: ViewKeyExposure;
+ /**
+ * When `false`, the dapp transacts without learning the user's address.
+ * Defaults to `true`. Only valid with `decryptPermission: NoDecrypt`.
+ */
+ readAddress?: boolean;
}
const initialState: {
@@ -54,7 +71,16 @@ export const AleoWalletProvider: FC = ({
localStorageKey = 'walletName',
decryptPermission = DecryptPermission.NoDecrypt,
programs,
+ recordAccess,
+ viewKeyExposure,
+ readAddress,
}) => {
+ const connectOptions = useMemo(() => {
+ if (recordAccess === undefined && viewKeyExposure === undefined && readAddress === undefined) {
+ return undefined;
+ }
+ return { recordAccess, viewKeyExposure, readAddress };
+ }, [recordAccess, viewKeyExposure, readAddress]);
const [name, setName] = useLocalStorage(localStorageKey, null);
const [{ wallet, adapter, publicKey, connected, network }, setState] = useState(initialState);
const readyState = adapter?.readyState || WalletReadyState.UNSUPPORTED;
@@ -211,7 +237,7 @@ export const AleoWalletProvider: FC = ({
}));
try {
- const account = await adapter.connect(initialNetwork, decryptPermission, programs);
+ const account = await adapter.connect(initialNetwork, decryptPermission, programs, connectOptions);
setState(state => ({
...state,
publicKey: account.address,
@@ -226,7 +252,7 @@ export const AleoWalletProvider: FC = ({
setReconnecting(false);
isReconnecting.current = false;
}
- }, [adapter, disconnect, handleError, initialNetwork, decryptPermission, programs]);
+ }, [adapter, disconnect, handleError, initialNetwork, decryptPermission, programs, connectOptions]);
// Setup and teardown event listeners when the adapter changes
useEffect(() => {
@@ -269,7 +295,7 @@ export const AleoWalletProvider: FC = ({
isConnecting.current = true;
setConnecting(true);
try {
- const account = await adapter.connect(initialNetwork, decryptPermission, programs);
+ const account = await adapter.connect(initialNetwork, decryptPermission, programs, connectOptions);
lastAuthorizedAccount.current = account.address ?? null;
} catch (error: unknown) {
// Clear the selected wallet
@@ -298,7 +324,7 @@ export const AleoWalletProvider: FC = ({
if (adapter && connected) {
disconnect();
}
- }, [decryptPermission, programs]);
+ }, [decryptPermission, programs, connectOptions]);
// Connect the adapter to the wallet
const connect = useCallback(async () => {
@@ -319,7 +345,7 @@ export const AleoWalletProvider: FC = ({
isConnecting.current = true;
setConnecting(true);
try {
- const account = await adapter.connect(initialNetwork, decryptPermission, programs);
+ const account = await adapter.connect(initialNetwork, decryptPermission, programs, connectOptions);
lastAuthorizedAccount.current = account.address ?? null;
} catch (error: unknown) {
// Clear the selected wallet
diff --git a/packages/aleo-wallet-adaptor/wallets/fox/src/FoxWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/fox/src/FoxWalletAdapter.ts
index 00a16ea..fc473ff 100644
--- a/packages/aleo-wallet-adaptor/wallets/fox/src/FoxWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/fox/src/FoxWalletAdapter.ts
@@ -1,11 +1,14 @@
import {
Account,
+ hasInputRequest,
Network,
TransactionOptions,
TransactionStatusResponse,
} from '@provablehq/aleo-types';
import {
AleoDeployment,
+ ConnectOptions,
+ hasUnsupportedConnectOptions,
RecordStatusFilter,
WalletDecryptPermission,
WalletName,
@@ -17,10 +20,12 @@ import {
MethodNotImplementedError,
scopePollingDetectionStrategy,
WalletConnectionError,
+ WalletConnectOptionsNotSupportedError,
WalletDecryptionError,
WalletDecryptionNotAllowedError,
WalletDisconnectionError,
WalletError,
+ WalletInputRequestNotSupportedError,
WalletNotConnectedError,
WalletSignMessageError,
WalletTransactionError,
@@ -121,7 +126,11 @@ export class FoxWalletAdapter extends BaseAleoWalletAdapter {
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise {
+ if (hasUnsupportedConnectOptions(options)) {
+ throw new WalletConnectOptionsNotSupportedError(this.name);
+ }
try {
if (this.readyState !== WalletReadyState.INSTALLED) {
throw new WalletConnectionError('Fox Wallet is not available');
@@ -258,6 +267,9 @@ export class FoxWalletAdapter extends BaseAleoWalletAdapter {
* @returns The executed temporary transaction ID
*/
async executeTransaction(options: TransactionOptions): Promise<{ transactionId: string }> {
+ if (hasInputRequest(options.inputs)) {
+ throw new WalletInputRequestNotSupportedError(this.name);
+ }
if (!this._publicKey || !this.account) {
throw new WalletNotConnectedError();
}
diff --git a/packages/aleo-wallet-adaptor/wallets/leo/src/LeoWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/leo/src/LeoWalletAdapter.ts
index b73c963..f02c733 100644
--- a/packages/aleo-wallet-adaptor/wallets/leo/src/LeoWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/leo/src/LeoWalletAdapter.ts
@@ -1,5 +1,6 @@
import {
Account,
+ hasInputRequest,
Network,
TransactionOptions,
TransactionStatus,
@@ -7,6 +8,8 @@ import {
} from '@provablehq/aleo-types';
import {
AleoDeployment,
+ ConnectOptions,
+ hasUnsupportedConnectOptions,
RecordStatusFilter,
WalletDecryptPermission,
WalletName,
@@ -17,10 +20,12 @@ import {
filterRecordsByStatus,
MethodNotImplementedError,
WalletConnectionError,
+ WalletConnectOptionsNotSupportedError,
WalletDecryptionNotAllowedError,
WalletDecryptionError,
WalletDisconnectionError,
WalletError,
+ WalletInputRequestNotSupportedError,
WalletNotConnectedError,
WalletSignMessageError,
WalletTransactionError,
@@ -126,7 +131,11 @@ export class LeoWalletAdapter extends BaseAleoWalletAdapter {
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise {
+ if (hasUnsupportedConnectOptions(options)) {
+ throw new WalletConnectOptionsNotSupportedError(this.name);
+ }
try {
if (this.readyState !== WalletReadyState.INSTALLED) {
throw new WalletConnectionError('Leo Wallet is not available');
@@ -259,6 +268,9 @@ export class LeoWalletAdapter extends BaseAleoWalletAdapter {
* @returns The executed temporary transaction ID
*/
async executeTransaction(options: TransactionOptions): Promise<{ transactionId: string }> {
+ if (hasInputRequest(options.inputs)) {
+ throw new WalletInputRequestNotSupportedError(this.name);
+ }
if (!this._publicKey || !this.account) {
throw new WalletNotConnectedError();
}
diff --git a/packages/aleo-wallet-adaptor/wallets/puzzle/src/PuzzleWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/puzzle/src/PuzzleWalletAdapter.ts
index 177467a..d15ac57 100644
--- a/packages/aleo-wallet-adaptor/wallets/puzzle/src/PuzzleWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/puzzle/src/PuzzleWalletAdapter.ts
@@ -1,5 +1,6 @@
import {
Account,
+ hasInputRequest,
TransactionOptions,
Network,
TransactionStatusResponse,
@@ -7,6 +8,8 @@ import {
} from '@provablehq/aleo-types';
import {
AleoDeployment,
+ ConnectOptions,
+ hasUnsupportedConnectOptions,
RecordStatusFilter,
WalletDecryptPermission,
WalletName,
@@ -18,10 +21,12 @@ import {
MethodNotImplementedError,
scopePollingDetectionStrategy,
WalletConnectionError,
+ WalletConnectOptionsNotSupportedError,
WalletDecryptionError,
WalletDecryptionNotAllowedError,
WalletDisconnectionError,
WalletError,
+ WalletInputRequestNotSupportedError,
WalletNotConnectedError,
WalletSignMessageError,
WalletTransactionError,
@@ -135,7 +140,11 @@ export class PuzzleWalletAdapter extends BaseAleoWalletAdapter {
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise {
+ if (hasUnsupportedConnectOptions(options)) {
+ throw new WalletConnectOptionsNotSupportedError(this.name);
+ }
try {
if (this.readyState !== WalletReadyState.INSTALLED) {
throw new WalletConnectionError('Puzzle Wallet is not available');
@@ -256,6 +265,9 @@ export class PuzzleWalletAdapter extends BaseAleoWalletAdapter {
* @returns The executed temporary transaction ID
*/
async executeTransaction(options: TransactionOptions): Promise<{ transactionId: string }> {
+ if (hasInputRequest(options.inputs)) {
+ throw new WalletInputRequestNotSupportedError(this.name);
+ }
if (!this._publicKey || !this.account) {
throw new WalletNotConnectedError();
}
@@ -272,7 +284,8 @@ export class PuzzleWalletAdapter extends BaseAleoWalletAdapter {
programId: options.program,
functionId: options.function,
fee,
- inputs: options.inputs,
+ // hasInputRequest guard above ensures every element is a string literal.
+ inputs: options.inputs as string[],
address: this._publicKey,
network: PUZZLE_NETWORK_MAP[this.network],
};
diff --git a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
index 86f0c35..9974582 100644
--- a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
@@ -7,6 +7,7 @@ import {
} from '@provablehq/aleo-types';
import {
AleoDeployment,
+ ConnectOptions,
RecordStatusFilter,
WalletDecryptPermission,
WalletName,
@@ -112,6 +113,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise {
try {
if (this.readyState !== WalletReadyState.INSTALLED) {
@@ -124,6 +126,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
network,
decryptPermission,
programs,
+ options,
);
this._publicKey = connectResult?.address || '';
this._onNetworkChange(network);
@@ -133,7 +136,9 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
);
}
- if (!this._publicKey) {
+ // When the dapp opted into address withholding (readAddress: false),
+ // an empty address is the expected result, not an error.
+ if (!this._publicKey && options?.readAddress !== false) {
throw new WalletConnectionError('No address returned from wallet');
}
diff --git a/packages/aleo-wallet-adaptor/wallets/shield/src/types.ts b/packages/aleo-wallet-adaptor/wallets/shield/src/types.ts
index 890d15d..24f668c 100644
--- a/packages/aleo-wallet-adaptor/wallets/shield/src/types.ts
+++ b/packages/aleo-wallet-adaptor/wallets/shield/src/types.ts
@@ -6,6 +6,7 @@ import {
} from '@provablehq/aleo-types';
import {
AleoDeployment,
+ ConnectOptions,
EventEmitter,
WalletDecryptPermission,
} from '@provablehq/aleo-wallet-standard';
@@ -32,6 +33,7 @@ export interface ShieldWallet extends EventEmitter {
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise<{ address: string }>;
disconnect(): Promise;
signMessage(message: Uint8Array): Promise;
diff --git a/packages/aleo-wallet-adaptor/wallets/soter/src/SoterWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/soter/src/SoterWalletAdapter.ts
index 4b50596..8158a6c 100644
--- a/packages/aleo-wallet-adaptor/wallets/soter/src/SoterWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/soter/src/SoterWalletAdapter.ts
@@ -1,10 +1,13 @@
import {
Account,
+ hasInputRequest,
Network,
TransactionOptions,
TransactionStatusResponse,
} from '@provablehq/aleo-types';
import {
+ ConnectOptions,
+ hasUnsupportedConnectOptions,
RecordStatusFilter,
WalletDecryptPermission,
WalletName,
@@ -16,10 +19,12 @@ import {
scopePollingDetectionStrategy,
MethodNotImplementedError,
WalletConnectionError,
+ WalletConnectOptionsNotSupportedError,
WalletDecryptionError,
WalletDecryptionNotAllowedError,
WalletDisconnectionError,
WalletError,
+ WalletInputRequestNotSupportedError,
WalletNotConnectedError,
WalletSignMessageError,
WalletTransactionError,
@@ -117,7 +122,11 @@ export class SoterWalletAdapter extends BaseAleoWalletAdapter {
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise {
+ if (hasUnsupportedConnectOptions(options)) {
+ throw new WalletConnectOptionsNotSupportedError(this.name);
+ }
try {
if (this.readyState !== WalletReadyState.INSTALLED) {
throw new WalletConnectionError('Soter Wallet is not available');
@@ -246,6 +255,9 @@ export class SoterWalletAdapter extends BaseAleoWalletAdapter {
* @returns The executed temporary transaction ID
*/
async executeTransaction(options: TransactionOptions): Promise<{ transactionId: string }> {
+ if (hasInputRequest(options.inputs)) {
+ throw new WalletInputRequestNotSupportedError(this.name);
+ }
if (!this._publicKey || !this.account) {
throw new WalletNotConnectedError();
}
diff --git a/packages/aleo-wallet-standard/src/adapter.ts b/packages/aleo-wallet-standard/src/adapter.ts
index 4fff6d9..a3e2e8a 100644
--- a/packages/aleo-wallet-standard/src/adapter.ts
+++ b/packages/aleo-wallet-standard/src/adapter.ts
@@ -7,7 +7,7 @@ import {
} from '@provablehq/aleo-types';
import { AleoChain } from './chains';
import type { RecordStatusFilter } from './features';
-import { WalletDecryptPermission, WalletName, WalletReadyState } from './wallet';
+import { ConnectOptions, WalletDecryptPermission, WalletName, WalletReadyState } from './wallet';
import { EventEmitter, WalletEvents } from './events';
export interface AleoDeployment {
@@ -71,12 +71,14 @@ export interface WalletAdapterProps {
* @param network The network to connect to
* @param decryptPermission The decrypt permission
* @param programs The programs to connect to
+ * @param options Optional additive connect-time options (record access, view-key exposure, address withholding)
* @returns The connected account
*/
connect(
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise;
/**
diff --git a/packages/aleo-wallet-standard/src/features.ts b/packages/aleo-wallet-standard/src/features.ts
index 4d3de24..a788aed 100644
--- a/packages/aleo-wallet-standard/src/features.ts
+++ b/packages/aleo-wallet-standard/src/features.ts
@@ -6,7 +6,7 @@ import {
TxHistoryResult,
} from '@provablehq/aleo-types';
import { AleoChain } from './chains';
-import { WalletDecryptPermission } from './wallet';
+import { ConnectOptions, WalletDecryptPermission } from './wallet';
import { AleoDeployment } from './adapter';
export type RecordStatusFilter = 'all' | 'spent' | 'unspent';
@@ -37,12 +37,14 @@ export interface ConnectFeature extends WalletFeature {
* @param network The network to connect to
* @param decryptPermission The decrypt permission
* @param programs The programs to connect to
+ * @param options Optional additive connect-time options (record access, view-key exposure, address withholding)
* @returns The connected account
*/
connect(
network: Network,
decryptPermission: WalletDecryptPermission,
programs?: string[],
+ options?: ConnectOptions,
): Promise;
/**
diff --git a/packages/aleo-wallet-standard/src/wallet.ts b/packages/aleo-wallet-standard/src/wallet.ts
index f4053d5..3db7531 100644
--- a/packages/aleo-wallet-standard/src/wallet.ts
+++ b/packages/aleo-wallet-standard/src/wallet.ts
@@ -168,3 +168,72 @@ export enum WalletDecryptPermission {
AutoDecrypt = 'AUTO_DECRYPT', // The dapp can decrypt any requested records
OnChainHistory = 'ON_CHAIN_HISTORY', // The dapp can request on-chain record plain texts and transaction ids, but cannot decrypt them
}
+
+/**
+ * Field-level grant within a `RecordGrant`.
+ *
+ * `readAccess` controls plaintext exposure independently of filterability:
+ * - `readAccess === true` (or omitted): the field's plaintext is included in `requestRecords` decrypt output.
+ * - `readAccess === false`: the field remains usable as a filter key in `type: "record"` requests, but its plaintext is redacted from decrypt results.
+ */
+export interface FieldGrant {
+ name: string;
+ readAccess?: boolean;
+}
+
+/**
+ * Per-record grant within a `ProgramGrant`. `fields === undefined` permits all fields.
+ */
+export interface RecordGrant {
+ recordname: string;
+ fields?: FieldGrant[];
+}
+
+/**
+ * Per-program grant within a `RecordAccessGrant`. `records === undefined` permits all records.
+ */
+export interface ProgramGrant {
+ program: string;
+ records?: RecordGrant[];
+}
+
+/**
+ * Optional fine-grained record access grant the dapp can supply at connect time.
+ * When undefined, the wallet falls back to the existing `programs` allowlist for
+ * broad record access. See `docs/adapter-privacy-extension.md` for full semantics.
+ */
+export type RecordAccessGrant =
+ | { level: 'none' }
+ | { level: 'byProgram'; programs: ProgramGrant[] };
+
+/**
+ * View-key exposure preference. Defaults to `DENY` when omitted.
+ */
+export type ViewKeyExposure = 'DENY' | 'PER_TX_PROMPT';
+
+/**
+ * Optional, additive connect-time options. All fields are opt-in; omitting them
+ * preserves today's behavior.
+ */
+export interface ConnectOptions {
+ /** Opt-in record/field narrowing on top of `programs`. */
+ recordAccess?: RecordAccessGrant;
+ /** View-key exposure preference; defaults to `DENY`. */
+ viewKeyExposure?: ViewKeyExposure;
+ /** When `false`, the dapp transacts without learning the user's address. Defaults to `true`. */
+ readAddress?: boolean;
+}
+
+/**
+ * Returns true if `options` requests any capability beyond the legacy default.
+ * Wallet adapters that do not yet implement these capabilities should throw
+ * before attempting to connect.
+ */
+export function hasUnsupportedConnectOptions(options?: ConnectOptions): boolean {
+ if (!options) return false;
+ return (
+ options.recordAccess !== undefined ||
+ options.viewKeyExposure !== undefined ||
+ options.readAddress === false
+ );
+}
From c3ea98672f128364768abdcc44a236f4236de509 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Wed, 6 May 2026 19:12:34 -0400
Subject: [PATCH 06/18] Add record UIDs and envelope-metadata grants for record
reads
---
docs/adapter-privacy-extension.md | 70 ++++++++++++++-----
packages/aleo-types/src/transaction.ts | 41 ++++++++++-
.../aleo-wallet-adaptor/core/src/adapter.ts | 15 ++++
.../aleo-wallet-adaptor/core/src/errors.ts | 13 ++++
.../aleo-wallet-adaptor/core/src/types.ts | 2 +
packages/aleo-wallet-standard/src/wallet.ts | 13 ++++
6 files changed, 134 insertions(+), 20 deletions(-)
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index 8aa036e..0275d9b 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -11,13 +11,26 @@ 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; filters?: RecordFilters } // Specification to use a record from a specific program with given filters. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
+ | { type: "record"; program: string; filters?: RecordFilters; uid?: string } // Specification to use a record from a specific program. 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 matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
| { type: "viewKey"; label?: string }; // Specification to fill the input field with the view key behind the active address. Allowed in a input position with an aleo type of: `scalar or field`.
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).
+}
```
+`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. Input a view key if where there's a field or scalar input.
@@ -62,7 +75,7 @@ interface RecordGrant {
}
interface FieldGrant {
- name: string;
+ 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
}
```
@@ -97,6 +110,21 @@ When `recordAccess` is set, these rules apply on top of the unchanged `programs`
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.
+
### 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.
@@ -109,17 +137,17 @@ The dapp learns the address through the same paths as today. The only change is
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 return `address: ""` if ever extended to populate; today's `undefined` already complies |
-| `decrypt(record)` | refused with permission error |
-| `requestRecords(program, includePlaintext?)` | refused with permission error — use `type: "record"` requests for transaction inputs instead |
-| `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 |
+| 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`
@@ -130,10 +158,6 @@ Because every plaintext-bearing decrypt operation is refused under `readAddress:
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.
-### Independent rule for `type: "user"`
-
-A wallet only renders a user-input prompt for parameters declared `.private` in the program source. Public parameters must be supplied as literals by the dapp. Enforced statically in the resolver, not in `ConnectHistory`.
-
## Fulfillment flow
```mermaid
@@ -167,6 +191,14 @@ The worker boundary still receives `string[]`. All fulfillment is wallet-side; t
| `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 `ProgramGrant.fields` | permission error at gate |
-| `type: "user"` | parameter declared `.public` | fulfillment error before prompting |
+| `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: "viewKey"` | `viewKeyExposure: "DENY"` | permission error at gate |
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index 1938eda..3d32945 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -66,10 +66,18 @@ export type InputRequest =
label?: string;
}
| {
- /** Auto-select an owned record from `program` matching `filters`. Allowed in `record`, `dynamic_record`, or `external_record` positions. */
+ /**
+ * Use an owned record from `program` as the input. When `uid` is present,
+ * it pins a specific record previously returned by `requestRecords` and
+ * `filters` is ignored. When absent, the wallet auto-selects an unspent
+ * record matching `filters`. The two are mutually exclusive — supplying
+ * both is rejected before reaching the wallet. Allowed in `record`,
+ * `dynamic_record`, or `external_record` positions.
+ */
type: 'record';
program: string;
filters?: RecordFilters;
+ uid?: string;
}
| {
/** Fill the input slot with the view key behind the active address. Allowed in `scalar` or `field` positions. */
@@ -83,6 +91,37 @@ export type InputRequest =
*/
export type TransactionInput = string | InputRequest;
+/**
+ * Structured form of a record's plaintext fields, returned alongside the
+ * wallet-defined record envelope from `requestRecords`. Only fields the dapp
+ * has read access to are present; redacted fields are omitted (not
+ * present-with-undefined). See `docs/adapter-privacy-extension.md` for the
+ * grant rules that govern field exposure.
+ */
+export interface RecordView {
+ fields: Record;
+}
+
+/**
+ * Additive contract for the per-record envelope returned by `requestRecords`.
+ * Wallets keep their existing record shape (e.g. Shield's `OwnedRecord`); this
+ * interface declares the two new fields conforming wallets emit on top.
+ *
+ * - `recordView` — structured form of the plaintext, populated whenever the
+ * wallet decrypted the record.
+ * - `uid` — wallet-issued opaque handle, stable for the lifetime of the
+ * connection. Pass back as `uid` in a `type: "record"` `InputRequest` to pin
+ * this exact record. Not derived from the record's commitment, nonce, or tag.
+ *
+ * Both are optional in the type because pre-spec wallets won't emit them;
+ * conforming wallets always populate them.
+ */
+export interface RecordEnvelope {
+ recordView?: RecordView;
+ uid?: string;
+ [legacyField: string]: unknown;
+}
+
/** Type guard for a literal input slot. */
export function isLiteralInput(input: TransactionInput): input is string {
return typeof input === 'string';
diff --git a/packages/aleo-wallet-adaptor/core/src/adapter.ts b/packages/aleo-wallet-adaptor/core/src/adapter.ts
index 79c47f4..e3ab78b 100644
--- a/packages/aleo-wallet-adaptor/core/src/adapter.ts
+++ b/packages/aleo-wallet-adaptor/core/src/adapter.ts
@@ -1,6 +1,7 @@
import {
Account,
Network,
+ TransactionInput,
TransactionOptions,
TransactionStatusResponse,
TxHistoryResult,
@@ -22,6 +23,7 @@ import {
import {
WalletAddressWithheldError,
WalletFeatureNotAvailableError,
+ WalletInputRequestInvalidError,
WalletNotConnectedError,
} from './errors';
import { WalletConnectionError } from './errors';
@@ -191,6 +193,7 @@ export abstract class BaseAleoWalletAdapter
if (!this._wallet || !this.account) {
throw new WalletNotConnectedError();
}
+ validateInputRequests(options.inputs);
const feature = this._wallet.features[WalletFeatureName.EXECUTE];
if (!feature || !feature.available) {
throw new WalletFeatureNotAvailableError(WalletFeatureName.EXECUTE);
@@ -347,3 +350,15 @@ export function scopePollingDetectionStrategy(detect: () => boolean): void {
// Strategy #4: Detect synchronously, now.
detectAndDispose();
}
+
+function validateInputRequests(inputs: TransactionInput[]): void {
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i];
+ if (typeof input === 'string') continue;
+ if (input.type === 'record' && input.uid !== undefined && input.filters !== undefined) {
+ throw new WalletInputRequestInvalidError(
+ `inputs[${i}]: type "record" cannot specify both \`uid\` and \`filters\`. \`uid\` pins a specific record returned by requestRecords; filters are ignored when \`uid\` is set.`,
+ );
+ }
+ }
+}
diff --git a/packages/aleo-wallet-adaptor/core/src/errors.ts b/packages/aleo-wallet-adaptor/core/src/errors.ts
index 9593953..8df6d16 100644
--- a/packages/aleo-wallet-adaptor/core/src/errors.ts
+++ b/packages/aleo-wallet-adaptor/core/src/errors.ts
@@ -166,6 +166,19 @@ export class WalletConnectOptionsNotSupportedError extends WalletError {
}
}
+/**
+ * Thrown when an `InputRequest` of `type: "record"` provides both `uid` and
+ * `filters`. `uid` pins a specific record returned by `requestRecords`; when
+ * present, filters cannot apply. The two are mutually exclusive.
+ */
+export class WalletInputRequestInvalidError extends WalletError {
+ name = 'WalletInputRequestInvalidError';
+
+ constructor(reason: string) {
+ super(`InputRequest is invalid: ${reason}`);
+ }
+}
+
/**
* Thrown when a dapp tries to call a method that the wallet refuses while
* the connection was made with `readAddress: false` (e.g. `decrypt`,
diff --git a/packages/aleo-wallet-adaptor/core/src/types.ts b/packages/aleo-wallet-adaptor/core/src/types.ts
index 424bcea..dfd5536 100644
--- a/packages/aleo-wallet-adaptor/core/src/types.ts
+++ b/packages/aleo-wallet-adaptor/core/src/types.ts
@@ -12,8 +12,10 @@ export type {
export { hasUnsupportedConnectOptions } from '@provablehq/aleo-wallet-standard';
export type {
InputRequest,
+ RecordEnvelope,
RecordFieldFilter,
RecordFilters,
+ RecordView,
TransactionInput,
} from '@provablehq/aleo-types';
export { hasInputRequest, isLiteralInput } from '@provablehq/aleo-types';
diff --git a/packages/aleo-wallet-standard/src/wallet.ts b/packages/aleo-wallet-standard/src/wallet.ts
index 3db7531..5d6cab3 100644
--- a/packages/aleo-wallet-standard/src/wallet.ts
+++ b/packages/aleo-wallet-standard/src/wallet.ts
@@ -172,6 +172,19 @@ export enum WalletDecryptPermission {
/**
* Field-level grant within a `RecordGrant`.
*
+ * `name` accepts either a record-body field name (e.g. `"amount"`,
+ * `"data.amount"` for dotted paths into struct fields) or a `$`-prefixed
+ * envelope-metadata token from this reserved set:
+ *
+ * `$commitment`, `$tag`, `$transitionId`, `$transactionId`, `$outputIndex`,
+ * `$transactionIndex`, `$transitionIndex`, `$owner`, `$sender`
+ *
+ * The `$` prefix prevents collision with body fields named identically. When
+ * `RecordGrant.fields` is present, body fields not listed are stripped from
+ * `recordView.fields`, and envelope metadata not listed via `$`-prefixed
+ * entries is stripped from the returned record. See
+ * `docs/adapter-privacy-extension.md` for the full grant matrix.
+ *
* `readAccess` controls plaintext exposure independently of filterability:
* - `readAccess === true` (or omitted): the field's plaintext is included in `requestRecords` decrypt output.
* - `readAccess === false`: the field remains usable as a filter key in `type: "record"` requests, but its plaintext is redacted from decrypt results.
From e2b001ee531c26cc669e6dc99e575d941956cec3 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Tue, 12 May 2026 18:49:25 -0400
Subject: [PATCH 07/18] Add private input example to the dapp page
---
examples/react-app/src/App.tsx | 9 +
.../components/functions/PrivateInputs.tsx | 1324 +++++++++++++++++
.../src/components/layout/Sidebar.tsx | 2 +
examples/react-app/src/lib/codeExamples.ts | 42 +
examples/react-app/src/lib/store/global.ts | 15 +-
.../react-app/src/pages/PrivateInputsPage.tsx | 9 +
examples/react-app/src/pages/index.ts | 1 +
examples/react-app/src/routes.tsx | 5 +
.../aleo-wallet-adaptor/core/src/adapter.ts | 2 +-
.../aleo-wallet-adaptor/core/src/index.ts | 6 +-
.../react/src/WalletProvider.tsx | 41 +-
.../wallets/shield/src/ShieldWalletAdapter.ts | 28 +-
12 files changed, 1466 insertions(+), 18 deletions(-)
create mode 100644 examples/react-app/src/components/functions/PrivateInputs.tsx
create mode 100644 examples/react-app/src/pages/PrivateInputsPage.tsx
diff --git a/examples/react-app/src/App.tsx b/examples/react-app/src/App.tsx
index bc1761a..94c90d2 100644
--- a/examples/react-app/src/App.tsx
+++ b/examples/react-app/src/App.tsx
@@ -14,6 +14,9 @@ import {
decryptPermissionAtom,
networkAtom,
programsAtom,
+ readAddressAtom,
+ recordAccessAtom,
+ viewKeyExposureAtom,
} from './lib/store/global';
import { routes } from './routes';
// Import wallet adapter CSS after our own styles
@@ -37,6 +40,9 @@ export function App() {
const decryptPermission = useAtomValue(decryptPermissionAtom);
const autoConnect = useAtomValue(autoConnectAtom);
const programs = useAtomValue(programsAtom);
+ const recordAccess = useAtomValue(recordAccessAtom);
+ const readAddress = useAtomValue(readAddressAtom);
+ const viewKeyExposure = useAtomValue(viewKeyExposureAtom);
return (
@@ -47,6 +53,9 @@ export function App() {
onError={error => toast.error(error.message)}
decryptPermission={decryptPermission}
programs={programs}
+ recordAccess={recordAccess}
+ readAddress={readAddress}
+ viewKeyExposure={viewKeyExposure}
>
diff --git a/examples/react-app/src/components/functions/PrivateInputs.tsx b/examples/react-app/src/components/functions/PrivateInputs.tsx
new file mode 100644
index 0000000..8c1a4d0
--- /dev/null
+++ b/examples/react-app/src/components/functions/PrivateInputs.tsx
@@ -0,0 +1,1324 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAtom } from 'jotai';
+import { Button } from '@/components/ui/button';
+import { Label } from '@/components/ui/label';
+import { Input } from '@/components/ui/input';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Switch } from '@/components/ui/switch';
+import {
+ CheckCircle,
+ Copy,
+ Database,
+ Loader2,
+ Lock,
+ Plus,
+ ShieldAlert,
+ Trash2,
+ XCircle,
+ Zap,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import { useWallet } from '@provablehq/aleo-wallet-adaptor-react';
+import { useWalletModal } from '@provablehq/aleo-wallet-adaptor-react-ui';
+import {
+ Network,
+ RecordEnvelope,
+ RecordFieldFilter,
+ RecordFilters,
+ TransactionInput,
+ TransactionStatus,
+} from '@provablehq/aleo-types';
+import {
+ FieldGrant,
+ ProgramGrant,
+ RecordAccessGrant,
+ RecordGrant,
+ ViewKeyExposure,
+} from '@provablehq/aleo-wallet-adaptor-core';
+import { CodePanel } from '../CodePanel';
+import { codeExamples, PLACEHOLDERS } from '@/lib/codeExamples';
+import {
+ decryptPermissionAtom,
+ readAddressAtom,
+ recordAccessAtom,
+ viewKeyExposureAtom,
+} from '@/lib/store/global';
+import { DecryptPermission } from '@provablehq/aleo-wallet-adaptor-core';
+import { useProgram } from '@/lib/hooks/useProgram';
+
+type FilterOp = 'eq' | 'gte' | 'lte' | 'neq';
+type FilterRow = { field: string; op: FilterOp; value: string };
+
+type RecordSlotKind = 'record' | 'external_record' | 'dynamic_record';
+type ParsedSlot =
+ | { kind: 'primitive'; name: string; baseType: string; visibility: string; raw: string }
+ | {
+ kind: 'record';
+ name: string;
+ program: string;
+ recordname: string;
+ recordKind: RecordSlotKind;
+ raw: string;
+ };
+
+type RecordSlotMode = 'plaintext' | 'pick' | 'filter';
+type PrimitiveSlotMode = 'literal' | 'address' | 'viewKey';
+type SlotState =
+ | { kind: 'primitive'; mode: PrimitiveSlotMode; value: string }
+ | {
+ kind: 'record';
+ mode: RecordSlotMode;
+ plaintext: string;
+ uid: string;
+ filterRows: FilterRow[];
+ };
+
+const RECORD_SUFFIXES = ['record', 'external_record', 'dynamic_record'] as const;
+const DEFAULT_CREDITS_FILTER: FilterRow[] = [{ field: 'microcredits', op: 'gte', value: '101u64' }];
+
+// Per the spec (docs/adapter-privacy-extension.md): type:"address" InputRequest is allowed in
+// `address` | `group` | `scalar` | `field` slots; type:"viewKey" is allowed in `scalar` | `field`.
+const ADDRESS_REQUEST_ALLOWED = new Set(['address', 'group', 'scalar', 'field']);
+const VIEWKEY_REQUEST_ALLOWED = new Set(['scalar', 'field']);
+
+function primitiveSlotModes(baseType: string): PrimitiveSlotMode[] {
+ const modes: PrimitiveSlotMode[] = ['literal'];
+ if (ADDRESS_REQUEST_ALLOWED.has(baseType)) modes.push('address');
+ if (VIEWKEY_REQUEST_ALLOWED.has(baseType)) modes.push('viewKey');
+ return modes;
+}
+
+function parseTypeExpr(name: string, typeExpr: string): ParsedSlot | null {
+ const lastDot = typeExpr.lastIndexOf('.');
+ if (lastDot < 0) return null;
+ const head = typeExpr.slice(0, lastDot);
+ const suffix = typeExpr.slice(lastDot + 1);
+ if ((RECORD_SUFFIXES as readonly string[]).includes(suffix)) {
+ const slash = head.lastIndexOf('/');
+ const program = slash >= 0 ? head.slice(0, slash) : '';
+ const recordname = slash >= 0 ? head.slice(slash + 1) : head;
+ return {
+ kind: 'record',
+ name,
+ program,
+ recordname,
+ recordKind: suffix as RecordSlotKind,
+ raw: typeExpr,
+ };
+ }
+ return { kind: 'primitive', name, baseType: head, visibility: suffix, raw: typeExpr };
+}
+
+function parseFunctionInputs(source: string, functionName: string): ParsedSlot[] {
+ const lines = source.split('\n');
+ const slots: ParsedSlot[] = [];
+ let inFunction = false;
+ let inInputs = false;
+ for (const rawLine of lines) {
+ const t = rawLine.trim();
+ const fnMatch = t.match(/^function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*:/);
+ if (fnMatch) {
+ if (fnMatch[1] === functionName) {
+ inFunction = true;
+ inInputs = true;
+ continue;
+ }
+ if (inFunction) break;
+ continue;
+ }
+ if (!inFunction) continue;
+ if (/^closure\s+/.test(t) || /^finalize/.test(t)) break;
+ if (inInputs && t.startsWith('input ')) {
+ const m = t.match(/^input\s+(\w+)\s+as\s+(.+?);\s*$/);
+ if (m) {
+ const slot = parseTypeExpr(m[1], m[2]);
+ if (slot) slots.push(slot);
+ }
+ continue;
+ }
+ if (inInputs && t.length > 0 && !t.startsWith('//')) inInputs = false;
+ }
+ return slots;
+}
+
+function defaultSlotState(slot: ParsedSlot, fallbackProgram: string): SlotState {
+ if (slot.kind === 'primitive') {
+ // Default address-typed slots to wallet-provided active address (privacy-preserving default).
+ const mode: PrimitiveSlotMode = slot.baseType === 'address' ? 'address' : 'literal';
+ return { kind: 'primitive', mode, value: '' };
+ }
+ const slotProgram = slot.program || fallbackProgram;
+ const isCredits = slotProgram === 'credits.aleo' && slot.recordname === 'credits';
+ return {
+ kind: 'record',
+ mode: 'filter',
+ plaintext: '',
+ uid: '',
+ filterRows: isCredits ? [...DEFAULT_CREDITS_FILTER] : [{ field: '', op: 'eq', value: '' }],
+ };
+}
+
+function buildFilters(rows: FilterRow[]): RecordFilters {
+ const out: Record = {};
+ for (const row of rows) {
+ const field = row.field.trim();
+ const value = row.value.trim();
+ if (!field || !value) continue;
+ if (!out[field]) out[field] = {};
+ out[field][row.op] = value;
+ }
+ return out;
+}
+
+function buildInputs(
+ parsedSlots: ParsedSlot[],
+ slotStates: SlotState[],
+ fallbackProgram: string,
+): TransactionInput[] {
+ return parsedSlots.map((slot, i) => {
+ const state = slotStates[i];
+ if (!state) throw new Error(`slot ${i} (${slot.name}) has no state`);
+ if (state.kind === 'primitive') {
+ if (state.mode === 'address') return { type: 'address' };
+ if (state.mode === 'viewKey') return { type: 'viewKey' };
+ if (!state.value.trim()) {
+ throw new Error(`slot ${i} (${slot.name}: ${slot.raw}) is empty`);
+ }
+ return state.value.trim();
+ }
+ // record slot
+ const slotProgram =
+ (slot as Extract).program || fallbackProgram;
+ if (state.mode === 'plaintext') {
+ if (!state.plaintext.trim()) {
+ throw new Error(`slot ${i} (${slot.name}) plaintext is empty`);
+ }
+ return state.plaintext.trim();
+ }
+ if (state.mode === 'pick') {
+ if (!state.uid) {
+ throw new Error(`slot ${i} (${slot.name}) — pick a record from the dropdown`);
+ }
+ return { type: 'record', program: slotProgram, uid: state.uid };
+ }
+ // filter
+ const filters = buildFilters(state.filterRows);
+ if (Object.keys(filters).length === 0) {
+ throw new Error(`slot ${i} (${slot.name}) — add at least one filter row`);
+ }
+ return { type: 'record', program: slotProgram, filters };
+ });
+}
+
+type FormState = {
+ programName: string;
+ functionName: string;
+};
+
+const DEFAULTS: FormState = {
+ programName: 'credits.aleo',
+ functionName: 'transfer_private',
+};
+
+const DEFAULT_PROGRAM_GRANTS: ProgramGrant[] = [
+ {
+ program: 'credits.aleo',
+ records: [
+ {
+ recordname: 'credits',
+ fields: [{ name: 'microcredits' }, { name: '$commitment' }],
+ },
+ ],
+ },
+];
+
+const PRESERVED_ENVELOPE_KEYS = new Set([
+ 'programName',
+ 'recordName',
+ 'spent',
+ 'blockHeight',
+ 'blockTimestamp',
+ 'recordView',
+ 'uid',
+]);
+
+const METADATA_TOKEN_TO_LEGACY_KEY: Record = {
+ $commitment: 'commitment',
+ $tag: 'tag',
+ $transitionId: 'transitionId',
+ $transactionId: 'transactionId',
+ $outputIndex: 'outputIndex',
+ $transactionIndex: 'transactionIndex',
+ $transitionIndex: 'transitionIndex',
+ $owner: 'owner',
+ $sender: 'sender',
+};
+
+function buildRecordAccessGrant(programGrants: ProgramGrant[]): RecordAccessGrant {
+ return { level: 'byProgram', programs: programGrants };
+}
+
+function shortUid(uid: string): string {
+ if (uid.length <= 14) return uid;
+ return `${uid.slice(0, 6)}…${uid.slice(-6)}`;
+}
+
+export function PrivateInputs() {
+ const {
+ connected,
+ disconnect,
+ requestRecords,
+ executeTransaction,
+ transactionStatus: getTransactionStatus,
+ network,
+ } = useWallet();
+ const { setVisible: openWalletModal } = useWalletModal();
+ const [, setRecordAccess] = useAtom(recordAccessAtom);
+ const [readAddress, setReadAddress] = useAtom(readAddressAtom);
+ const [viewKeyExposure, setViewKeyExposure] = useAtom(viewKeyExposureAtom);
+ const [decryptPermission, setDecryptPermission] = useAtom(decryptPermissionAtom);
+
+ const [form, setForm] = useState(DEFAULTS);
+ const [programGrants, setProgramGrants] = useState(DEFAULT_PROGRAM_GRANTS);
+ const [showGrantJson, setShowGrantJson] = useState(false);
+ const [slotStates, setSlotStates] = useState([]);
+ const [records, setRecords] = useState([]);
+ const [isFetching, setIsFetching] = useState(false);
+ const [isExecuting, setIsExecuting] = useState(false);
+ const [isPolling, setIsPolling] = useState(false);
+ const [txStatus, setTxStatus] = useState(null);
+ const [txError, setTxError] = useState(null);
+ const [onchainTxId, setOnchainTxId] = useState(null);
+ const [validatorMessage, setValidatorMessage] = useState(null);
+ const pollingIntervalRef = useRef(null);
+
+ const programQuery = useProgram(form.programName.trim());
+ const programSource = useMemo(() => {
+ const data = programQuery.data;
+ if (typeof data !== 'string' || !data) return '';
+ // The /program endpoint sometimes returns a JSON-encoded string; mirror ExecuteTransaction's
+ // handling (JSON.parse if it looks like a JSON string literal, else use raw).
+ try {
+ return data.startsWith('"') ? JSON.parse(data) : data;
+ } catch {
+ return data;
+ }
+ }, [programQuery.data]);
+
+ const parsedSlots = useMemo(
+ () => (programSource ? parseFunctionInputs(programSource, form.functionName.trim()) : []),
+ [programSource, form.functionName],
+ );
+
+ // Regenerate slot states whenever the parsed signature changes (program or function).
+ useEffect(() => {
+ setSlotStates(parsedSlots.map(s => defaultSlotState(s, form.programName.trim())));
+ }, [parsedSlots, form.programName]);
+
+ // Spec interlock: readAddress:false requires decryptPermission:NoDecrypt
+ // (every plaintext decrypt would leak the owner address). See
+ // docs/adapter-privacy-extension.md "Compatibility constraint with decryptPermission".
+ const readAddressInterlockError = useMemo(() => {
+ if (readAddress === false && decryptPermission !== DecryptPermission.NoDecrypt) {
+ return `readAddress: false requires decryptPermission: NoDecrypt — currently ${decryptPermission}`;
+ }
+ return null;
+ }, [readAddress, decryptPermission]);
+
+ // Soft validation — the structured form prevents type errors by construction, but a programmer
+ // can still leave a name blank or use an unknown $-token. Surface those as a non-blocking warning.
+ const grantValidationError = useMemo(() => {
+ for (let pi = 0; pi < programGrants.length; pi++) {
+ const pg = programGrants[pi];
+ if (!pg.program.trim()) return `program #${pi + 1} has no program ID`;
+ for (let ri = 0; ri < (pg.records ?? []).length; ri++) {
+ const rg = pg.records![ri];
+ if (!rg.recordname.trim()) {
+ return `program "${pg.program}" record #${ri + 1} has no recordname`;
+ }
+ for (let fi = 0; fi < (rg.fields ?? []).length; fi++) {
+ const fg = rg.fields![fi];
+ if (!fg.name.trim()) {
+ return `program "${pg.program}" record "${rg.recordname}" field #${fi + 1} has no name`;
+ }
+ }
+ }
+ }
+ return null;
+ }, [programGrants]);
+
+ // Nested mutators for the grant tree
+ const updateProgram = (pi: number, patch: Partial) =>
+ setProgramGrants(gs => gs.map((g, j) => (j === pi ? { ...g, ...patch } : g)));
+ const addProgram = () => setProgramGrants(gs => [...gs, { program: '', records: [] }]);
+ const removeProgram = (pi: number) => setProgramGrants(gs => gs.filter((_, j) => j !== pi));
+
+ const setRecords_ = (pi: number, fn: (rs: RecordGrant[]) => RecordGrant[]) =>
+ updateProgram(pi, { records: fn(programGrants[pi].records ?? []) });
+ const addRecord = (pi: number) => setRecords_(pi, rs => [...rs, { recordname: '', fields: [] }]);
+ const removeRecord = (pi: number, ri: number) =>
+ setRecords_(pi, rs => rs.filter((_, j) => j !== ri));
+ const updateRecord = (pi: number, ri: number, patch: Partial) =>
+ setRecords_(pi, rs => rs.map((r, j) => (j === ri ? { ...r, ...patch } : r)));
+
+ const setFields_ = (pi: number, ri: number, fn: (fs: FieldGrant[]) => FieldGrant[]) =>
+ updateRecord(pi, ri, { fields: fn(programGrants[pi].records?.[ri].fields ?? []) });
+ const addField = (pi: number, ri: number) => setFields_(pi, ri, fs => [...fs, { name: '' }]);
+ const removeField = (pi: number, ri: number, fi: number) =>
+ setFields_(pi, ri, fs => fs.filter((_, j) => j !== fi));
+ const updateField = (pi: number, ri: number, fi: number, patch: Partial) =>
+ setFields_(pi, ri, fs => fs.map((f, j) => (j === fi ? { ...f, ...patch } : f)));
+
+ useEffect(() => {
+ return () => {
+ if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!connected) {
+ setRecords([]);
+ setTxStatus(null);
+ setOnchainTxId(null);
+ setTxError(null);
+ setValidatorMessage(null);
+ }
+ }, [connected]);
+
+ const applyGrantAndDisconnect = async () => {
+ if (grantValidationError) {
+ toast.error(`Grant is invalid: ${grantValidationError}`);
+ return;
+ }
+ if (readAddressInterlockError) {
+ toast.error(readAddressInterlockError);
+ return;
+ }
+ const grant = buildRecordAccessGrant(programGrants);
+ console.log('[PrivateInputs] applying grant to recordAccessAtom:', grant);
+ setRecordAccess(grant);
+ toast.success(
+ 'Grants saved. Reconnect the wallet to apply (grants are bound at connect time).',
+ );
+ if (connected) {
+ try {
+ await disconnect();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ };
+
+ const clearGrantAndDisconnect = async () => {
+ setRecordAccess(undefined);
+ setReadAddress(undefined);
+ setViewKeyExposure(undefined);
+ toast.success('All grants cleared. Reconnect to apply (broad legacy behavior restored).');
+ if (connected) {
+ try {
+ await disconnect();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ };
+
+ const handleFetch = async () => {
+ if (!connected) {
+ openWalletModal(true);
+ return;
+ }
+ if (!form.programName.trim()) {
+ toast.error('Enter a program name first.');
+ return;
+ }
+ setIsFetching(true);
+ try {
+ const result = (await requestRecords(form.programName.trim(), true, 'unspent')) as
+ | RecordEnvelope[]
+ | undefined;
+ const arr = result ?? [];
+ setRecords(arr);
+ toast.success(`Fetched ${arr.length} record(s)`);
+ } catch (e) {
+ console.error(e);
+ toast.error(`requestRecords failed: ${(e as Error).message}`);
+ } finally {
+ setIsFetching(false);
+ }
+ };
+
+ const pollTransactionStatus = async (id: string) => {
+ function clear() {
+ if (pollingIntervalRef.current) {
+ clearInterval(pollingIntervalRef.current);
+ pollingIntervalRef.current = null;
+ }
+ }
+ try {
+ const status = await getTransactionStatus(id);
+ setTxStatus(status.status);
+ if (status.transactionId) setOnchainTxId(status.transactionId);
+ const lower = status.status.toLowerCase();
+ if (lower === TransactionStatus.ACCEPTED.toLowerCase()) {
+ setIsPolling(false);
+ clear();
+ toast.success(`Transaction ${status.status}`);
+ } else if (
+ lower === TransactionStatus.FAILED.toLowerCase() ||
+ lower === TransactionStatus.REJECTED.toLowerCase()
+ ) {
+ setIsPolling(false);
+ if (status.error) setTxError(status.error);
+ clear();
+ toast.error(`Transaction ${status.status}`);
+ }
+ } catch (err) {
+ console.error('polling error', err);
+ setTxError('Error polling transaction status');
+ setTxStatus(TransactionStatus.FAILED);
+ setIsPolling(false);
+ clear();
+ }
+ };
+
+ const handleExecute = async () => {
+ console.log('[PrivateInputs] handleExecute: connected=', connected, 'readAddress=', readAddress);
+ if (!connected) {
+ openWalletModal(true);
+ return;
+ }
+ if (parsedSlots.length === 0) {
+ toast.error('No input slots parsed — check the program name and function name.');
+ return;
+ }
+ setIsExecuting(true);
+ setOnchainTxId(null);
+ setTxStatus(null);
+ setTxError(null);
+ setValidatorMessage(null);
+ try {
+ const inputs = buildInputs(parsedSlots, slotStates, form.programName.trim());
+ console.log('[PrivateInputs] calling executeTransaction with inputs:', inputs);
+ const tx = await executeTransaction({
+ program: form.programName.trim(),
+ function: form.functionName.trim(),
+ inputs,
+ });
+ if (tx?.transactionId) {
+ toast.success('Transaction submitted');
+ setIsPolling(true);
+ pollingIntervalRef.current = setInterval(() => {
+ pollTransactionStatus(tx.transactionId);
+ }, 1000);
+ pollTransactionStatus(tx.transactionId);
+ } else {
+ toast.error('No transactionId returned');
+ }
+ } catch (e) {
+ console.error(e);
+ toast.error(`executeTransaction failed: ${(e as Error).message}`);
+ setTxError((e as Error).message);
+ } finally {
+ setIsExecuting(false);
+ }
+ };
+
+ // Negative test: pick any record slot, force both uid and filters, expect WalletInputRequestInvalidError.
+ const firstRecordSlotIndex = parsedSlots.findIndex(s => s.kind === 'record');
+ const negativeTestEnabled = firstRecordSlotIndex >= 0 && records.length > 0;
+ const handleTryInvalidCombo = async () => {
+ if (firstRecordSlotIndex < 0) {
+ toast.error('No record slot in this function.');
+ return;
+ }
+ const uid = records[0]?.uid as string | undefined;
+ if (!uid) {
+ toast.error('Fetch records first so we have a uid to test with.');
+ return;
+ }
+ setValidatorMessage(null);
+ // Build a real inputs array with one slot overridden to the invalid combo.
+ try {
+ const baseInputs = parsedSlots.map((slot, i): TransactionInput => {
+ if (i === firstRecordSlotIndex) {
+ const slotProgram =
+ (slot as Extract).program || form.programName.trim();
+ return { type: 'record', program: slotProgram, uid, filters: {} };
+ }
+ const state = slotStates[i];
+ if (state?.kind === 'primitive') return state.value || '0u64';
+ // Fallback for other record slots: use address (will be rejected if slot isn't address-typed,
+ // but the validator on slot 0 should fire first).
+ return { type: 'address' };
+ });
+ await executeTransaction({
+ program: form.programName.trim(),
+ function: form.functionName.trim(),
+ inputs: baseInputs,
+ });
+ setValidatorMessage('No error thrown — the validator did NOT fire (regression).');
+ toast.error('Validator did not fire — regression');
+ } catch (e) {
+ const msg = (e as Error).message;
+ setValidatorMessage(`Validator fired as expected: ${msg}`);
+ toast.success('Validator fired — uid + filters rejected');
+ }
+ };
+
+ const copy = (text: string) => {
+ navigator.clipboard.writeText(text);
+ toast.success('Copied');
+ };
+
+ // Hint about which legacy envelope keys should appear in returned records,
+ // walking every program's record grants in the configuration.
+ const grantedMetadata = useMemo(() => {
+ const names: string[] = [];
+ for (const pg of programGrants) {
+ for (const rg of pg.records ?? []) {
+ for (const f of rg.fields ?? []) {
+ if (f.name.startsWith('$')) {
+ names.push(METADATA_TOKEN_TO_LEGACY_KEY[f.name] ?? f.name.slice(1));
+ }
+ }
+ }
+ }
+ return Array.from(new Set(names));
+ }, [programGrants]);
+
+ // Per-slot state mutators
+ const updateSlot = (i: number, patch: Partial) => {
+ setSlotStates(prev => prev.map((s, j) => (j === i ? ({ ...s, ...patch } as SlotState) : s)));
+ };
+ const updateFilterRows = (i: number, fn: (rs: FilterRow[]) => FilterRow[]) => {
+ setSlotStates(prev =>
+ prev.map((s, j) =>
+ j === i && s.kind === 'record' ? { ...s, filterRows: fn(s.filterRows) } : s,
+ ),
+ );
+ };
+
+ const renderRecordRow = (rec: RecordEnvelope, i: number) => {
+ const uid = (rec.uid as string | undefined) ?? `(no-uid-${i})`;
+ const fields =
+ (rec.recordView as { fields?: Record } | undefined)?.fields ?? {};
+ const envelope = Object.entries(rec).filter(
+ ([k, v]) => !PRESERVED_ENVELOPE_KEYS.has(k) && v !== undefined,
+ );
+ return (
+
+ {state.mode === 'address' ? (
+ <>
+ Sends {`{type:"address"}`}. The wallet fills the slot with the active
+ address; the dapp never sees it.
+ >
+ ) : (
+ <>
+ Sends {`{type:"viewKey"}`}. The wallet derives the value from the
+ active view key (gated by viewKeyExposure).
+ >
+ )}
+
+ )}
+
+ );
+ };
+
+ const renderRecordSlot = (slot: Extract, i: number) => {
+ const state = slotStates[i];
+ if (!state || state.kind !== 'record') return null;
+ const slotProgram = slot.program || form.programName.trim();
+ // Only show records that match this slot's program + recordname in the dropdown.
+ const eligibleRecords = records.filter(
+ r =>
+ (r.programName as string | undefined) === slotProgram &&
+ (r.recordName as string | undefined) === slot.recordname,
+ );
+ return (
+
+ Rows AND-combine. Multiple rows on the same field combine into one{' '}
+ RecordFieldFilter. Builds{' '}
+ {`{type:"record", program, filters}`} for the wallet.
+
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ Exercises the privacy extension end-to-end: per-program record-access grants;{' '}
+ recordView/uid on{' '}
+ requestRecords; $-prefixed
+ envelope-metadata grants in FieldGrant.name;{' '}
+ type: "record" by uid, by{' '}
+ filters, or as plaintext; and the privacy-preserving{' '}
+ type: "address"/
+ type: "viewKey" slots.
+
+
+ Function inputs are auto-derived from the program source. Defaults satisfy{' '}
+ credits.aleo / transfer_private.
+
+
+
+
+
+
+ 1. Connect-time grant
+
+
+ {/* Address grant */}
+
+
+
+
+
+ {readAddress === false
+ ? 'Withhold the active address from the dapp. The wallet still injects it via { type: "address" } slots.'
+ : "Dapp reads the active address (today's default behavior)."}
+
+ {viewKeyExposure === 'PER_TX_PROMPT'
+ ? 'Dapp may request the active view key per transaction (user is prompted each time).'
+ : 'Deny view-key access (default). { type: "viewKey" } slots are refused.'}
+
+ Record access: one entry per program. Records narrows to specific record
+ types (omit for broad access). Fields narrows further to specific body fields and{' '}
+ $-prefixed envelope-metadata tokens.
+
+ Grant requests envelope metadata: {grantedMetadata.join(', ')}. These
+ should appear in each record's "envelope metadata present" pane in section 3.
+
+
+
+ )}
+
+ setShowGrantJson((e.target as HTMLDetailsElement).open)}
+ >
+
+ JSON preview (for copy-paste into your dapp's AleoWalletProvider)
+
+
+ Fetches{' '}
+ requestRecords({form.programName.trim() || 'program'}, true, "unspent") and
+ shows what came back. Use this to verify the grant's strip rules and to copy uids into
+ the "Pick from records" mode above.
+
+
+ {records.length > 0 ? (
+
+
Records ({records.length})
+
{records.map(renderRecordRow)}
+
+ ) : (
+
+ (no records fetched yet, or none available for this program)
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/examples/react-app/src/components/layout/Sidebar.tsx b/examples/react-app/src/components/layout/Sidebar.tsx
index 7721535..fd48c3a 100644
--- a/examples/react-app/src/components/layout/Sidebar.tsx
+++ b/examples/react-app/src/components/layout/Sidebar.tsx
@@ -8,6 +8,7 @@ import {
Database,
Key,
History,
+ Lock,
Menu,
X,
Book,
@@ -51,6 +52,7 @@ const navigationGroups: NavGroup[] = [
title: 'Data',
items: [
{ to: '/records', label: 'Records', icon: Database },
+ { to: '/private-inputs', label: 'Private Inputs', icon: Lock },
{ to: '/decrypt', label: 'Decrypt', icon: KeyRound },
{ to: '/view-keys', label: 'View Keys', icon: Key },
{ to: '/history', label: 'Tx History', icon: History },
diff --git a/examples/react-app/src/lib/codeExamples.ts b/examples/react-app/src/lib/codeExamples.ts
index 78a37ac..69ac22a 100644
--- a/examples/react-app/src/lib/codeExamples.ts
+++ b/examples/react-app/src/lib/codeExamples.ts
@@ -86,6 +86,48 @@ const { requestTransactionHistory } = useWallet();
// Get transaction history for a program
const history = await requestTransactionHistory('${PLACEHOLDERS.PROGRAM}');
console.log('Transactions:', history.transactions);`,
+
+ privateInputs: `import { useWallet } from '@provablehq/aleo-wallet-adaptor-react';
+import type { RecordEnvelope } from '@provablehq/aleo-types';
+
+const { requestRecords, executeTransaction } = useWallet();
+
+// 1. Connect with a narrowed grant — only \`microcredits\` body field plus the
+// \`$commitment\` envelope-metadata token (passed at ).
+//
+// recordAccess: {
+// level: 'byProgram',
+// programs: [
+// {
+// program: '${PLACEHOLDERS.PROGRAM}',
+// records: [
+// { recordname: 'credits', fields: [
+// { name: 'microcredits' },
+// { name: '$commitment' },
+// ]},
+// ],
+// },
+// ],
+// }
+
+// 2. Fetch records — the wallet returns RecordEnvelope[] with recordView and uid.
+const records = (await requestRecords('${PLACEHOLDERS.PROGRAM}', true, 'unspent')) as RecordEnvelope[];
+const chosen = records[0];
+
+// 3. Pin that exact record by uid in a type: "record" InputRequest. Sending the
+// transfer to self via { type: "address" } proves end-to-end privacy: the
+// dapp never reads the owner address.
+const tx = await executeTransaction({
+ program: '${PLACEHOLDERS.PROGRAM}',
+ function: '${PLACEHOLDERS.FUNCTION}',
+ inputs: [
+ { type: 'record', program: '${PLACEHOLDERS.PROGRAM}', uid: chosen.uid! },
+ { type: 'address' },
+ '100u64',
+ ],
+ fee: 200000,
+});
+console.log('Transaction Id:', tx?.transactionId);`,
} as const;
export type CodeExampleKey = keyof typeof codeExamples;
diff --git a/examples/react-app/src/lib/store/global.ts b/examples/react-app/src/lib/store/global.ts
index de2b861..546aed2 100644
--- a/examples/react-app/src/lib/store/global.ts
+++ b/examples/react-app/src/lib/store/global.ts
@@ -1,6 +1,10 @@
import { atomWithStorage } from 'jotai/utils';
import { Network } from '@provablehq/aleo-types';
-import { DecryptPermission } from '@provablehq/aleo-wallet-adaptor-core';
+import {
+ DecryptPermission,
+ RecordAccessGrant,
+ ViewKeyExposure,
+} from '@provablehq/aleo-wallet-adaptor-core';
/**
* Adapter default values
@@ -18,6 +22,15 @@ export const programsAtom = atomWithStorage('programs', [
'toka_token.aleo',
'tokb_token.aleo',
]);
+export const recordAccessAtom = atomWithStorage(
+ 'recordAccess',
+ undefined,
+);
+export const readAddressAtom = atomWithStorage('readAddress', undefined);
+export const viewKeyExposureAtom = atomWithStorage(
+ 'viewKeyExposure',
+ undefined,
+);
/**
* UI state
diff --git a/examples/react-app/src/pages/PrivateInputsPage.tsx b/examples/react-app/src/pages/PrivateInputsPage.tsx
new file mode 100644
index 0000000..f1d8d1b
--- /dev/null
+++ b/examples/react-app/src/pages/PrivateInputsPage.tsx
@@ -0,0 +1,9 @@
+import { PrivateInputs } from '@/components/functions/PrivateInputs';
+
+export function PrivateInputsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/examples/react-app/src/pages/index.ts b/examples/react-app/src/pages/index.ts
index 6180b40..3633201 100644
--- a/examples/react-app/src/pages/index.ts
+++ b/examples/react-app/src/pages/index.ts
@@ -6,3 +6,4 @@ export { DecryptPage } from './DecryptPage';
export { RecordsPage } from './RecordsPage';
export { ViewKeysPage } from './ViewKeysPage';
export { TransactionHistoryPage } from './TransactionHistoryPage';
+export { PrivateInputsPage } from './PrivateInputsPage';
diff --git a/examples/react-app/src/routes.tsx b/examples/react-app/src/routes.tsx
index 122b137..6407598 100644
--- a/examples/react-app/src/routes.tsx
+++ b/examples/react-app/src/routes.tsx
@@ -7,6 +7,7 @@ import {
SignMessagePage,
DecryptPage,
RecordsPage,
+ PrivateInputsPage,
ViewKeysPage,
TransactionHistoryPage,
} from '@/pages';
@@ -49,6 +50,10 @@ export const routes: RouteObject[] = [
path: 'records',
element: ,
},
+ {
+ path: 'private-inputs',
+ element: ,
+ },
{
path: 'view-keys',
element: ,
diff --git a/packages/aleo-wallet-adaptor/core/src/adapter.ts b/packages/aleo-wallet-adaptor/core/src/adapter.ts
index e3ab78b..fe9508e 100644
--- a/packages/aleo-wallet-adaptor/core/src/adapter.ts
+++ b/packages/aleo-wallet-adaptor/core/src/adapter.ts
@@ -351,7 +351,7 @@ export function scopePollingDetectionStrategy(detect: () => boolean): void {
detectAndDispose();
}
-function validateInputRequests(inputs: TransactionInput[]): void {
+export function validateInputRequests(inputs: TransactionInput[]): void {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
if (typeof input === 'string') continue;
diff --git a/packages/aleo-wallet-adaptor/core/src/index.ts b/packages/aleo-wallet-adaptor/core/src/index.ts
index 5ce93e7..e513603 100644
--- a/packages/aleo-wallet-adaptor/core/src/index.ts
+++ b/packages/aleo-wallet-adaptor/core/src/index.ts
@@ -1,4 +1,8 @@
-export { BaseAleoWalletAdapter, scopePollingDetectionStrategy } from './adapter';
+export {
+ BaseAleoWalletAdapter,
+ scopePollingDetectionStrategy,
+ validateInputRequests,
+} from './adapter';
export * from './account';
export * from './errors';
export * from './records';
diff --git a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
index 86bc9e5..86be218 100644
--- a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
+++ b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
@@ -295,6 +295,13 @@ export const AleoWalletProvider: FC = ({
isConnecting.current = true;
setConnecting(true);
try {
+ // eslint-disable-next-line no-console
+ console.log('[WalletProvider] autoConnect about to call adapter.connect with:', {
+ initialNetwork,
+ decryptPermission,
+ programs,
+ connectOptions,
+ });
const account = await adapter.connect(initialNetwork, decryptPermission, programs, connectOptions);
lastAuthorizedAccount.current = account.address ?? null;
} catch (error: unknown) {
@@ -307,7 +314,18 @@ export const AleoWalletProvider: FC = ({
isConnecting.current = false;
}
})();
- }, [isConnecting, connected, autoConnect, adapter, readyState, setName]);
+ }, [
+ isConnecting,
+ connected,
+ autoConnect,
+ adapter,
+ readyState,
+ setName,
+ initialNetwork,
+ decryptPermission,
+ programs,
+ connectOptions,
+ ]);
useEffect(() => {
if (adapter && connected && adapter.network !== initialNetwork) {
@@ -345,6 +363,13 @@ export const AleoWalletProvider: FC = ({
isConnecting.current = true;
setConnecting(true);
try {
+ // eslint-disable-next-line no-console
+ console.log('[WalletProvider] connect() about to call adapter.connect with:', {
+ initialNetwork,
+ decryptPermission,
+ programs,
+ connectOptions,
+ });
const account = await adapter.connect(initialNetwork, decryptPermission, programs, connectOptions);
lastAuthorizedAccount.current = account.address ?? null;
} catch (error: unknown) {
@@ -357,7 +382,19 @@ export const AleoWalletProvider: FC = ({
setConnecting(false);
isConnecting.current = false;
}
- }, [isConnecting, isDisconnecting, connected, adapter, readyState, handleError, setName]);
+ }, [
+ isConnecting,
+ isDisconnecting,
+ connected,
+ adapter,
+ readyState,
+ handleError,
+ setName,
+ initialNetwork,
+ decryptPermission,
+ programs,
+ connectOptions,
+ ]);
const executeTransaction = useCallback(
async (transaction: TransactionOptions) => {
diff --git a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
index 9974582..2550038 100644
--- a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
@@ -16,16 +16,17 @@ import {
import {
BaseAleoWalletAdapter,
filterRecordsByStatus,
+ scopePollingDetectionStrategy,
+ validateInputRequests,
WalletConnectionError,
+ WalletDecryptionError,
+ WalletDecryptionNotAllowedError,
WalletDisconnectionError,
WalletError,
WalletNotConnectedError,
- WalletSwitchNetworkError,
WalletSignMessageError,
+ WalletSwitchNetworkError,
WalletTransactionError,
- WalletDecryptionError,
- WalletDecryptionNotAllowedError,
- scopePollingDetectionStrategy,
} from '@provablehq/aleo-wallet-adaptor-core';
import { ShieldWallet, ShieldWindow } from './types';
@@ -181,7 +182,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
* @returns The signed message
*/
async signMessage(message: Uint8Array): Promise {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
@@ -201,7 +202,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
}
async decrypt(cipherText: string) {
- if (!this._shieldWallet || !this._publicKey) {
+ if (!this._shieldWallet || !this.account) {
throw new WalletNotConnectedError();
}
switch (this.decryptPermission) {
@@ -229,9 +230,10 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
* @returns The executed temporary transaction ID
*/
async executeTransaction(options: TransactionOptions): Promise<{ transactionId: string }> {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
+ validateInputRequests(options.inputs);
try {
const result = await this._shieldWallet?.executeTransaction({
@@ -263,7 +265,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
* @returns The transaction status
*/
async transactionStatus(transactionId: string): Promise {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
@@ -288,7 +290,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async switchNetwork(_network: Network): Promise {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
@@ -314,7 +316,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
includePlaintext: boolean,
statusFilter: RecordStatusFilter = 'all',
): Promise {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
@@ -334,7 +336,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
*/
async executeDeployment(deployment: AleoDeployment): Promise<{ transactionId: string }> {
try {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
try {
@@ -366,7 +368,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
*/
async transitionViewKeys(transactionId: string): Promise {
try {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
try {
@@ -393,7 +395,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
*/
async requestTransactionHistory(program: string): Promise {
try {
- if (!this._publicKey || !this.account) {
+ if (!this.account) {
throw new WalletNotConnectedError();
}
try {
From c403b5681211711659d78005d392cb843d689d4c Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Tue, 12 May 2026 18:49:48 -0400
Subject: [PATCH 08/18] Add quickstart for using private inputs
---
docs/dapp-privacy-quickstart.md | 157 +++
...28-dynamic-dispatch-execute-integration.md | 1158 +++++++++++++++++
...mic-dispatch-execute-integration-design.md | 187 +++
3 files changed, 1502 insertions(+)
create mode 100644 docs/dapp-privacy-quickstart.md
create mode 100644 docs/superpowers/plans/2026-04-28-dynamic-dispatch-execute-integration.md
create mode 100644 docs/superpowers/specs/2026-04-28-dynamic-dispatch-execute-integration-design.md
diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md
new file mode 100644
index 0000000..e7481a7
--- /dev/null
+++ b/docs/dapp-privacy-quickstart.md
@@ -0,0 +1,157 @@
+# 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`. |
+| `viewKeyExposure` | `"DENY" \| "PER_TX_PROMPT"` (default `"DENY"`) | Whether `type: "viewKey"` slots are allowed. |
+| `recordAccess` | `RecordAccessGrant` (default `undefined` = broad) | Per-program / per-record / per-field narrowing of record reads. |
+
+| `InputRequest` slot type | Shape | Valid in |
+|---|---|---|
+| `{ type: "address" }` | wallet injects active address | `address`, `group`, `scalar`, `field` |
+| `{ type: "viewKey" }` | wallet injects active view key | `scalar`, `field` |
+| `{ type: "record", program, uid }` | pin specific record by handle | `record`, `dynamic_record`, `external_record` |
+| `{ type: "record", program, filters }` | wallet auto-selects matching record | same |
+
+## 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: [
+ { type: 'record', program: 'credits.aleo', uid: chosen.uid! }, // pin by uid
+ { type: 'address' }, // wallet injects active address
+ '100u64', // literal
+ ],
+});
+```
+
+### Pick the right `type: "record"` shape
+
+- **`{ 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', filters },
+ { type: 'address' },
+ '100u64',
+ ],
+});
+```
+
+## 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.
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 ``), insert:
+
+```tsx
+{knownDispatchProgram && !dispatchAlertDismissed && (
+
+
+
+
+
+ {knownDispatchProgram.program} uses{' '}
+ call.dynamic to invoke a function on
+ whichever target program you put in imports.
+ The first import is the active target — its field representation auto-fills
+ the function's target input.
+ {knownDispatchProgram.description ? ` ${knownDispatchProgram.description}` : ''}
+
+
+
+
+
+)}
+```
+
+- [ ] **Step 4: Lint and build**
+
+Run from `examples/react-app`:
+
+```bash
+npm run lint
+npm run build
+```
+
+Expected: both succeed.
+
+- [ ] **Step 5: Smoke test**
+
+Run `npm run dev`. On the Execute page:
+- Type/select `credits.aleo` (or any non-registry program). No alert.
+- Select `token_router.aleo`. Alert appears.
+- Click the X. Alert disappears.
+- Reload the page (don't close the tab). Select `token_router.aleo` again. Alert stays dismissed (sessionStorage hit).
+- Open a new browser tab, navigate to the same URL. Alert appears again (fresh sessionStorage).
+- (If multiple registry entries exist in the future) Switching between two known dispatch programs shows each one's alert state independently.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add examples/react-app/src/components/functions/ExecuteTransaction.tsx
+git commit -m "feat(react-app): dismissible dispatch explainer alert with session persistence"
+```
+
+---
+
+## Task 7: Conditional `Imports` field with tooltip
+
+Add the comma-separated Imports text input. Visibility is governed by `showImportsField` from Task 4.
+
+**Files:**
+- Modify: `examples/react-app/src/components/functions/ExecuteTransaction.tsx`
+
+- [ ] **Step 1: Confirm Tooltip is available**
+
+`@radix-ui/react-tooltip` is already in `package.json`. The example app imports `` from a local `@/components/ui/tooltip` shadcn-style wrapper. Verify the wrapper exists:
+
+```bash
+ls examples/react-app/src/components/ui/tooltip.tsx
+```
+
+If it does not exist, fall back to a plain `` on the `?` icon (still acceptable but less polished). The remainder of this task assumes the wrapper exists; adapt the JSX accordingly if not.
+
+- [ ] **Step 2: Add Tooltip import and HelpCircle icon**
+
+Append to imports:
+
+```ts
+import { HelpCircle } from 'lucide-react';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+```
+
+(If the shadcn tooltip wrapper isn't present, skip this entire import and use the plain-`title`-attribute fallback in Step 4.)
+
+- [ ] **Step 3: Add Imports state**
+
+Alongside other `useState` declarations:
+
+```ts
+const [importsField, setImportsField] = useState('');
+```
+
+- [ ] **Step 4: Render the Imports section**
+
+Insert this JSX **between** the existing Inputs `space-y-2` block (which ends with the textarea / dynamic-input renderers) and the Fee `space-y-2` block (currently starting ``):
+
+```tsx
+{showImportsField && (
+
+
+
+ Imports
+
+
+
+
+
+
+
+
+ The wallet needs source for any program reached via call.dynamic.
+ List target programs here so the wallet knows which sources to fetch when
+ building the proof.
+
+ Comma-separated program IDs. The first import is the active target for this
+ dispatch call — its field representation is auto-filled into the function's
+ target input.
+
+
+)}
+```
+
+If using the plain-`title` fallback, replace the `...` block with:
+
+```tsx
+
+
+
+```
+
+- [ ] **Step 5: Lint and build**
+
+Run from `examples/react-app`:
+
+```bash
+npm run lint
+npm run build
+```
+
+Expected: both succeed.
+
+- [ ] **Step 6: Smoke test**
+
+Run `npm run dev`. On Execute:
+- Select `credits.aleo` and a non-dispatch function. No Imports field.
+- Select `token_router.aleo` → `route_transfer`. Imports field renders, empty for now (pre-fill comes in Task 8).
+- Tooltip / `?` icon shows the explainer text on hover.
+- (If a non-registry program with `call.dynamic` is on testnet, select it.) Imports field renders even though the program isn't in the registry — auto-detected.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add examples/react-app/src/components/functions/ExecuteTransaction.tsx
+git commit -m "feat(react-app): conditional Imports field with explainer tooltip"
+```
+
+---
+
+## Task 8: Imports pre-fill, force-on dynamic inputs, and target-input auto-population
+
+The pre-fill, the `useDynamicInputs` toggle-on, and the "first import wins" auto-population are coupled (they all fire on a known-dispatch program/function transition), so they ship in one task. Includes the per-(program, function) "dirty" tracking that prevents overwriting user-edited target inputs.
+
+**Files:**
+- Modify: `examples/react-app/src/components/functions/ExecuteTransaction.tsx`
+
+- [ ] **Step 1: Add `programIdToField` import**
+
+Append to the imports block:
+
+```ts
+import { programIdToField } from '@/lib/programIdField';
+```
+
+- [ ] **Step 2: Add a ref for the dirty target-input flag**
+
+Below other `useRef` declarations:
+
+```ts
+// Tracks whether the user has manually edited the target input for the current
+// (program, function). When dirty, auto-populate skips this input until the
+// program or function changes.
+const targetInputDirtyRef = useRef<{ key: string; dirty: boolean }>({
+ key: '',
+ dirty: false,
+});
+```
+
+Define a helper inside the component body (above the JSX `return`):
+
+```ts
+const dirtyKey = `${program}::${functionName}`;
+const isTargetInputDirty = () =>
+ targetInputDirtyRef.current.key === dirtyKey &&
+ targetInputDirtyRef.current.dirty;
+const markTargetInputDirty = () => {
+ targetInputDirtyRef.current = { key: dirtyKey, dirty: true };
+};
+const resetTargetInputDirty = () => {
+ targetInputDirtyRef.current = { key: dirtyKey, dirty: false };
+};
+```
+
+- [ ] **Step 3: Pre-fill Imports on transition into a known dispatch program**
+
+Add a new `useEffect` after the existing `useEffect`s that watch `program`:
+
+```ts
+useEffect(() => {
+ if (knownDispatchProgram) {
+ setImportsField(knownDispatchProgram.knownTargets.join(', '));
+ } else {
+ setImportsField('');
+ }
+ resetTargetInputDirty();
+ // Fires on program change only.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+}, [program]);
+```
+
+Note: deliberately omitting `knownDispatchProgram` from the dependency array — we want this to fire on `program` change, not on every render where the memoized value is recomputed. The disable comment makes the intent explicit.
+
+- [ ] **Step 4: Force `useDynamicInputs` on for known dispatch functions**
+
+Add another `useEffect`:
+
+```ts
+useEffect(() => {
+ if (knownDispatchFunction) {
+ setUseDynamicInputs(true);
+ resetTargetInputDirty();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+}, [program, functionName]);
+```
+
+This deliberately fires on (program, function) transitions, not on every render. `setUseDynamicInputs` is the existing atom setter from `useDynamicInputsAtom`.
+
+- [ ] **Step 5: Compute the resolved target field and apply it**
+
+Add a `useMemo` and `useEffect` pair below the previous effects:
+
+```ts
+const firstImport = useMemo(() => {
+ return importsField
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean)[0];
+}, [importsField]);
+
+const resolvedTargetField = useMemo(() => {
+ if (!firstImport) return undefined;
+ try {
+ return programIdToField(firstImport);
+ } catch {
+ return undefined;
+ }
+}, [firstImport]);
+
+useEffect(() => {
+ if (!knownDispatchFunction) return;
+ if (!useDynamicInputs) return;
+ if (!resolvedTargetField) return;
+ if (isTargetInputDirty()) return;
+
+ const idx = knownDispatchFunction.targetInputIndex;
+ setDynamicInputValues(prev => {
+ if (prev[idx] === resolvedTargetField) return prev;
+ const next = [...prev];
+ while (next.length <= idx) next.push('');
+ next[idx] = resolvedTargetField;
+ return next;
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+}, [resolvedTargetField, knownDispatchFunction, useDynamicInputs]);
+```
+
+The dependency array intentionally excludes `dirtyKey` and `setDynamicInputValues`; `setDynamicInputValues` is a stable setter and `dirtyKey` is read at call time via the helper closures.
+
+- [ ] **Step 6: Mark dirty when the user edits the target input**
+
+Find the existing dynamic input `` rendering (currently around lines 338–349 inside the `currentFunction.inputs.map`). Modify its `onChange` to mark dirty when the edited index is the dispatch target:
+
+```tsx
+ {
+ const newValues = [...dynamicInputValues];
+ newValues[index] = e.target.value;
+ setDynamicInputValues(newValues);
+ setInputs(newValues.join('\n'));
+ if (
+ knownDispatchFunction &&
+ knownDispatchFunction.targetInputIndex === index
+ ) {
+ markTargetInputDirty();
+ }
+ }}
+ className="transition-all duration-300"
+/>
+```
+
+- [ ] **Step 7: Lint and build**
+
+Run from `examples/react-app`:
+
+```bash
+npm run lint
+npm run build
+```
+
+Expected: both succeed.
+
+- [ ] **Step 8: Smoke test (multiple flows)**
+
+Run `npm run dev`. On Execute, repeat for each of `route_transfer`, `route_deposit`, `route_withdraw`:
+1. Select `token_router.aleo`. Imports pre-fills with `toka_token.aleo, tokb_token.aleo`. Function defaults; switch to the function under test.
+2. Confirm `useDynamicInputs` is on (per-input fields rendered).
+3. Confirm the function's first input field shows `521331175801343116537716field` (the precomputed `toka_token.aleo` field). If a different first import is in the field, confirm the input matches that.
+4. Edit the Imports field — put `tokb_token.aleo` first. Confirm the first input flips to `521331175801343133314932field`.
+5. Manually edit the first input to a custom value. Edit Imports again. Confirm the input is **not** overwritten (dirty flag holding).
+6. Switch function (e.g. from `route_transfer` to `route_deposit`). Confirm the dirty flag resets and the new function's first input is auto-populated again.
+7. Type an unknown program in Imports as the first entry. Confirm the input is left untouched (no auto-populate, no error toast — the failure is silent by design).
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add examples/react-app/src/components/functions/ExecuteTransaction.tsx
+git commit -m "feat(react-app): pre-fill imports + auto-populate dispatch target input"
+```
+
+---
+
+## Task 9: Plumb `imports` into the `executeTransaction` call
+
+Currently `handleExecuteTransaction` (around lines 188–240) calls `executeTransaction({ program, function, inputs, fee, privateFee })`. Add `imports` when the field is shown and non-empty.
+
+**Files:**
+- Modify: `examples/react-app/src/components/functions/ExecuteTransaction.tsx`
+
+- [ ] **Step 1: Compute the imports array and include it conditionally**
+
+Inside `handleExecuteTransaction`, after the existing `inputArray` is computed and before the `executeTransaction` call, add:
+
+```ts
+const importsArray = showImportsField
+ ? importsField
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean)
+ : [];
+
+const tx = await executeTransaction({
+ program: program.trim(),
+ function: functionName.trim(),
+ inputs: inputArray,
+ fee: Number(fee),
+ privateFee,
+ ...(importsArray.length > 0 ? { imports: importsArray } : {}),
+});
+```
+
+That is, replace the existing `executeTransaction({ ... })` call with the spread variant above; the existing `tx?.transactionId` handling that follows is unchanged.
+
+- [ ] **Step 2: Lint and build**
+
+Run from `examples/react-app`:
+
+```bash
+npm run lint
+npm run build
+```
+
+Expected: both succeed.
+
+- [ ] **Step 3: Smoke test**
+
+Run `npm run dev`. On Execute:
+1. Select `credits.aleo` and a non-dispatch function. Open dev tools → Network tab. Submit; confirm the request payload to the wallet does **not** contain an `imports` key.
+2. Select `token_router.aleo` → `route_transfer`. Confirm the Imports field is pre-filled. Submit (real wallet required). Confirm the wallet receives `imports: ['toka_token.aleo', 'tokb_token.aleo']`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add examples/react-app/src/components/functions/ExecuteTransaction.tsx
+git commit -m "feat(react-app): pass imports to executeTransaction when present"
+```
+
+---
+
+## Task 10: Update `codeExamples` and remove dispatch-only placeholders
+
+Update the `executeTransaction` snippet to mention `imports` and drop the dispatch-only example.
+
+**Files:**
+- Modify: `examples/react-app/src/lib/codeExamples.ts`
+
+- [ ] **Step 1: Add `imports` to the executeTransaction snippet**
+
+Replace the body of the `executeTransaction` template literal so the call shape includes a commented `imports` line. Locate the existing `executeTransaction:` entry in `codeExamples` and replace its template with:
+
+```ts
+ executeTransaction: `import { useWallet } from '@provablehq/aleo-wallet-adaptor-react';
+
+const { executeTransaction, transactionStatus } = useWallet();
+
+// Execute a transaction
+const result = await executeTransaction({
+ program: '${PLACEHOLDERS.PROGRAM}',
+ function: '${PLACEHOLDERS.FUNCTION}',
+ inputs: [${PLACEHOLDERS.INPUTS}],
+ fee: ${PLACEHOLDERS.FEE},
+ // imports: ['target_program.aleo'], // required when calling functions that use call.dynamic
+});
+
+// Poll for transaction status
+const status = await transactionStatus(result.transactionId);
+console.log('Status:', status.status);
+console.log('On-chain TX ID:', status.transactionId);`,
+```
+
+- [ ] **Step 2: Remove `dynamicDispatch` and dispatch-only placeholders**
+
+In `examples/react-app/src/lib/codeExamples.ts`:
+
+1. Delete the entire `dynamicDispatch:` entry from `codeExamples`.
+2. From the `PLACEHOLDERS` object, delete `TARGET_PROGRAM`, `FROM`, `TO`, `AMOUNT`, and `MINT_AMOUNT`. **Before deleting**, confirm none of these are referenced elsewhere:
+
+```bash
+grep -rn "PLACEHOLDERS\.\(TARGET_PROGRAM\|FROM\|TO\|AMOUNT\|MINT_AMOUNT\)" examples/react-app/src
+```
+
+Expected: only `examples/react-app/src/components/functions/DynamicDispatch.tsx` matches (which Task 11 will delete). If anything else matches, do not delete that placeholder.
+
+- [ ] **Step 3: Lint and build**
+
+Run from `examples/react-app`:
+
+```bash
+npm run lint
+npm run build
+```
+
+Expected: build will fail at `DynamicDispatch.tsx` because it still references the deleted placeholders. That's fine — it's deleted next task. If you need a clean build before moving on, swap Task 10 and Task 11 ordering.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add examples/react-app/src/lib/codeExamples.ts
+git commit -m "feat(react-app): document imports option in execute code example"
+```
+
+---
+
+## Task 11: Remove standalone Dynamic Dispatch tab
+
+Delete the standalone tab and all its scaffolding.
+
+**Files:**
+- Delete: `examples/react-app/src/components/functions/DynamicDispatch.tsx`
+- Delete: `examples/react-app/src/pages/DynamicDispatchPage.tsx`
+- Modify: `examples/react-app/src/pages/index.ts`
+- Modify: `examples/react-app/src/routes.tsx`
+- Modify: `examples/react-app/src/components/layout/Sidebar.tsx`
+
+- [ ] **Step 1: Delete the component and page files**
+
+```bash
+rm examples/react-app/src/components/functions/DynamicDispatch.tsx
+rm examples/react-app/src/pages/DynamicDispatchPage.tsx
+```
+
+- [ ] **Step 2: Remove the export from `pages/index.ts`**
+
+Delete this line from `examples/react-app/src/pages/index.ts`:
+
+```ts
+export { DynamicDispatchPage } from './DynamicDispatchPage';
+```
+
+- [ ] **Step 3: Remove the route**
+
+In `examples/react-app/src/routes.tsx`:
+
+1. Remove `DynamicDispatchPage` from the import from `@/pages` (the destructured list around lines 3–12).
+2. Delete the `dynamic-dispatch` route entry:
+
+```tsx
+{
+ path: 'dynamic-dispatch',
+ element: ,
+},
+```
+
+- [ ] **Step 4: Remove the sidebar entry**
+
+In `examples/react-app/src/components/layout/Sidebar.tsx`:
+
+1. Locate the "Transactions" `NavGroup` (around line 38). Delete the `Dynamic Dispatch` `NavItem`:
+
+```ts
+{ to: '/dynamic-dispatch', label: 'Dynamic Dispatch', icon: Workflow },
+```
+
+2. The `Workflow` icon is now unused in this file. Remove it from the lucide import block.
+
+- [ ] **Step 5: Verify nothing else references the deleted symbols**
+
+```bash
+grep -rn "DynamicDispatch\|dynamic-dispatch" examples/react-app/src
+grep -rn "programIdField" examples/react-app/src
+```
+
+The first command should return zero matches. The second should still match `examples/react-app/src/lib/programIdField.ts` and `examples/react-app/src/components/functions/ExecuteTransaction.tsx` (Task 8 uses it). If `programIdField` is referenced in any *other* file, investigate before continuing.
+
+- [ ] **Step 6: Lint and build**
+
+Run from `examples/react-app`:
+
+```bash
+npm run lint
+npm run build
+```
+
+Expected: both succeed cleanly. If they don't, the most likely culprit is a stale import; fix it before committing.
+
+- [ ] **Step 7: Smoke test the whole app**
+
+Run `npm run dev`:
+- Sidebar no longer shows "Dynamic Dispatch."
+- Visiting `http://localhost:5173/dynamic-dispatch` 404s (or redirects to wallet, depending on router behavior — either is fine).
+- Visiting `/execute` works exactly as in prior tasks (filter, alert, imports field, auto-populate, transaction submit).
+- Visit other unrelated tabs (Decrypt, Records, Sign Message, etc.) — confirm none broken by collateral edits.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add examples/react-app/src/pages/index.ts \
+ examples/react-app/src/routes.tsx \
+ examples/react-app/src/components/layout/Sidebar.tsx
+git add -u # picks up the deletions
+git commit -m "feat(react-app): remove standalone Dynamic Dispatch tab"
+```
+
+---
+
+## Task 12: End-to-end wallet test (per `testing-wallet-adapter-changes`)
+
+Final verification with a real wallet against testnet.
+
+**Files:** none (manual)
+
+- [ ] **Step 1: Re-run the full smoke test from a clean dev server**
+
+Stop and restart `npm run dev`. Hard-reload the browser. Run through every smoke-test step in Tasks 5, 6, 7, 8, 9, 11.
+
+- [ ] **Step 2: Sign in with a real wallet extension**
+
+Use a wallet that supports the `imports` option on `executeTransaction` (the Shield wallet, per the spec's existing testing skill). Connect on testnet.
+
+- [ ] **Step 3: Known dispatch happy path**
+
+Assuming the connected wallet has minted balances on `toka_token.aleo` and approved `token_router.aleo` as a spender (do this via the Records / Execute tabs against `mint_public` and `approve_public` if needed; the prep flow is documented in `.claude/skills/testing-wallet-adapter-changes/SKILL.md`):
+
+1. Select `token_router.aleo` → `route_transfer`.
+2. Imports pre-fills; first input auto-populates with `toka_token.aleo`'s field.
+3. Fill remaining inputs (from = your address; to = your address; amount = `1000`).
+4. Submit. Confirm the wallet pops up with the dispatch transaction. Approve.
+5. Wait for status to become `Accepted`. Open the explorer link.
+
+- [ ] **Step 4: Active-target swap**
+
+Edit the Imports field to put `tokb_token.aleo` first. Confirm the first input flips. Submit. Confirm `tokb`-side balances change.
+
+- [ ] **Step 5: Manual override is sticky**
+
+After auto-population, manually replace the first input with a custom value. Submit. Confirm the wallet receives that custom value (likely fails finalize, but the request shape is what we're verifying).
+
+- [ ] **Step 6: Auto-detected unknown dispatch**
+
+If a non-registry dispatch program is available on testnet, select it. Confirm Imports renders empty. Type a target program. Submit. Confirm the wallet receives the imports.
+
+- [ ] **Step 7: Generic execute remains intact**
+
+Pick `credits.aleo` → `transfer_public`. Confirm no Imports field, no alert, no filter applied. Submit a small public transfer to yourself. Confirm it lands.
+
+- [ ] **Step 8: Update PR description**
+
+Update PR #82's description to reflect: standalone DD tab removed, imports surfaced contextually on Execute, registry-driven filter + auto-populate, parser-driven detection for non-registry dispatch programs. No code commit required for this step.
+
+---
+
+## Self-review checklist
+
+Run through this once after the plan is implemented, before requesting review.
+
+- [ ] All spec sections covered: registry (Task 1), parser (Task 2), autocomplete prop (Task 3), filter (Task 5), alert + dismissal (Task 6), Imports field + tooltip (Task 7), pre-fill + dynamic-inputs force + auto-populate + dirty (Task 8), executeTransaction plumbing (Task 9), code example (Task 10), removals (Task 11), e2e test (Task 12).
+- [ ] `programIdField.ts` is **not** deleted. Task 11's grep step confirms this.
+- [ ] No `TODO`, `TBD`, "implement later" anywhere in the diff.
+- [ ] `KNOWN_DISPATCH_PROGRAM_IDS`, `getKnownDispatchProgram`, `getKnownDispatchFunction` names match between the registry module and the consumer (`ExecuteTransaction.tsx`).
+- [ ] `imports` is omitted (not sent as `[]`) when the field is empty/hidden. Verified in Task 9 Step 3.
+- [ ] sessionStorage key naming is consistent (`dispatch-alert-dismissed:`).
+- [ ] No `Co-Authored-By` or Claude attribution in any commit message.
diff --git a/docs/superpowers/specs/2026-04-28-dynamic-dispatch-execute-integration-design.md b/docs/superpowers/specs/2026-04-28-dynamic-dispatch-execute-integration-design.md
new file mode 100644
index 0000000..44c3b3c
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-28-dynamic-dispatch-execute-integration-design.md
@@ -0,0 +1,187 @@
+# Dynamic Dispatch — Execute Tab Integration
+
+**Date:** 2026-04-28
+**Status:** Design (approved, pending implementation plan)
+**Branch:** `feat/dynamic-dispatch-example`
+**Related PR:** #82 (review feedback)
+
+## Background
+
+PR #82 added a standalone `Dynamic Dispatch` page to the example app (`examples/react-app`) that demonstrates the new `imports` option on `TransactionOptions`. Maintainer feedback on the PR:
+
+1. Dynamic dispatch should not be its own tab — it should be integrated into the existing `Execute Transaction` page, since `imports` is just another optional field on `TransactionOptions` (like `privateFee`).
+2. The current standalone tab is too narrow for testing: it hardcodes two target programs (`toka_token.aleo`, `tokb_token.aleo`), so a developer building their own dispatch program cannot use it.
+
+This spec resolves both concerns by integrating `imports` support into the Execute Transaction page with contextual disclosure (the "Design 1 — Discreet contextual" approach from brainstorming), and removing the standalone tab.
+
+## Goals
+
+- Generic developers can specify `imports` on the Execute page when calling any function on any program (covers the long tail of dispatch programs).
+- Curated dispatch examples remain easy to demo: when a known dispatch program/function is selected, the Imports field pre-fills with that program's known target programs, the dispatch function's target-program input is auto-populated, and an explainer alert appears.
+- The Execute page parses program source for `call.dynamic` so it can reveal the Imports field automatically when a function uses dynamic dispatch — even if the program is not in our curated registry.
+- The standalone Dynamic Dispatch tab and its scaffolding are removed.
+
+## Non-goals
+
+- Auto-detecting `call.dynamic` *targets* from program source. Source-level detection answers "does this function dispatch?", not "what should be in `imports`?". Targets come from the curated registry (for known programs) or the user (for unknown programs).
+- Discovering dispatch programs by scanning every program in the network index. The "Dynamic dispatch only" filter is registry-driven; lazy parsing on selection covers unknowns.
+- Replacing the wallet adapter's `imports` semantics or its proof-building behavior. This spec is purely about the example app's UI surface for the existing API.
+
+## Architecture
+
+### Data sources
+
+- **Network program index (existing).** `usePrograms(network)` calls `${API_ENDPOINT}/programs/summary`, returning `{ id, name, description?, version? }[]`. Powers the program autocomplete.
+- **Program source (existing).** `useProgram(programId)` fetches `${API_ENDPOINT}/program/${programId}` on demand. Returns the Aleo program source text (currently parsed by `parseLeoProgramFunctions` in `src/lib/utils.ts`).
+- **Curated dispatch registry (new).** A small TypeScript constant in `src/lib/dispatchPrograms.ts` mapping known dispatch programs to their dispatch functions and known target programs. Sits alongside the network index — does not replace it.
+- **Program-ID → field literal lookup (existing).** `src/lib/programIdField.ts` is a hand-maintained map of `program_id → field literal`. The shipped `@provablehq/wasm` bundle does not expose `ProgramID::to_field`, so this lookup is computed out-of-band and pasted in. Adding a new known dispatch target requires adding both a `knownTargets` entry to the registry and a precomputed `field` literal here.
+
+### New module — `src/lib/dispatchPrograms.ts`
+
+```ts
+export interface DispatchFunctionEntry {
+ name: string;
+ // Index of the function input that consumes the target program as a `field` literal.
+ // `route_transfer` / `route_deposit` / `route_withdraw` all use index 0; other
+ // dispatch programs may use a different index.
+ 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/withdrawals to ' +
+ 'whichever target token program is supplied via `imports`.',
+ },
+];
+
+export function isKnownDispatchProgram(programId: string): boolean;
+export function getKnownDispatchProgram(programId: string): DispatchProgramEntry | undefined;
+export function getKnownDispatchFunction(
+ programId: string,
+ functionName: string,
+): DispatchFunctionEntry | undefined;
+```
+
+### Parser extension — `parseLeoProgramFunctions`
+
+Current behavior: `src/lib/utils.ts:24` walks program source line-by-line, captures `function NAME:` and `input … as TYPE.VIS` lines, and bails out of "in function" mode at the first non-`input` body statement.
+
+Change: keep scanning the body of each function and set `usesDynamicCall: true` on the `FunctionInfo` if any line in the body matches the `call.dynamic` instruction (case-insensitive, allowing leading whitespace; see Edge cases below for the precise pattern). Function-end detection switches from "first non-input line" to a brace/structural marker so we can scan the full body.
+
+`FunctionInfo` gains a single field:
+
+```ts
+export interface FunctionInfo {
+ name: string;
+ inputs: FunctionInput[];
+ usesDynamicCall: boolean; // new
+}
+```
+
+All existing call sites continue to work; `usesDynamicCall` defaults to `false` for any function whose body contains no `call.dynamic` instruction.
+
+### `ProgramAutocomplete` — filter prop
+
+`src/components/ProgramAutocomplete.tsx` gets an optional `programIdAllowlist?: string[]` prop. When provided, the rendered dropdown is intersected with that allowlist (still subject to the existing search-term filter). The autocomplete itself stays generic — it has no knowledge of dispatch.
+
+### `ExecuteTransaction` — UI changes
+
+Change set, in roughly the order they appear on the page:
+
+1. **"Dynamic dispatch only" filter checkbox.** New checkbox rendered next to (or directly above) the Program ID autocomplete on the Execute page. Default off. When on, `ProgramAutocomplete` receives `programIdAllowlist={KNOWN_DISPATCH_PROGRAMS.map(e => e.program)}` so the dropdown is restricted to known dispatch programs.
+
+2. **Dispatch explainer alert (conditional, dismissible).** When the currently-selected program is in `KNOWN_DISPATCH_PROGRAMS`, render an `` below the Program ID field with a short paragraph explaining that the program uses `call.dynamic`, what `imports` does, and that the first import is the active target for the call. The alert is dismissible. Dismiss state is keyed by program id and persisted in `sessionStorage` under a single key (e.g. `dispatch-alert-dismissed:`), so dismissing for one program does not suppress it for another, and dismissals survive page reloads but reset when the browser tab closes.
+
+3. **Force `useDynamicInputs` on for known dispatch functions.** When the selected `(program, function)` pair is in the registry, set `useDynamicInputs = true` automatically. This guarantees the per-input fields are rendered so target-input auto-population has somewhere to write. The `useDynamicInputsAtom` toggle remains user-editable; the auto-set fires on the transition into a known dispatch function.
+
+4. **Conditional Imports section.** Rendered below the Inputs section. Visible iff:
+ - The selected `(program, function)` is in the registry (known dispatch), **or**
+ - The parser reports `usesDynamicCall === true` for the selected function (auto-detected).
+
+ Layout:
+ - Label `Imports` with a `?` tooltip icon (Radix ``). Tooltip body: short paragraph explaining that the wallet needs source for any program reached via `call.dynamic`, and that the list here tells the wallet which sources to fetch when building the proof.
+ - Single comma-separated text `` field.
+ - Help text below the field: *"The first import is the active target for this dispatch call — its field representation is auto-filled into the function's target input."*
+
+5. **Pre-fill Imports for known dispatch.** When the user transitions *into* a known dispatch program (i.e., selects a program that is in the registry, having previously had a different program or no program selected), the Imports field's initial value is set to `knownTargets.join(', ')`. The pre-fill fires on **program** change, not on function change within the same program — so switching between `route_transfer` and `route_deposit` on `token_router.aleo` does not clobber a user's edited Imports. Outside of this transition the Imports field is fully user-controlled.
+
+6. **Auto-populate target input ("first import wins").** When (a) the Imports field changes, (b) the selected function is in the registry, (c) `useDynamicInputs` is on, and (d) the per-(program, function) "dirty" flag is unset, evaluate the first comma-separated entry in Imports. If that entry exists in `PROGRAM_ID_FIELDS` (the `programIdField.ts` lookup), write `programIdToField(entry)` into the dynamic input at `targetInputIndex`. If the first entry is unknown (not in `PROGRAM_ID_FIELDS`), do nothing — leave the input as the user set it. The same rule fires once on transition into a known dispatch function (covering the initial pre-fill case). If the user manually edits the target input after auto-population, set the dirty flag for this (program, function) pair so subsequent Imports changes do not overwrite their value. The dirty flag is reset when the user changes program or function.
+
+7. **`executeTransaction` call site.** When `imports` is non-empty, parse the field as `value.split(',').map(s => s.trim()).filter(Boolean)` and pass `imports: parsed` in the `executeTransaction` options. When the field is hidden or empty, omit the `imports` key entirely (do not send `imports: []`).
+
+8. **Code example panel update.** `codeExamples.executeTransaction` gains a commented `imports` option line in the snippet. The dispatch-only `codeExamples.dynamicDispatch` and any placeholders only it consumed are removed.
+
+### Removals
+
+- `src/components/functions/DynamicDispatch.tsx`
+- `src/pages/DynamicDispatchPage.tsx`
+- The `DynamicDispatchPage` export in `src/pages/index.ts`
+- The dynamic dispatch route entry in `src/routes.tsx`
+- The dynamic dispatch sidebar entry in `src/components/layout/Sidebar.tsx`
+- `codeExamples.dynamicDispatch` (and any placeholders only it consumed) in `src/lib/codeExamples.ts`
+
+`src/lib/programIdField.ts` is **kept** — it is still required for auto-populating dispatch functions' target inputs.
+
+## Data flow
+
+User selects `token_router.aleo` (known dispatch program):
+1. Autocomplete commits the value; `useProgram('token_router.aleo')` fetches source.
+2. Source parses into functions; `usesDynamicCall` is computed for each.
+3. `getKnownDispatchProgram('token_router.aleo')` returns the registry entry → explainer alert renders (unless previously dismissed for this program in `sessionStorage`).
+4. Default function (`route_transfer`, the first parsed) loads. `getKnownDispatchFunction('token_router.aleo', 'route_transfer')` returns its entry → `useDynamicInputs` is set true; the Imports field renders with initial value `'toka_token.aleo, tokb_token.aleo'`.
+5. First-import-wins rule fires once on mount: `programIdToField('toka_token.aleo')` is written into dynamic input index 0.
+6. User fills remaining inputs (recipients, amount, fee), clicks Execute. The submission carries `imports: ['toka_token.aleo', 'tokb_token.aleo']`.
+
+User selects an unknown program whose function happens to use `call.dynamic`:
+1. Source parses; the parser reports `usesDynamicCall === true` for the selected function.
+2. Imports field renders, empty, with placeholder.
+3. No explainer alert (program is not in the registry); no auto-populate (no registry entry to consult).
+4. User types target program ID(s) into Imports; submission carries them.
+
+## Error handling
+
+- **Network fetch failures (`useProgram` error).** Existing behavior preserved: `programCode` is cleared, dispatch UI is hidden. The Imports field never renders without successfully parsed source.
+- **Unknown program ID in Imports field.** Submitted as-is; the wallet will fail to fetch source if the program does not exist on the network. We do not validate.
+- **First-import-wins points to a program not in `PROGRAM_ID_FIELDS`.** Auto-populate is skipped; the user is responsible for filling the target input. Help text and tooltip already explain the convention; we do not surface a warning.
+- **Manually-edited target input.** Dirty flag prevents overwrites on subsequent Imports changes. Flag clears on program/function change.
+- **`useDynamicInputs` toggled off mid-flow.** Auto-populate has nothing to write to (no per-input fields rendered). The Imports field still controls what is sent. The pre-existing manual textarea path is unchanged.
+
+## Edge cases
+
+- **`call.dynamic` inside a comment.** Aleo bytecode/instruction format from `${API_ENDPOINT}/program/${programId}` does not include source-level comments, so this is not a real concern. We do not add comment-stripping.
+- **Multiple `call.dynamic` instructions in one function.** The flag is boolean — we do not enumerate them. Imports field stays single-list.
+- **Function selected via custom-name input (function not in parsed list).** The registry lookup falls back to `getKnownDispatchFunction(program, customName)`; if the user typed a known function name on a known program, treat it as known. Otherwise, no auto-population (nothing to write to without a parsed signature anyway).
+- **Switching programs while alert is dismissed.** Per-program dismiss key means switching to a different known dispatch program shows its alert. Switching back shows the previously-dismissed state (still dismissed in `sessionStorage`).
+- **`programIdToField` throws.** The current implementation throws on missing/placeholder entries. The caller catches and treats as "unknown — skip auto-populate." We do not surface the throw to the user.
+
+## Testing
+
+Per the existing `testing-wallet-adapter-changes` skill (`.claude/skills/testing-wallet-adapter-changes/SKILL.md`), the change is verified end-to-end against testnet with a real wallet extension installed:
+
+1. **Known dispatch happy path.** Select `token_router.aleo`; confirm explainer alert appears; select `route_transfer`; confirm Imports pre-fills with `toka_token.aleo, tokb_token.aleo`; confirm the first dynamic input field is populated with `toka_token.aleo`'s field literal; fill remaining inputs and execute. Verify the wallet receives `imports: ['toka_token.aleo', 'tokb_token.aleo']` and the transaction lands. (Assumes the user has minted/approved against the router beforehand — that prep flow is documented in the testing skill, no longer in app UI.)
+2. **Active-target swap.** With `route_transfer` selected, edit the Imports field to put `tokb_token.aleo` first. Confirm the first dynamic input updates to `tokb`'s field literal. Execute; verify it lands.
+3. **Manual override is sticky.** Manually edit the first dynamic input to a custom value, then change the Imports field. Confirm the input is *not* overwritten.
+4. **Auto-detected unknown dispatch.** Deploy or reference a non-registry program whose function contains `call.dynamic`. Confirm: no explainer alert; Imports field auto-renders empty; no auto-populate. Manually fill imports + the target input; submit; verify the wallet receives the imports.
+5. **Dispatch-only filter.** Toggle "Dynamic dispatch only" on; confirm autocomplete is restricted to `KNOWN_DISPATCH_PROGRAMS`. Toggle off; confirm full network list returns.
+6. **Alert dismissal persists across reloads, resets across tab close.** Dismiss the alert, reload — alert stays dismissed. Close tab and reopen — alert re-appears.
+7. **`imports` omitted when empty/hidden.** Select a non-dispatch function; submit a normal transaction; confirm the request payload does not include an `imports` key.
+
+## Open considerations (not blocking)
+
+- Whether to add a small "compute me" CLI/dev tool that emits `field` literals for new dispatch targets, so future contributors don't have to drop into a snarkVM REPL. Out of scope for this change.
+- Whether the dispatch-only filter should also surface when *parsed* programs (not in the registry) are dispatch-capable. Doing this requires scanning all programs returned from the index, which is too expensive for the autocomplete. Punt.
From e2d22e8bf42bb8a93b33c8a55716d00b61536163 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Wed, 27 May 2026 22:29:32 -0500
Subject: [PATCH 09/18] Remove view-key-as-input feature
Drops the type: "viewKey" InputRequest variant and the
viewKeyExposure ConnectOptions field across the type packages,
wallet adapter, React provider, example app, docs, and changeset.
The separate transitionViewKeys feature is unrelated and unchanged.
---
.changeset/wallet-specified-inputs.md | 6 +-
docs/adapter-privacy-extension.md | 17 ++----
docs/dapp-privacy-quickstart.md | 5 +-
examples/react-app/src/App.tsx | 3 -
.../components/functions/PrivateInputs.tsx | 57 ++-----------------
examples/react-app/src/lib/store/global.ts | 10 +---
packages/aleo-types/src/transaction.ts | 5 --
.../aleo-wallet-adaptor/core/src/errors.ts | 4 +-
.../aleo-wallet-adaptor/core/src/types.ts | 1 -
.../react/src/WalletProvider.tsx | 12 +---
packages/aleo-wallet-standard/src/adapter.ts | 2 +-
packages/aleo-wallet-standard/src/features.ts | 2 +-
packages/aleo-wallet-standard/src/wallet.ts | 13 +----
13 files changed, 24 insertions(+), 113 deletions(-)
diff --git a/.changeset/wallet-specified-inputs.md b/.changeset/wallet-specified-inputs.md
index 703e18f..cf3e399 100644
--- a/.changeset/wallet-specified-inputs.md
+++ b/.changeset/wallet-specified-inputs.md
@@ -12,12 +12,12 @@
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, derive a value from the user's view key, or auto-select an owned record matching dapp-supplied filters. Passing literal `string[]` continues to work — `string` is a subtype of `TransactionInput`.
+`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`, `viewKeyExposure`, 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.
+`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`, `viewKeyExposure`, and `readAddress` and forwards them on connect. Existing usages without these props are unaffected.
+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
index 0275d9b..7d04ed5 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -2,7 +2,7 @@
## 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 derive a value from the user's view key. The wallet fulfills the request before passing the transaction to the SDK.
+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 or to auto-select an owned record matching dapp-supplied criteria. The wallet fulfills the request before passing the transaction to the SDK.
## Wire-level types
@@ -11,8 +11,7 @@ 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; filters?: RecordFilters; uid?: string } // Specification to use a record from a specific program. 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 matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
- | { type: "viewKey"; label?: string }; // Specification to fill the input field with the view key behind the active address. Allowed in a input position with an aleo type of: `scalar or field`.
+ | { type: "record"; program: string; filters?: RecordFilters; uid?: string }; // Specification to use a record from a specific program. 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 matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
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.
@@ -33,8 +32,7 @@ The remaining (legacy) fields — including `recordPlaintext`, `commitment`, `ta
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. Input a view key if where there's a field or scalar input.
-3. 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.
+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.
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.
@@ -48,7 +46,7 @@ Adapters are ONLY allowed to successfully execute this if the user has authorize
### Proposed
-Add three new fields to `ConnectHistory`, all additive. The existing `decryptPermission` and `programs?: string[]` are preserved exactly, and the `connect()` signature does not change.
+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 {
@@ -57,7 +55,6 @@ interface ConnectHistory {
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
- viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // new — default DENY
}
type RecordAccessGrant =
@@ -95,7 +92,6 @@ 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."
-- **`viewKeyExposure` defaults to `DENY`**: matches today's de-facto behavior, since no view-key-derived inputs were possible.
- **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.
@@ -164,14 +160,12 @@ The default (`undefined` or `true`) preserves today's behavior verbatim at every
flowchart TD
A["dapp: executeTransaction inputs: Input[]"] --> B["validation.ts schema accepts string | InputRequest"]
B --> C{"AdapterService permission gate"}
- C -- "violates recordAccess or viewKeyExposure" --> X["error to dapp"]
+ 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: viewKey" --> R2["derive value via SDK"]
R -- "type: user" --> R3["render typed form from program signature"]
R1 --> F["confirm screen shows every fulfilled value"]
- R2 --> F
R3 --> F
F -- user confirms --> G["initializeGenericTransaction (fulfilled string[], lockedRecords)"]
G ===> H["worker.ts → SDK UNCHANGED"]
@@ -201,4 +195,3 @@ The worker boundary still receives `string[]`. All fulfillment is wallet-side; t
| `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: "viewKey"` | `viewKeyExposure: "DENY"` | permission error at gate |
diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md
index e7481a7..ebf327c 100644
--- a/docs/dapp-privacy-quickstart.md
+++ b/docs/dapp-privacy-quickstart.md
@@ -4,18 +4,16 @@ How to use the wallet-adapter's new privacy features from a dapp. For the full s
## What's new
-Three connect-time grants and three transaction-input request types:
+Two connect-time grants and two transaction-input request types:
| Grant | Type | Effect |
|---|---|---|
| `readAddress` | `boolean` (default `true`) | When `false`, the dapp transacts without learning the active address. Requires `decryptPermission: NoDecrypt`. |
-| `viewKeyExposure` | `"DENY" \| "PER_TX_PROMPT"` (default `"DENY"`) | Whether `type: "viewKey"` slots are allowed. |
| `recordAccess` | `RecordAccessGrant` (default `undefined` = broad) | Per-program / per-record / per-field narrowing of record reads. |
| `InputRequest` slot type | Shape | Valid in |
|---|---|---|
| `{ type: "address" }` | wallet injects active address | `address`, `group`, `scalar`, `field` |
-| `{ type: "viewKey" }` | wallet injects active view key | `scalar`, `field` |
| `{ type: "record", program, uid }` | pin specific record by handle | `record`, `dynamic_record`, `external_record` |
| `{ type: "record", program, filters }` | wallet auto-selects matching record | same |
@@ -33,7 +31,6 @@ import { DecryptPermission } from '@provablehq/aleo-wallet-adaptor-core';
decryptPermission={DecryptPermission.NoDecrypt} // required when readAddress: false
programs={['credits.aleo']} // existing per-program allowlist
readAddress={false} // withhold address
- viewKeyExposure="PER_TX_PROMPT" // allow view-key slots
recordAccess={{
level: 'byProgram',
programs: [
diff --git a/examples/react-app/src/App.tsx b/examples/react-app/src/App.tsx
index 94c90d2..da6d818 100644
--- a/examples/react-app/src/App.tsx
+++ b/examples/react-app/src/App.tsx
@@ -16,7 +16,6 @@ import {
programsAtom,
readAddressAtom,
recordAccessAtom,
- viewKeyExposureAtom,
} from './lib/store/global';
import { routes } from './routes';
// Import wallet adapter CSS after our own styles
@@ -42,7 +41,6 @@ export function App() {
const programs = useAtomValue(programsAtom);
const recordAccess = useAtomValue(recordAccessAtom);
const readAddress = useAtomValue(readAddressAtom);
- const viewKeyExposure = useAtomValue(viewKeyExposureAtom);
return (
@@ -55,7 +53,6 @@ export function App() {
programs={programs}
recordAccess={recordAccess}
readAddress={readAddress}
- viewKeyExposure={viewKeyExposure}
>
diff --git a/examples/react-app/src/components/functions/PrivateInputs.tsx b/examples/react-app/src/components/functions/PrivateInputs.tsx
index 8c1a4d0..6cf537f 100644
--- a/examples/react-app/src/components/functions/PrivateInputs.tsx
+++ b/examples/react-app/src/components/functions/PrivateInputs.tsx
@@ -34,7 +34,6 @@ import {
ProgramGrant,
RecordAccessGrant,
RecordGrant,
- ViewKeyExposure,
} from '@provablehq/aleo-wallet-adaptor-core';
import { CodePanel } from '../CodePanel';
import { codeExamples, PLACEHOLDERS } from '@/lib/codeExamples';
@@ -42,7 +41,6 @@ import {
decryptPermissionAtom,
readAddressAtom,
recordAccessAtom,
- viewKeyExposureAtom,
} from '@/lib/store/global';
import { DecryptPermission } from '@provablehq/aleo-wallet-adaptor-core';
import { useProgram } from '@/lib/hooks/useProgram';
@@ -63,7 +61,7 @@ type ParsedSlot =
};
type RecordSlotMode = 'plaintext' | 'pick' | 'filter';
-type PrimitiveSlotMode = 'literal' | 'address' | 'viewKey';
+type PrimitiveSlotMode = 'literal' | 'address';
type SlotState =
| { kind: 'primitive'; mode: PrimitiveSlotMode; value: string }
| {
@@ -78,14 +76,12 @@ const RECORD_SUFFIXES = ['record', 'external_record', 'dynamic_record'] as const
const DEFAULT_CREDITS_FILTER: FilterRow[] = [{ field: 'microcredits', op: 'gte', value: '101u64' }];
// Per the spec (docs/adapter-privacy-extension.md): type:"address" InputRequest is allowed in
-// `address` | `group` | `scalar` | `field` slots; type:"viewKey" is allowed in `scalar` | `field`.
+// `address` | `group` | `scalar` | `field` slots.
const ADDRESS_REQUEST_ALLOWED = new Set(['address', 'group', 'scalar', 'field']);
-const VIEWKEY_REQUEST_ALLOWED = new Set(['scalar', 'field']);
function primitiveSlotModes(baseType: string): PrimitiveSlotMode[] {
const modes: PrimitiveSlotMode[] = ['literal'];
if (ADDRESS_REQUEST_ALLOWED.has(baseType)) modes.push('address');
- if (VIEWKEY_REQUEST_ALLOWED.has(baseType)) modes.push('viewKey');
return modes;
}
@@ -181,7 +177,6 @@ function buildInputs(
if (!state) throw new Error(`slot ${i} (${slot.name}) has no state`);
if (state.kind === 'primitive') {
if (state.mode === 'address') return { type: 'address' };
- if (state.mode === 'viewKey') return { type: 'viewKey' };
if (!state.value.trim()) {
throw new Error(`slot ${i} (${slot.name}: ${slot.raw}) is empty`);
}
@@ -276,7 +271,6 @@ export function PrivateInputs() {
const { setVisible: openWalletModal } = useWalletModal();
const [, setRecordAccess] = useAtom(recordAccessAtom);
const [readAddress, setReadAddress] = useAtom(readAddressAtom);
- const [viewKeyExposure, setViewKeyExposure] = useAtom(viewKeyExposureAtom);
const [decryptPermission, setDecryptPermission] = useAtom(decryptPermissionAtom);
const [form, setForm] = useState(DEFAULTS);
@@ -413,7 +407,6 @@ export function PrivateInputs() {
const clearGrantAndDisconnect = async () => {
setRecordAccess(undefined);
setReadAddress(undefined);
- setViewKeyExposure(undefined);
toast.success('All grants cleared. Reconnect to apply (broad legacy behavior restored).');
if (connected) {
try {
@@ -672,11 +665,7 @@ export function PrivateInputs() {
variant={state.mode === m ? 'default' : 'outline'}
onClick={() => updateSlot(i, { mode: m })}
>
- {m === 'literal'
- ? 'Literal'
- : m === 'address'
- ? 'Wallet active address'
- : 'Wallet view key'}
+ {m === 'literal' ? 'Literal' : 'Wallet active address'}
))}
@@ -689,17 +678,8 @@ export function PrivateInputs() {
/>
) : (
- {state.mode === 'address' ? (
- <>
- Sends {`{type:"address"}`}. The wallet fills the slot with the active
- address; the dapp never sees it.
- >
- ) : (
- <>
- Sends {`{type:"viewKey"}`}. The wallet derives the value from the
- active view key (gated by viewKeyExposure).
- >
- )}
+ Sends {`{type:"address"}`}. The wallet fills the slot with the active
+ address; the dapp never sees it.
)}
@@ -876,8 +856,7 @@ export function PrivateInputs() {
envelope-metadata grants in FieldGrant.name;{' '}
type: "record" by uid, by{' '}
filters, or as plaintext; and the privacy-preserving{' '}
- type: "address"/
- type: "viewKey" slots.
+ type: "address" slots.
Function inputs are auto-derived from the program source. Defaults satisfy{' '}
@@ -933,29 +912,6 @@ export function PrivateInputs() {
)}
- {/* View-key grant */}
-
-
-
- View-key exposure
-
- {viewKeyExposure === 'PER_TX_PROMPT'
- ? 'Dapp may request the active view key per transaction (user is prompted each time).'
- : 'Deny view-key access (default). { type: "viewKey" } slots are refused.'}
-
Record access: one entry per program. Records narrows to specific record
@@ -1130,7 +1086,6 @@ export function PrivateInputs() {
{
recordAccess: { level: 'byProgram', programs: programGrants },
readAddress,
- viewKeyExposure,
},
null,
2,
diff --git a/examples/react-app/src/lib/store/global.ts b/examples/react-app/src/lib/store/global.ts
index 546aed2..00cc21d 100644
--- a/examples/react-app/src/lib/store/global.ts
+++ b/examples/react-app/src/lib/store/global.ts
@@ -1,10 +1,6 @@
import { atomWithStorage } from 'jotai/utils';
import { Network } from '@provablehq/aleo-types';
-import {
- DecryptPermission,
- RecordAccessGrant,
- ViewKeyExposure,
-} from '@provablehq/aleo-wallet-adaptor-core';
+import { DecryptPermission, RecordAccessGrant } from '@provablehq/aleo-wallet-adaptor-core';
/**
* Adapter default values
@@ -27,10 +23,6 @@ export const recordAccessAtom = atomWithStorage(
undefined,
);
export const readAddressAtom = atomWithStorage('readAddress', undefined);
-export const viewKeyExposureAtom = atomWithStorage(
- 'viewKeyExposure',
- undefined,
-);
/**
* UI state
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index 3d32945..f737dce 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -78,11 +78,6 @@ export type InputRequest =
program: string;
filters?: RecordFilters;
uid?: string;
- }
- | {
- /** Fill the input slot with the view key behind the active address. Allowed in `scalar` or `field` positions. */
- type: 'viewKey';
- label?: string;
};
/**
diff --git a/packages/aleo-wallet-adaptor/core/src/errors.ts b/packages/aleo-wallet-adaptor/core/src/errors.ts
index 8df6d16..095ffe3 100644
--- a/packages/aleo-wallet-adaptor/core/src/errors.ts
+++ b/packages/aleo-wallet-adaptor/core/src/errors.ts
@@ -152,7 +152,7 @@ export class WalletInputRequestNotSupportedError extends WalletError {
/**
* Thrown by a wallet adapter that does not yet honor the new `ConnectOptions`
- * fields (`recordAccess`, `viewKeyExposure`, `readAddress: false`).
+ * fields (`recordAccess`, `readAddress: false`).
*/
export class WalletConnectOptionsNotSupportedError extends WalletError {
name = 'WalletConnectOptionsNotSupportedError';
@@ -160,7 +160,7 @@ export class WalletConnectOptionsNotSupportedError extends WalletError {
constructor(walletName: string) {
super(
`Wallet "${walletName}" does not yet support ConnectOptions ` +
- '(recordAccess, viewKeyExposure, readAddress). ' +
+ '(recordAccess, readAddress). ' +
'Connect without these options, or switch to a wallet that supports them.',
);
}
diff --git a/packages/aleo-wallet-adaptor/core/src/types.ts b/packages/aleo-wallet-adaptor/core/src/types.ts
index dfd5536..621b389 100644
--- a/packages/aleo-wallet-adaptor/core/src/types.ts
+++ b/packages/aleo-wallet-adaptor/core/src/types.ts
@@ -7,7 +7,6 @@ export type {
ProgramGrant,
RecordAccessGrant,
RecordGrant,
- ViewKeyExposure,
} from '@provablehq/aleo-wallet-standard';
export { hasUnsupportedConnectOptions } from '@provablehq/aleo-wallet-standard';
export type {
diff --git a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
index 86be218..3a09bec 100644
--- a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
+++ b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
@@ -8,7 +8,6 @@ import {
ConnectOptions,
RecordAccessGrant,
RecordStatusFilter,
- ViewKeyExposure,
} from '@provablehq/aleo-wallet-standard';
import { Network, TransactionOptions } from '@provablehq/aleo-types';
import { Wallet, WalletContext } from './context';
@@ -37,10 +36,6 @@ export interface WalletProviderProps {
* wallet's connect call. Only honored by wallets that support it (e.g. shield).
*/
recordAccess?: RecordAccessGrant;
- /**
- * View-key exposure preference. Defaults to `DENY` when omitted.
- */
- viewKeyExposure?: ViewKeyExposure;
/**
* When `false`, the dapp transacts without learning the user's address.
* Defaults to `true`. Only valid with `decryptPermission: NoDecrypt`.
@@ -72,15 +67,14 @@ export const AleoWalletProvider: FC = ({
decryptPermission = DecryptPermission.NoDecrypt,
programs,
recordAccess,
- viewKeyExposure,
readAddress,
}) => {
const connectOptions = useMemo(() => {
- if (recordAccess === undefined && viewKeyExposure === undefined && readAddress === undefined) {
+ if (recordAccess === undefined && readAddress === undefined) {
return undefined;
}
- return { recordAccess, viewKeyExposure, readAddress };
- }, [recordAccess, viewKeyExposure, readAddress]);
+ return { recordAccess, readAddress };
+ }, [recordAccess, readAddress]);
const [name, setName] = useLocalStorage(localStorageKey, null);
const [{ wallet, adapter, publicKey, connected, network }, setState] = useState(initialState);
const readyState = adapter?.readyState || WalletReadyState.UNSUPPORTED;
diff --git a/packages/aleo-wallet-standard/src/adapter.ts b/packages/aleo-wallet-standard/src/adapter.ts
index a3e2e8a..dfec58a 100644
--- a/packages/aleo-wallet-standard/src/adapter.ts
+++ b/packages/aleo-wallet-standard/src/adapter.ts
@@ -71,7 +71,7 @@ export interface WalletAdapterProps {
* @param network The network to connect to
* @param decryptPermission The decrypt permission
* @param programs The programs to connect to
- * @param options Optional additive connect-time options (record access, view-key exposure, address withholding)
+ * @param options Optional additive connect-time options (record access, address withholding)
* @returns The connected account
*/
connect(
diff --git a/packages/aleo-wallet-standard/src/features.ts b/packages/aleo-wallet-standard/src/features.ts
index a788aed..7baa8f0 100644
--- a/packages/aleo-wallet-standard/src/features.ts
+++ b/packages/aleo-wallet-standard/src/features.ts
@@ -37,7 +37,7 @@ export interface ConnectFeature extends WalletFeature {
* @param network The network to connect to
* @param decryptPermission The decrypt permission
* @param programs The programs to connect to
- * @param options Optional additive connect-time options (record access, view-key exposure, address withholding)
+ * @param options Optional additive connect-time options (record access, address withholding)
* @returns The connected account
*/
connect(
diff --git a/packages/aleo-wallet-standard/src/wallet.ts b/packages/aleo-wallet-standard/src/wallet.ts
index 5d6cab3..48e6e02 100644
--- a/packages/aleo-wallet-standard/src/wallet.ts
+++ b/packages/aleo-wallet-standard/src/wallet.ts
@@ -219,11 +219,6 @@ export type RecordAccessGrant =
| { level: 'none' }
| { level: 'byProgram'; programs: ProgramGrant[] };
-/**
- * View-key exposure preference. Defaults to `DENY` when omitted.
- */
-export type ViewKeyExposure = 'DENY' | 'PER_TX_PROMPT';
-
/**
* Optional, additive connect-time options. All fields are opt-in; omitting them
* preserves today's behavior.
@@ -231,8 +226,6 @@ export type ViewKeyExposure = 'DENY' | 'PER_TX_PROMPT';
export interface ConnectOptions {
/** Opt-in record/field narrowing on top of `programs`. */
recordAccess?: RecordAccessGrant;
- /** View-key exposure preference; defaults to `DENY`. */
- viewKeyExposure?: ViewKeyExposure;
/** When `false`, the dapp transacts without learning the user's address. Defaults to `true`. */
readAddress?: boolean;
}
@@ -244,9 +237,5 @@ export interface ConnectOptions {
*/
export function hasUnsupportedConnectOptions(options?: ConnectOptions): boolean {
if (!options) return false;
- return (
- options.recordAccess !== undefined ||
- options.viewKeyExposure !== undefined ||
- options.readAddress === false
- );
+ return options.recordAccess !== undefined || options.readAddress === false;
}
From a5d741c21e82bc517f510531bf6000697736bebe Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Thu, 14 May 2026 16:22:25 -0400
Subject: [PATCH 10/18] Add type: "derived" InputRequest for wallet-computed
cryptographic values
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a fourth `InputRequest` variant `{ type: "derived", algorithm, args }`
that asks the wallet to run a named cryptographic algorithm over its own
state (view key, wallet-maintained counters, etc.) plus dapp-supplied args,
and substitutes the result into a transaction input slot. The dapp never
observes the wallet-side inputs — only the output.
Strict opt-in via a new `algorithmsAllowed?: AlgorithmGrant[]` field on
`ConnectOptions`: each grant authorizes exactly one
`(algorithm, program, function, inputPosition)` call site, all four fields
required and exact-match. The wallet refuses every derived request whose
tuple is not present. No broad default.
A new `algorithmsSupported(): Promise` adapter method lets dapps
discover what a wallet implements before populating the allowlist.
Wallets without derived-input support return `[]` (default) and connections
with non-empty `algorithmsAllowed` throw `WalletConnectOptionsNotSupportedError`
via the existing `hasUnsupportedConnectOptions` path.
Inaugural algorithm: `program-scoped-address-blind`. Inputs: `{ "domain-separator": field }`.
Output: `address`. Valid slot positions: `address`, `group`, `scalar`, `field`.
Updates the PrivateInputs example with a fourth `Derived` mode on primitive
slots whose baseType is in an algorithm's `validSlotTypes`, plus a
connect-time `AlgorithmGrant[]` editor with an "Auto-grant this function's
eligible slots" convenience button. The grant JSON preview now includes
`algorithmsAllowed`.
See docs/adapter-privacy-extension.md §"Derived inputs" for the spec and
docs/dapp-privacy-quickstart.md for an implementor's guide.
---
.changeset/derived-inputs.md | 25 ++
docs/adapter-privacy-extension.md | 92 +++++-
docs/dapp-privacy-quickstart.md | 48 +++-
examples/react-app/src/App.tsx | 3 +
.../components/functions/PrivateInputs.tsx | 270 +++++++++++++++++-
examples/react-app/src/lib/store/global.ts | 10 +-
packages/aleo-types/src/index.ts | 1 +
packages/aleo-types/src/transaction.ts | 64 +++++
.../aleo-wallet-adaptor/core/src/adapter.ts | 41 +++
.../aleo-wallet-adaptor/core/src/types.ts | 7 +-
.../react/src/WalletProvider.tsx | 30 +-
.../aleo-wallet-adaptor/react/src/context.ts | 7 +
.../wallets/shield/src/ShieldWalletAdapter.ts | 13 +
packages/aleo-wallet-standard/src/adapter.ts | 7 +
packages/aleo-wallet-standard/src/wallet.ts | 31 +-
15 files changed, 631 insertions(+), 18 deletions(-)
create mode 100644 .changeset/derived-inputs.md
diff --git a/.changeset/derived-inputs.md b/.changeset/derived-inputs.md
new file mode 100644
index 0000000..bea36b7
--- /dev/null
+++ b/.changeset/derived-inputs.md
@@ -0,0 +1,25 @@
+---
+'@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).
+
+Inaugural algorithm: `program-scoped-address-blind`. Inputs (dapp-provided): `{ "domain-separator": field }`. Output type: `address`. Valid input slot positions: `address`, `group`, `scalar`, `field`. The output is a per-program blinded address whose link to the active address is hidden by a BHP256 commitment.
+
+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/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index 7d04ed5..c73966d 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -2,7 +2,7 @@
## 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 or to auto-select an owned record matching dapp-supplied criteria. The wallet fulfills the request before passing the transaction to the SDK.
+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
@@ -11,7 +11,8 @@ 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; filters?: RecordFilters; uid?: string }; // Specification to use a record from a specific program. 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 matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
+ | { type: "record"; program: string; filters?: RecordFilters; uid?: string } // Specification to use a record from a specific program. 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 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.
@@ -19,6 +20,23 @@ type RecordFieldFilter = { eq?: string, gte?: string, lte?: string, neq?: string
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-address-blind";
+type AlgorithmName = KnownAlgorithm | (string & {});
+
+interface AlgorithmArg {
+ type: LiteralType; // parsing directive — the wallet decodes `value` according to this Aleo primitive type
+ value: string; // an Aleo literal in canonical string form (e.g. "12345field", "100u64", "true")
+}
```
`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:
@@ -33,6 +51,7 @@ The remaining (legacy) fields — including `recordPlaintext`, `commitment`, `ta
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.
@@ -55,6 +74,7 @@ interface ConnectHistory {
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 =
@@ -75,6 +95,16 @@ 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. All four fields 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
+}
```
| Configuration | Meaning |
@@ -121,6 +151,55 @@ Rationale: when the dapp asks for narrow field access, it has explicitly given u
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
+
+##### `program-scoped-address-blind`
+
+Produces a blinded address scoped to a specific program. Two dapps using the same wallet against different programs derive different blinded addresses. Whether the same dapp derives the same blinded address across executions is governed by wallet-internal state; the dapp can neither control nor observe that state.
+
+| Property | Value |
+|---|---|
+| `args` (dapp-provided) | `{ "domain-separator": AlgorithmArg }` |
+| wallet-derived inputs | program address (as field), active view key (as field), wallet-maintained counter (as field) |
+| output type | `address` |
+| valid input slot positions | `address`, `group`, `scalar`, `field` |
+
+Algorithm (pseudo, matching the wallet's reference implementation):
+
+```
+r = Poseidon4.hashToScalar([programAddrField, domainSeparatorField, viewKeyField, counterField])
+blinded = BHP256.commitToGroup(signerGroup.toBitsLE(), r)
+result = Address.fromGroup(blinded)
+```
+
+The dapp never observes the view key, the counter, or the intermediate scalar `r`. The output is an address whose link to the active address is hidden by the BHP256 commitment — recovering the active address from the output is computationally infeasible without `r`.
+
+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-address-blind` 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.
@@ -165,8 +244,10 @@ flowchart TD
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"]
@@ -195,3 +276,10 @@ The worker boundary still receives `string[]`. All fulfillment is wallet-side; t
| `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
index ebf327c..c4a47a2 100644
--- a/docs/dapp-privacy-quickstart.md
+++ b/docs/dapp-privacy-quickstart.md
@@ -4,18 +4,20 @@ How to use the wallet-adapter's new privacy features from a dapp. For the full s
## What's new
-Two connect-time grants and two transaction-input request types:
+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, uid }` | pin specific record by handle | `record`, `dynamic_record`, `external_record` |
| `{ type: "record", program, filters }` | wallet auto-selects matching record | same |
+| `{ type: "derived", algorithm, args }` | wallet runs a named crypto algorithm | depends on algorithm — see catalog |
## Wiring connect-time options
@@ -133,6 +135,49 @@ await executeTransaction({
});
```
+## 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:
+
+```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 slot:
+
+```ts
+await executeTransaction({
+ program: 'myapp.aleo',
+ function: 'vote',
+ inputs: [
+ { type: 'derived',
+ algorithm: 'program-scoped-address-blind',
+ args: {
+ // Pre-encode anything non-primitive to an Aleo literal; the wallet only
+ // accepts AlgorithmArg values that are LiteralType-parseable strings.
+ 'domain-separator': { type: 'field', value: '12345field' },
+ },
+ label: 'Your private voter handle',
+ },
+ // ...rest of the function's inputs
+ ],
+});
+```
+
+`ALGORITHM_SCHEMAS` from `@provablehq/aleo-types` ships the args schema, output type, and valid slot positions for every known algorithm — 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`:
@@ -152,3 +197,4 @@ If your existing dapp:
- **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/examples/react-app/src/App.tsx b/examples/react-app/src/App.tsx
index da6d818..1cfd6b0 100644
--- a/examples/react-app/src/App.tsx
+++ b/examples/react-app/src/App.tsx
@@ -10,6 +10,7 @@ import { toast, Toaster } from 'sonner';
import { ThemeProvider } from 'next-themes';
import { useAtomValue } from 'jotai';
import {
+ algorithmsAllowedAtom,
autoConnectAtom,
decryptPermissionAtom,
networkAtom,
@@ -41,6 +42,7 @@ export function App() {
const programs = useAtomValue(programsAtom);
const recordAccess = useAtomValue(recordAccessAtom);
const readAddress = useAtomValue(readAddressAtom);
+ const algorithmsAllowed = useAtomValue(algorithmsAllowedAtom);
return (
@@ -53,6 +55,7 @@ export function App() {
programs={programs}
recordAccess={recordAccess}
readAddress={readAddress}
+ algorithmsAllowed={algorithmsAllowed}
>
diff --git a/examples/react-app/src/components/functions/PrivateInputs.tsx b/examples/react-app/src/components/functions/PrivateInputs.tsx
index 6cf537f..6438027 100644
--- a/examples/react-app/src/components/functions/PrivateInputs.tsx
+++ b/examples/react-app/src/components/functions/PrivateInputs.tsx
@@ -22,6 +22,10 @@ import { toast } from 'sonner';
import { useWallet } from '@provablehq/aleo-wallet-adaptor-react';
import { useWalletModal } from '@provablehq/aleo-wallet-adaptor-react-ui';
import {
+ ALGORITHM_SCHEMAS,
+ AlgorithmArg,
+ AlgorithmName,
+ KnownAlgorithm,
Network,
RecordEnvelope,
RecordFieldFilter,
@@ -30,6 +34,7 @@ import {
TransactionStatus,
} from '@provablehq/aleo-types';
import {
+ AlgorithmGrant,
FieldGrant,
ProgramGrant,
RecordAccessGrant,
@@ -38,6 +43,7 @@ import {
import { CodePanel } from '../CodePanel';
import { codeExamples, PLACEHOLDERS } from '@/lib/codeExamples';
import {
+ algorithmsAllowedAtom,
decryptPermissionAtom,
readAddressAtom,
recordAccessAtom,
@@ -61,9 +67,15 @@ type ParsedSlot =
};
type RecordSlotMode = 'plaintext' | 'pick' | 'filter';
-type PrimitiveSlotMode = 'literal' | 'address';
+type PrimitiveSlotMode = 'literal' | 'address' | 'derived';
type SlotState =
- | { kind: 'primitive'; mode: PrimitiveSlotMode; value: string }
+ | {
+ kind: 'primitive';
+ mode: PrimitiveSlotMode;
+ value: string;
+ derivedAlgorithm: KnownAlgorithm | '';
+ derivedArgs: Record; // arg name → user-typed value (parsed lazily at submit)
+ }
| {
kind: 'record';
mode: RecordSlotMode;
@@ -82,9 +94,18 @@ const ADDRESS_REQUEST_ALLOWED = new Set(['address', 'group', 'scalar', 'field'])
function primitiveSlotModes(baseType: string): PrimitiveSlotMode[] {
const modes: PrimitiveSlotMode[] = ['literal'];
if (ADDRESS_REQUEST_ALLOWED.has(baseType)) modes.push('address');
+ // Derived is offered when at least one known algorithm's `validSlotTypes`
+ // includes this baseType. Grant validation happens wallet-side at execute.
+ if (eligibleAlgorithmsForBaseType(baseType).length > 0) modes.push('derived');
return modes;
}
+function eligibleAlgorithmsForBaseType(baseType: string): KnownAlgorithm[] {
+ return (Object.keys(ALGORITHM_SCHEMAS) as KnownAlgorithm[]).filter(name =>
+ (ALGORITHM_SCHEMAS[name].validSlotTypes as readonly string[]).includes(baseType),
+ );
+}
+
function parseTypeExpr(name: string, typeExpr: string): ParsedSlot | null {
const lastDot = typeExpr.lastIndexOf('.');
if (lastDot < 0) return null;
@@ -142,7 +163,7 @@ function defaultSlotState(slot: ParsedSlot, fallbackProgram: string): SlotState
if (slot.kind === 'primitive') {
// Default address-typed slots to wallet-provided active address (privacy-preserving default).
const mode: PrimitiveSlotMode = slot.baseType === 'address' ? 'address' : 'literal';
- return { kind: 'primitive', mode, value: '' };
+ return { kind: 'primitive', mode, value: '', derivedAlgorithm: '', derivedArgs: {} };
}
const slotProgram = slot.program || fallbackProgram;
const isCredits = slotProgram === 'credits.aleo' && slot.recordname === 'credits';
@@ -177,6 +198,23 @@ function buildInputs(
if (!state) throw new Error(`slot ${i} (${slot.name}) has no state`);
if (state.kind === 'primitive') {
if (state.mode === 'address') return { type: 'address' };
+ if (state.mode === 'derived') {
+ if (!state.derivedAlgorithm) {
+ throw new Error(`slot ${i} (${slot.name}) — pick an algorithm for the derived input`);
+ }
+ const schema = ALGORITHM_SCHEMAS[state.derivedAlgorithm];
+ const args: Record = {};
+ for (const [argName, argSchema] of Object.entries(schema.args)) {
+ const raw = (state.derivedArgs[argName] ?? '').trim();
+ if (!raw) {
+ throw new Error(
+ `slot ${i} (${slot.name}) — derived arg "${argName}" (${argSchema.type}) is empty`,
+ );
+ }
+ args[argName] = { type: argSchema.type, value: raw };
+ }
+ return { type: 'derived', algorithm: state.derivedAlgorithm as AlgorithmName, args };
+ }
if (!state.value.trim()) {
throw new Error(`slot ${i} (${slot.name}: ${slot.raw}) is empty`);
}
@@ -271,6 +309,7 @@ export function PrivateInputs() {
const { setVisible: openWalletModal } = useWalletModal();
const [, setRecordAccess] = useAtom(recordAccessAtom);
const [readAddress, setReadAddress] = useAtom(readAddressAtom);
+ const [algorithmsAllowed, setAlgorithmsAllowed] = useAtom(algorithmsAllowedAtom);
const [decryptPermission, setDecryptPermission] = useAtom(decryptPermissionAtom);
const [form, setForm] = useState(DEFAULTS);
@@ -407,6 +446,7 @@ export function PrivateInputs() {
const clearGrantAndDisconnect = async () => {
setRecordAccess(undefined);
setReadAddress(undefined);
+ setAlgorithmsAllowed(undefined);
toast.success('All grants cleared. Reconnect to apply (broad legacy behavior restored).');
if (connected) {
try {
@@ -477,7 +517,12 @@ export function PrivateInputs() {
};
const handleExecute = async () => {
- console.log('[PrivateInputs] handleExecute: connected=', connected, 'readAddress=', readAddress);
+ console.log(
+ '[PrivateInputs] handleExecute: connected=',
+ connected,
+ 'readAddress=',
+ readAddress,
+ );
if (!connected) {
openWalletModal(true);
return;
@@ -649,6 +694,8 @@ export function PrivateInputs() {
const state = slotStates[i];
if (!state || state.kind !== 'primitive') return null;
const modes = primitiveSlotModes(slot.baseType);
+ const eligibleAlgs = eligibleAlgorithmsForBaseType(slot.baseType);
+ const algSchema = state.derivedAlgorithm ? ALGORITHM_SCHEMAS[state.derivedAlgorithm] : null;
return (
Record access: one entry per program. Records narrows to specific record
@@ -1086,6 +1337,7 @@ export function PrivateInputs() {
{
recordAccess: { level: 'byProgram', programs: programGrants },
readAddress,
+ algorithmsAllowed,
},
null,
2,
diff --git a/examples/react-app/src/lib/store/global.ts b/examples/react-app/src/lib/store/global.ts
index 00cc21d..6870f2e 100644
--- a/examples/react-app/src/lib/store/global.ts
+++ b/examples/react-app/src/lib/store/global.ts
@@ -1,6 +1,10 @@
import { atomWithStorage } from 'jotai/utils';
import { Network } from '@provablehq/aleo-types';
-import { DecryptPermission, RecordAccessGrant } from '@provablehq/aleo-wallet-adaptor-core';
+import {
+ AlgorithmGrant,
+ DecryptPermission,
+ RecordAccessGrant,
+} from '@provablehq/aleo-wallet-adaptor-core';
/**
* Adapter default values
@@ -23,6 +27,10 @@ export const recordAccessAtom = atomWithStorage(
undefined,
);
export const readAddressAtom = atomWithStorage('readAddress', undefined);
+export const algorithmsAllowedAtom = atomWithStorage(
+ 'algorithmsAllowed',
+ undefined,
+);
/**
* UI state
diff --git a/packages/aleo-types/src/index.ts b/packages/aleo-types/src/index.ts
index df73003..e111564 100644
--- a/packages/aleo-types/src/index.ts
+++ b/packages/aleo-types/src/index.ts
@@ -1,3 +1,4 @@
export * from './account';
+export * from './data';
export * from './transaction';
export * from './network';
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index f737dce..3bf05bb 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -1,3 +1,5 @@
+import { LiteralType } from './data';
+
/**
* Status of a transaction
*/
@@ -78,8 +80,70 @@ export type InputRequest =
program: string;
filters?: RecordFilters;
uid?: string;
+ }
+ | {
+ /**
+ * Fill the input slot with the output of a wallet-evaluated cryptographic
+ * algorithm. The wallet runs the named `algorithm` over its own state
+ * (view key, wallet-maintained counters, etc.) plus the dapp's `args`,
+ * and substitutes the result into the slot. The dapp never observes the
+ * wallet-side inputs — only the output.
+ *
+ * Strictly opt-in: the wallet refuses every derived request whose
+ * `(algorithm, program, function, inputPosition)` tuple is not present
+ * in the connection's `algorithmsAllowed`. Each algorithm declares its
+ * `args` schema and output Aleo type; the output type determines which
+ * input positions are valid (same rules as `type: "address"`).
+ */
+ type: 'derived';
+ algorithm: AlgorithmName;
+ args: Record;
+ label?: string;
};
+/**
+ * Algorithms that conforming wallets are expected to implement. The
+ * `(string & {})` extension permits unknown values for forward-compat:
+ * a wallet shipping a new algorithm before this union is updated can still
+ * be addressed. The wallet validates at runtime against its own
+ * `algorithmsSupported()` list.
+ */
+export type KnownAlgorithm = 'program-scoped-address-blind';
+export type AlgorithmName = KnownAlgorithm | (string & {});
+
+/**
+ * One typed argument passed to a wallet-side cryptographic algorithm. The
+ * wallet parses `value` according to `type` (an Aleo primitive type) before
+ * invoking the algorithm.
+ */
+export interface AlgorithmArg {
+ type: LiteralType;
+ value: string;
+}
+
+/**
+ * Static catalog of known algorithms — their dapp-provided `args` schema, the
+ * Aleo type of their output, and the input-slot positions where they are
+ * valid. The wallet is the source of truth at runtime; this registry lets the
+ * SDK and dapp tooling render correct forms and pre-validate shapes.
+ */
+export const ALGORITHM_SCHEMAS = {
+ 'program-scoped-address-blind': {
+ args: {
+ 'domain-separator': { type: 'field' as LiteralType },
+ },
+ outputType: 'address' as LiteralType,
+ validSlotTypes: ['address', 'group', 'scalar', 'field'] as LiteralType[],
+ },
+} as const satisfies Record<
+ KnownAlgorithm,
+ {
+ args: Record;
+ outputType: LiteralType;
+ validSlotTypes: LiteralType[];
+ }
+>;
+
/**
* One element of a transaction's `inputs` array. A literal Aleo value (string)
* or an `InputRequest` describing a value the wallet should supply.
diff --git a/packages/aleo-wallet-adaptor/core/src/adapter.ts b/packages/aleo-wallet-adaptor/core/src/adapter.ts
index fe9508e..6910361 100644
--- a/packages/aleo-wallet-adaptor/core/src/adapter.ts
+++ b/packages/aleo-wallet-adaptor/core/src/adapter.ts
@@ -306,6 +306,18 @@ export abstract class BaseAleoWalletAdapter
}
return feature.requestTransactionHistory(program);
}
+
+ /**
+ * Return the algorithm names this wallet implements for `type: "derived"`
+ * InputRequests. A dapp calls this before connect to learn which entries
+ * are valid in `ConnectOptions.algorithmsAllowed`. Wallets that do not
+ * support derived inputs at all should return `[]`.
+ *
+ * Override in adapters that support derived inputs.
+ */
+ async algorithmsSupported(): Promise {
+ return [];
+ }
}
export function scopePollingDetectionStrategy(detect: () => boolean): void {
@@ -360,5 +372,34 @@ export function validateInputRequests(inputs: TransactionInput[]): void {
`inputs[${i}]: type "record" cannot specify both \`uid\` and \`filters\`. \`uid\` pins a specific record returned by requestRecords; filters are ignored when \`uid\` is set.`,
);
}
+ if (input.type === 'derived') {
+ if (typeof input.algorithm !== 'string' || input.algorithm.length === 0) {
+ throw new WalletInputRequestInvalidError(
+ `inputs[${i}]: type "derived" requires a non-empty \`algorithm\` string.`,
+ );
+ }
+ if (input.args === null || typeof input.args !== 'object' || Array.isArray(input.args)) {
+ throw new WalletInputRequestInvalidError(
+ `inputs[${i}]: type "derived" requires \`args\` to be an object (Record).`,
+ );
+ }
+ for (const [argName, arg] of Object.entries(input.args)) {
+ if (arg === null || typeof arg !== 'object') {
+ throw new WalletInputRequestInvalidError(
+ `inputs[${i}]: args["${argName}"] must be { type, value }.`,
+ );
+ }
+ if (typeof (arg as { type?: unknown }).type !== 'string') {
+ throw new WalletInputRequestInvalidError(
+ `inputs[${i}]: args["${argName}"].type must be a LiteralType string.`,
+ );
+ }
+ if (typeof (arg as { value?: unknown }).value !== 'string') {
+ throw new WalletInputRequestInvalidError(
+ `inputs[${i}]: args["${argName}"].value must be a string Aleo literal.`,
+ );
+ }
+ }
+ }
}
}
diff --git a/packages/aleo-wallet-adaptor/core/src/types.ts b/packages/aleo-wallet-adaptor/core/src/types.ts
index 621b389..8499046 100644
--- a/packages/aleo-wallet-adaptor/core/src/types.ts
+++ b/packages/aleo-wallet-adaptor/core/src/types.ts
@@ -2,6 +2,7 @@ import { WalletDecryptPermission } from '@provablehq/aleo-wallet-standard';
export { WalletDecryptPermission as DecryptPermission };
export type {
+ AlgorithmGrant,
ConnectOptions,
FieldGrant,
ProgramGrant,
@@ -10,11 +11,15 @@ export type {
} from '@provablehq/aleo-wallet-standard';
export { hasUnsupportedConnectOptions } from '@provablehq/aleo-wallet-standard';
export type {
+ AlgorithmArg,
+ AlgorithmName,
InputRequest,
+ KnownAlgorithm,
+ LiteralType,
RecordEnvelope,
RecordFieldFilter,
RecordFilters,
RecordView,
TransactionInput,
} from '@provablehq/aleo-types';
-export { hasInputRequest, isLiteralInput } from '@provablehq/aleo-types';
+export { ALGORITHM_SCHEMAS, hasInputRequest, isLiteralInput } from '@provablehq/aleo-types';
diff --git a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
index 3a09bec..024e051 100644
--- a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
+++ b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx
@@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
+ AlgorithmGrant,
WalletName,
WalletReadyState,
WalletAdapter,
@@ -41,6 +42,12 @@ export interface WalletProviderProps {
* Defaults to `true`. Only valid with `decryptPermission: NoDecrypt`.
*/
readAddress?: boolean;
+ /**
+ * Strict opt-in allowlist for `type: "derived"` InputRequests. Each grant
+ * authorizes exactly one (algorithm, program, function, inputPosition)
+ * call site. Default undefined → every derived request is refused.
+ */
+ algorithmsAllowed?: AlgorithmGrant[];
}
const initialState: {
@@ -68,13 +75,18 @@ export const AleoWalletProvider: FC = ({
programs,
recordAccess,
readAddress,
+ algorithmsAllowed,
}) => {
const connectOptions = useMemo(() => {
- if (recordAccess === undefined && readAddress === undefined) {
+ if (
+ recordAccess === undefined &&
+ readAddress === undefined &&
+ (algorithmsAllowed === undefined || algorithmsAllowed.length === 0)
+ ) {
return undefined;
}
- return { recordAccess, readAddress };
- }, [recordAccess, readAddress]);
+ return { recordAccess, readAddress, algorithmsAllowed };
+ }, [recordAccess, readAddress, algorithmsAllowed]);
const [name, setName] = useLocalStorage(localStorageKey, null);
const [{ wallet, adapter, publicKey, connected, network }, setState] = useState(initialState);
const readyState = adapter?.readyState || WalletReadyState.UNSUPPORTED;
@@ -510,6 +522,17 @@ export const AleoWalletProvider: FC = ({
[adapter, handleError, connected],
);
+ // Doesn't require a connection — dapps may call this before connect to discover
+ // which algorithms a wallet supports and to populate `algorithmsAllowed`.
+ const algorithmsSupported = useCallback(async () => {
+ if (!adapter || !('algorithmsSupported' in adapter)) return [];
+ try {
+ return await adapter.algorithmsSupported();
+ } catch {
+ return [];
+ }
+ }, [adapter]);
+
const checkNetwork = useCallback(async () => {
if (adapter && adapter.network !== initialNetwork) {
const switchResult = await switchNetwork(initialNetwork);
@@ -543,6 +566,7 @@ export const AleoWalletProvider: FC = ({
executeDeployment,
transitionViewKeys,
requestTransactionHistory,
+ algorithmsSupported,
}}
>
{children}
diff --git a/packages/aleo-wallet-adaptor/react/src/context.ts b/packages/aleo-wallet-adaptor/react/src/context.ts
index a26b2cb..f505f54 100644
--- a/packages/aleo-wallet-adaptor/react/src/context.ts
+++ b/packages/aleo-wallet-adaptor/react/src/context.ts
@@ -133,6 +133,13 @@ export interface WalletContextState {
* @returns array of transactionId
*/
requestTransactionHistory: (program: string) => Promise;
+ /**
+ * Return the algorithm names this wallet implements for `type: "derived"`
+ * InputRequests. A dapp calls this before connect to pick which entries to
+ * include in `algorithmsAllowed`. Wallets without derived-input support
+ * return `[]`.
+ */
+ algorithmsSupported: () => Promise;
}
/**
diff --git a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
index 2550038..1acbdd6 100644
--- a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
@@ -415,6 +415,19 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
}
}
+ /**
+ * Shield's currently-supported derived-input algorithms. Returns the SDK's
+ * known-algorithm catalog; the wallet itself is the source of truth at
+ * runtime and will reject any algorithm it doesn't implement.
+ *
+ * TODO(wallet): when the injector exposes an `algorithmsSupported` message,
+ * replace this static list with a real round-trip so dapps see what THIS
+ * Shield build supports, not just the SDK's static catalog.
+ */
+ async algorithmsSupported(): Promise {
+ return ['program-scoped-address-blind'];
+ }
+
/**
* EVENTS HANDLING
*/
diff --git a/packages/aleo-wallet-standard/src/adapter.ts b/packages/aleo-wallet-standard/src/adapter.ts
index dfec58a..7ac8f9a 100644
--- a/packages/aleo-wallet-standard/src/adapter.ts
+++ b/packages/aleo-wallet-standard/src/adapter.ts
@@ -159,6 +159,13 @@ export interface WalletAdapterProps {
* @returns array of transactionId
*/
requestTransactionHistory: (program: string) => Promise;
+
+ /**
+ * Return the algorithm names this wallet implements for `type: "derived"`
+ * InputRequests. Wallets without derived-input support return `[]`.
+ * No connection required.
+ */
+ algorithmsSupported: () => Promise;
}
export type WalletAdapter = WalletAdapterProps &
diff --git a/packages/aleo-wallet-standard/src/wallet.ts b/packages/aleo-wallet-standard/src/wallet.ts
index 48e6e02..87c518a 100644
--- a/packages/aleo-wallet-standard/src/wallet.ts
+++ b/packages/aleo-wallet-standard/src/wallet.ts
@@ -219,6 +219,26 @@ export type RecordAccessGrant =
| { level: 'none' }
| { level: 'byProgram'; programs: ProgramGrant[] };
+/**
+ * Authorization for a `type: "derived"` InputRequest at one specific call site.
+ * All four fields are required and exact-match; the wallet refuses every
+ * derived request whose `(algorithm, program, function, inputPosition)` tuple
+ * is not in the connection's `algorithmsAllowed`. A dapp that wants to use
+ * the same algorithm at multiple call sites lists each one as its own entry.
+ *
+ * See `docs/adapter-privacy-extension.md` § "Derived inputs".
+ */
+export interface AlgorithmGrant {
+ /** Must appear in the wallet's `algorithmsSupported()` list. */
+ algorithm: string;
+ /** Must also appear in the connection's `programs` allowlist. */
+ program: string;
+ /** Exact transition name within `program`. */
+ function: string;
+ /** 0-based index into the function's input slots. */
+ inputPosition: number;
+}
+
/**
* Optional, additive connect-time options. All fields are opt-in; omitting them
* preserves today's behavior.
@@ -228,6 +248,11 @@ export interface ConnectOptions {
recordAccess?: RecordAccessGrant;
/** When `false`, the dapp transacts without learning the user's address. Defaults to `true`. */
readAddress?: boolean;
+ /**
+ * Strict opt-in allowlist for `type: "derived"` InputRequests. Default
+ * undefined → every derived request is refused. There is no broad default.
+ */
+ algorithmsAllowed?: AlgorithmGrant[];
}
/**
@@ -237,5 +262,9 @@ export interface ConnectOptions {
*/
export function hasUnsupportedConnectOptions(options?: ConnectOptions): boolean {
if (!options) return false;
- return options.recordAccess !== undefined || options.readAddress === false;
+ return (
+ options.recordAccess !== undefined ||
+ options.readAddress === false ||
+ (options.algorithmsAllowed !== undefined && options.algorithmsAllowed.length > 0)
+ );
}
From 91a0a5883abf1eba11f2b114ca4a74c7ed8b7270 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Fri, 29 May 2026 01:54:31 -0500
Subject: [PATCH 11/18] Require recordname on type:"record" InputRequest
---
docs/adapter-privacy-extension.md | 2 +-
docs/dapp-privacy-quickstart.md | 4 ++--
.../components/functions/PrivateInputs.tsx | 21 ++++++++++++-------
packages/aleo-types/src/transaction.ts | 17 ++++++++++-----
4 files changed, 29 insertions(+), 15 deletions(-)
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index c73966d..80c2a07 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -11,7 +11,7 @@ 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; filters?: RecordFilters; uid?: string } // Specification to use a record from a specific program. 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 matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`.
+ | { 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".
diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md
index c4a47a2..5d62f57 100644
--- a/docs/dapp-privacy-quickstart.md
+++ b/docs/dapp-privacy-quickstart.md
@@ -15,8 +15,8 @@ Three connect-time grants and three transaction-input request types:
| `InputRequest` slot type | Shape | Valid in |
|---|---|---|
| `{ type: "address" }` | wallet injects active address | `address`, `group`, `scalar`, `field` |
-| `{ type: "record", program, uid }` | pin specific record by handle | `record`, `dynamic_record`, `external_record` |
-| `{ type: "record", program, filters }` | wallet auto-selects matching record | same |
+| `{ 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
diff --git a/examples/react-app/src/components/functions/PrivateInputs.tsx b/examples/react-app/src/components/functions/PrivateInputs.tsx
index 6438027..7e0cbe5 100644
--- a/examples/react-app/src/components/functions/PrivateInputs.tsx
+++ b/examples/react-app/src/components/functions/PrivateInputs.tsx
@@ -221,8 +221,9 @@ function buildInputs(
return state.value.trim();
}
// record slot
- const slotProgram =
- (slot as Extract).program || fallbackProgram;
+ const recordSlot = slot as Extract;
+ const slotProgram = recordSlot.program || fallbackProgram;
+ const recordname = recordSlot.recordname;
if (state.mode === 'plaintext') {
if (!state.plaintext.trim()) {
throw new Error(`slot ${i} (${slot.name}) plaintext is empty`);
@@ -233,14 +234,14 @@ function buildInputs(
if (!state.uid) {
throw new Error(`slot ${i} (${slot.name}) — pick a record from the dropdown`);
}
- return { type: 'record', program: slotProgram, uid: state.uid };
+ return { type: 'record', program: slotProgram, recordname, uid: state.uid };
}
// filter
const filters = buildFilters(state.filterRows);
if (Object.keys(filters).length === 0) {
throw new Error(`slot ${i} (${slot.name}) — add at least one filter row`);
}
- return { type: 'record', program: slotProgram, filters };
+ return { type: 'record', program: slotProgram, recordname, filters };
});
}
@@ -581,9 +582,15 @@ export function PrivateInputs() {
try {
const baseInputs = parsedSlots.map((slot, i): TransactionInput => {
if (i === firstRecordSlotIndex) {
- const slotProgram =
- (slot as Extract).program || form.programName.trim();
- return { type: 'record', program: slotProgram, uid, filters: {} };
+ const recordSlot = slot as Extract;
+ const slotProgram = recordSlot.program || form.programName.trim();
+ return {
+ type: 'record',
+ program: slotProgram,
+ recordname: recordSlot.recordname,
+ uid,
+ filters: {},
+ };
}
const state = slotStates[i];
if (state?.kind === 'primitive') return state.value || '0u64';
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index 3bf05bb..d246b33 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -69,15 +69,22 @@ export type InputRequest =
}
| {
/**
- * Use an owned record from `program` as the input. When `uid` is present,
- * it pins a specific record previously returned by `requestRecords` and
- * `filters` is ignored. When absent, the wallet auto-selects an unspent
- * record matching `filters`. The two are mutually exclusive — supplying
- * both is rejected before reaching the wallet. Allowed in `record`,
+ * Use an owned record of type `program/recordname` as the input. When
+ * `uid` is present, it pins a specific record previously returned by
+ * `requestRecords` and `filters` is ignored. When absent, the wallet
+ * auto-selects an unspent record of `recordname` matching `filters`.
+ * `uid` and `filters` are mutually exclusive — supplying both is rejected
+ * before reaching the wallet.
+ *
+ * `recordname` is required so the gate can match the request against the
+ * dapp's grant 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. Allowed in `record`,
* `dynamic_record`, or `external_record` positions.
*/
type: 'record';
program: string;
+ recordname: string;
filters?: RecordFilters;
uid?: string;
}
From 2df9b8d6f0ef8ee739a906bc169936fe8329652e Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Fri, 29 May 2026 18:59:26 -0500
Subject: [PATCH 12/18] feat(types): split blinding algorithm; ArgType +
catalog possibleValues
---
packages/aleo-types/src/transaction.ts | 36 +++++++++++++++++++-------
1 file changed, 27 insertions(+), 9 deletions(-)
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index d246b33..4c25e91 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -115,19 +115,34 @@ export type InputRequest =
* be addressed. The wallet validates at runtime against its own
* `algorithmsSupported()` list.
*/
-export type KnownAlgorithm = 'program-scoped-address-blind';
+export type KnownAlgorithm =
+ | 'program-scoped-blinding-factor'
+ | 'program-scoped-blinded-address';
export type AlgorithmName = KnownAlgorithm | (string & {});
+/** Arg-level type: an Aleo literal type, or "string" for non-literal args (enums, identifiers). */
+export type ArgType = LiteralType | 'string';
+
/**
* One typed argument passed to a wallet-side cryptographic algorithm. The
- * wallet parses `value` according to `type` (an Aleo primitive type) before
- * invoking the algorithm.
+ * wallet parses `value` according to `type` — either an Aleo primitive type
+ * (`LiteralType`) or `"string"` for non-literal args such as enum identifiers.
*/
export interface AlgorithmArg {
- type: LiteralType;
+ type: ArgType;
value: string;
}
+/** A per-arg grant constraint: a fixed allowlist of acceptable values, or "any". */
+export type ArgConstraint = string[] | 'any';
+
+const BLINDING_ARGS = {
+ mode: { type: 'string' as ArgType, possibleValues: ['issue', 'resolve'] as const },
+ membershipProgram: { type: 'string' as ArgType },
+ membershipMapping: { type: 'string' as ArgType },
+ targetAddress: { type: 'address' as ArgType, optional: true },
+} as const;
+
/**
* Static catalog of known algorithms — their dapp-provided `args` schema, the
* Aleo type of their output, and the input-slot positions where they are
@@ -135,17 +150,20 @@ export interface AlgorithmArg {
* SDK and dapp tooling render correct forms and pre-validate shapes.
*/
export const ALGORITHM_SCHEMAS = {
- 'program-scoped-address-blind': {
- args: {
- 'domain-separator': { type: 'field' as LiteralType },
- },
+ 'program-scoped-blinding-factor': {
+ args: BLINDING_ARGS,
+ outputType: 'field' as LiteralType,
+ validSlotTypes: ['field', 'scalar', 'group'] as LiteralType[],
+ },
+ 'program-scoped-blinded-address': {
+ args: BLINDING_ARGS,
outputType: 'address' as LiteralType,
validSlotTypes: ['address', 'group', 'scalar', 'field'] as LiteralType[],
},
} as const satisfies Record<
KnownAlgorithm,
{
- args: Record;
+ args: Record;
outputType: LiteralType;
validSlotTypes: LiteralType[];
}
From 49b666cefd45bb29af2880e08a9275535d51c288 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Fri, 29 May 2026 18:59:26 -0500
Subject: [PATCH 13/18] feat(adapter): generic argConstraints on AlgorithmGrant
---
packages/aleo-wallet-standard/src/wallet.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/packages/aleo-wallet-standard/src/wallet.ts b/packages/aleo-wallet-standard/src/wallet.ts
index 87c518a..10f9623 100644
--- a/packages/aleo-wallet-standard/src/wallet.ts
+++ b/packages/aleo-wallet-standard/src/wallet.ts
@@ -1,3 +1,4 @@
+import type { ArgConstraint } from '@provablehq/aleo-types';
import { AleoChain } from './chains';
import {
AccountsFeature,
@@ -237,6 +238,12 @@ export interface AlgorithmGrant {
function: string;
/** 0-based index into the function's input slots. */
inputPosition: number;
+ /**
+ * Optional per-arg bounds on the derived InputRequest's `args`: for each arg
+ * name, a fixed allowlist of acceptable values or "any" (omitted ⇒ "any").
+ * Enforced by the wallet. Matched against each `AlgorithmArg.value`.
+ */
+ argConstraints?: Record;
}
/**
From 21dd57f71f090e4c3a54f6f8b8ecea4e65dd964a Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Fri, 29 May 2026 18:59:26 -0500
Subject: [PATCH 14/18] feat(shield): advertise two blinding algorithms; docs
for modes + argConstraints
---
docs/adapter-privacy-extension.md | 64 ++++++++++++-------
docs/dapp-privacy-quickstart.md | 42 ++++++++----
.../wallets/shield/src/ShieldWalletAdapter.ts | 2 +-
3 files changed, 71 insertions(+), 37 deletions(-)
diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md
index 80c2a07..2abba71 100644
--- a/docs/adapter-privacy-extension.md
+++ b/docs/adapter-privacy-extension.md
@@ -30,13 +30,19 @@ type LiteralType =
// 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-address-blind";
+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: LiteralType; // parsing directive — the wallet decodes `value` according to this Aleo primitive type
- value: string; // an Aleo literal in canonical string form (e.g. "12345field", "100u64", "true")
+ 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:
@@ -96,14 +102,17 @@ interface FieldGrant {
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. All four fields 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.
+// 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
+ 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.
}
```
@@ -169,32 +178,39 @@ Each `KnownAlgorithm` has a fixed Aleo output type, declared in the catalog belo
#### Algorithm catalog
-##### `program-scoped-address-blind`
+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`:
-Produces a blinded address scoped to a specific program. Two dapps using the same wallet against different programs derive different blinded addresses. Whether the same dapp derives the same blinded address across executions is governed by wallet-internal state; the dapp can neither control nor observe that state.
+| 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 |
-| Property | Value |
-|---|---|
-| `args` (dapp-provided) | `{ "domain-separator": AlgorithmArg }` |
-| wallet-derived inputs | program address (as field), active view key (as field), wallet-maintained counter (as field) |
-| output type | `address` |
-| valid input slot positions | `address`, `group`, `scalar`, `field` |
+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):
+Algorithm (pseudo, matching the wallet's reference implementation; `BF_DOMAIN`/`CS_DOMAIN` are fixed program-identifier field constants):
```
-r = Poseidon4.hashToScalar([programAddrField, domainSeparatorField, viewKeyField, counterField])
-blinded = BHP256.commitToGroup(signerGroup.toBitsLE(), r)
-result = Address.fromGroup(blinded)
+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 the intermediate scalar `r`. The output is an address whose link to the active address is hidden by the BHP256 commitment — recovering the active address from the output is computationally infeasible without `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-address-blind` 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.
+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`
diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md
index 5d62f57..83ccb52 100644
--- a/docs/dapp-privacy-quickstart.md
+++ b/docs/dapp-privacy-quickstart.md
@@ -139,7 +139,7 @@ await executeTransaction({
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:
+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';
@@ -147,36 +147,54 @@ 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 slot:
+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: 'myapp.aleo',
- function: 'vote',
+ program: 'amm_v3.aleo',
+ function: 'swap_private',
inputs: [
+ // ...token_in_record slot...
{ type: 'derived',
- algorithm: 'program-scoped-address-blind',
+ algorithm: 'program-scoped-blinding-factor', // → private blinding_factor
args: {
- // Pre-encode anything non-primitive to an Aleo literal; the wallet only
- // accepts AlgorithmArg values that are LiteralType-parseable strings.
- 'domain-separator': { type: 'field', value: '12345field' },
+ 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' },
},
- label: 'Your private voter handle',
},
// ...rest of the function's inputs
],
});
```
-`ALGORITHM_SCHEMAS` from `@provablehq/aleo-types` ships the args schema, output type, and valid slot positions for every known algorithm — use it to render correct forms or pre-validate shapes. Full algorithm catalog: see [`adapter-privacy-extension.md`](./adapter-privacy-extension.md) § "Algorithm catalog".
+For a claim (`claim_swap_output_private`), use `mode: { type: 'string', value: 'resolve' }` and add `targetAddress: { type: 'address', value: 'aleo1…' }` (the public blinded address of the swap to claim) to both slots.
+
+`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
diff --git a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
index 1acbdd6..42ea2e4 100644
--- a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
+++ b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts
@@ -425,7 +425,7 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter {
* Shield build supports, not just the SDK's static catalog.
*/
async algorithmsSupported(): Promise {
- return ['program-scoped-address-blind'];
+ return ['program-scoped-blinding-factor', 'program-scoped-blinded-address'];
}
/**
From 764b8d5a59faf6abd600978adbedd1be99ac1a6c Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Fri, 29 May 2026 19:03:05 -0500
Subject: [PATCH 15/18] fix(adapter): generic arg const name, ArgType error
text, changeset for split
---
.changeset/derived-inputs.md | 4 +++-
packages/aleo-types/src/transaction.ts | 6 +++---
packages/aleo-wallet-adaptor/core/src/adapter.ts | 2 +-
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/.changeset/derived-inputs.md b/.changeset/derived-inputs.md
index bea36b7..c8a1897 100644
--- a/.changeset/derived-inputs.md
+++ b/.changeset/derived-inputs.md
@@ -18,7 +18,9 @@ Strictly opt-in: a new `algorithmsAllowed?: AlgorithmGrant[]` field on `ConnectO
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).
-Inaugural algorithm: `program-scoped-address-blind`. Inputs (dapp-provided): `{ "domain-separator": field }`. Output type: `address`. Valid input slot positions: `address`, `group`, `scalar`, `field`. The output is a per-program blinded address whose link to the active address is hidden by a BHP256 commitment.
+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.
diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts
index 4c25e91..7f1f6f8 100644
--- a/packages/aleo-types/src/transaction.ts
+++ b/packages/aleo-types/src/transaction.ts
@@ -136,7 +136,7 @@ export interface AlgorithmArg {
/** A per-arg grant constraint: a fixed allowlist of acceptable values, or "any". */
export type ArgConstraint = string[] | 'any';
-const BLINDING_ARGS = {
+const PROGRAM_SCOPED_ARGS = {
mode: { type: 'string' as ArgType, possibleValues: ['issue', 'resolve'] as const },
membershipProgram: { type: 'string' as ArgType },
membershipMapping: { type: 'string' as ArgType },
@@ -151,12 +151,12 @@ const BLINDING_ARGS = {
*/
export const ALGORITHM_SCHEMAS = {
'program-scoped-blinding-factor': {
- args: BLINDING_ARGS,
+ args: PROGRAM_SCOPED_ARGS,
outputType: 'field' as LiteralType,
validSlotTypes: ['field', 'scalar', 'group'] as LiteralType[],
},
'program-scoped-blinded-address': {
- args: BLINDING_ARGS,
+ args: PROGRAM_SCOPED_ARGS,
outputType: 'address' as LiteralType,
validSlotTypes: ['address', 'group', 'scalar', 'field'] as LiteralType[],
},
diff --git a/packages/aleo-wallet-adaptor/core/src/adapter.ts b/packages/aleo-wallet-adaptor/core/src/adapter.ts
index 6910361..d5dc02b 100644
--- a/packages/aleo-wallet-adaptor/core/src/adapter.ts
+++ b/packages/aleo-wallet-adaptor/core/src/adapter.ts
@@ -391,7 +391,7 @@ export function validateInputRequests(inputs: TransactionInput[]): void {
}
if (typeof (arg as { type?: unknown }).type !== 'string') {
throw new WalletInputRequestInvalidError(
- `inputs[${i}]: args["${argName}"].type must be a LiteralType string.`,
+ `inputs[${i}]: args["${argName}"].type must be an ArgType string (a LiteralType or "string").`,
);
}
if (typeof (arg as { value?: unknown }).value !== 'string') {
From 841b71ee14673dc8798c03d573ef3bc88881efe7 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Tue, 2 Jun 2026 03:50:00 -0500
Subject: [PATCH 16/18] fix(example): honor ALGORITHM_SCHEMAS
optional/possibleValues in derived-input form
buildInputs now omits blank optional args (so issue-mode works without a
targetAddress) and the arg form renders possibleValues (e.g. mode) as a select
instead of a free-text field with a misleading numeric-literal placeholder.
---
.../components/functions/PrivateInputs.tsx | 79 ++++++++++++++-----
1 file changed, 59 insertions(+), 20 deletions(-)
diff --git a/examples/react-app/src/components/functions/PrivateInputs.tsx b/examples/react-app/src/components/functions/PrivateInputs.tsx
index 7e0cbe5..f296879 100644
--- a/examples/react-app/src/components/functions/PrivateInputs.tsx
+++ b/examples/react-app/src/components/functions/PrivateInputs.tsx
@@ -204,9 +204,17 @@ function buildInputs(
}
const schema = ALGORITHM_SCHEMAS[state.derivedAlgorithm];
const args: Record = {};
- for (const [argName, argSchema] of Object.entries(schema.args)) {
+ for (const [argName, rawSchema] of Object.entries(schema.args)) {
+ const argSchema = rawSchema as {
+ type: AlgorithmArg['type'];
+ possibleValues?: readonly string[];
+ optional?: boolean;
+ };
const raw = (state.derivedArgs[argName] ?? '').trim();
if (!raw) {
+ // Optional args (e.g. `targetAddress`, which `issue` mode doesn't use)
+ // are omitted when left blank; only required args must be present.
+ if (argSchema.optional) continue;
throw new Error(
`slot ${i} (${slot.name}) — derived arg "${argName}" (${argSchema.type}) is empty`,
);
@@ -782,25 +790,56 @@ export function PrivateInputs() {
.
+ );
+ })}
)}
From 5ddded6ff9c0550b571677b9d9e815e20e991bc0 Mon Sep 17 00:00:00 2001
From: Michael Turner
Date: Tue, 2 Jun 2026 04:27:49 -0500
Subject: [PATCH 17/18] docs(quickstart): require recordname in record-slot
examples; add resolve-mode claim example
---
docs/dapp-privacy-quickstart.md | 44 ++++++++++++++++++++++++++++++---
1 file changed, 41 insertions(+), 3 deletions(-)
diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md
index 83ccb52..9cb49a9 100644
--- a/docs/dapp-privacy-quickstart.md
+++ b/docs/dapp-privacy-quickstart.md
@@ -104,7 +104,8 @@ await executeTransaction({
program: 'credits.aleo',
function: 'transfer_private',
inputs: [
- { type: 'record', program: 'credits.aleo', uid: chosen.uid! }, // pin by uid
+ // 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
],
@@ -113,6 +114,7 @@ await executeTransaction({
### 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.
@@ -128,7 +130,7 @@ await executeTransaction({
program: 'credits.aleo',
function: 'transfer_private',
inputs: [
- { type: 'record', program: 'credits.aleo', filters },
+ { type: 'record', program: 'credits.aleo', recordname: 'credits', filters },
{ type: 'address' },
'100u64',
],
@@ -192,7 +194,43 @@ await executeTransaction({
});
```
-For a claim (`claim_swap_output_private`), use `mode: { type: 'string', value: 'resolve' }` and add `targetAddress: { type: 'address', value: 'aleo1…' }` (the public blinded address of the swap to claim) to both slots.
+### 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".
From 4f148d3d6fa1bfe7eee74c9717351b7fe755e504 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E2=80=9CDavid?=
Date: Tue, 2 Jun 2026 11:27:38 +0100
Subject: [PATCH 18/18] fix: adapter modal button was being disabled when
readAddress was false
---
.../react-ui/src/WalletMultiButton.tsx | 40 +++++++++++--------
1 file changed, 23 insertions(+), 17 deletions(-)
diff --git a/packages/aleo-wallet-adaptor/react-ui/src/WalletMultiButton.tsx b/packages/aleo-wallet-adaptor/react-ui/src/WalletMultiButton.tsx
index 4f98fe6..3da4c8f 100644
--- a/packages/aleo-wallet-adaptor/react-ui/src/WalletMultiButton.tsx
+++ b/packages/aleo-wallet-adaptor/react-ui/src/WalletMultiButton.tsx
@@ -10,7 +10,7 @@ import { useWallet } from '@provablehq/aleo-wallet-adaptor-react';
import { CheckIcon, CopyIcon, GenericWalletIcon } from './icons';
export const WalletMultiButton: FC = ({ children, ...props }) => {
- const { address, wallet, disconnect } = useWallet();
+ const { address, wallet, disconnect, connected } = useWallet();
const { setVisible } = useWalletModal();
const [copied, setCopied] = useState(false);
const [active, setActive] = useState(false);
@@ -19,9 +19,11 @@ export const WalletMultiButton: FC = ({ children, ...props }) => {
const base58 = useMemo(() => address?.toString(), [address]);
const content = useMemo(() => {
if (children) return children;
- if (!wallet || !base58) return null;
- return base58.slice(0, 4) + '..' + base58.slice(-4);
- }, [children, wallet, base58]);
+ if (!wallet) return null;
+ if (base58) return base58.slice(0, 4) + '..' + base58.slice(-4);
+ if (connected) return 'Connected';
+ return null;
+ }, [children, wallet, base58, connected]);
const copyAddress = useCallback(async () => {
if (base58) {
@@ -69,7 +71,9 @@ export const WalletMultiButton: FC = ({ children, ...props }) => {
}, [ref, closeDropdown]);
if (!wallet) return {children};
- if (!base58) return {children};
+ if (!connected && !base58) {
+ return {children};
+ }
return (