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 ( +
+
+ + {(rec.programName as string | undefined) ?? '?'}. + {(rec.recordName as string | undefined) ?? '?'} · uid={uid} + + {(rec.spent as boolean | undefined) ? ( + spent + ) : ( + unspent + )} +
+
+

recordView.fields

+ {Object.keys(fields).length === 0 ? ( +

(empty — no fields granted, or undecrypted)

+ ) : ( +
    + {Object.entries(fields).map(([k, v]) => ( +
  • + {k}: {String(v)} +
  • + ))} +
+ )} +
+
+

envelope metadata present

+ {envelope.length === 0 ? ( +

(all stripped)

+ ) : ( +
    + {envelope.map(([k, v]) => ( +
  • + {k}: {typeof v === 'string' ? v : JSON.stringify(v)} +
  • + ))} +
+ )} +
+
+ ); + }; + + const renderPrimitiveSlot = (slot: Extract, i: number) => { + const state = slotStates[i]; + if (!state || state.kind !== 'primitive') return null; + const modes = primitiveSlotModes(slot.baseType); + return ( +
+ + {modes.length > 1 && ( +
+ {modes.map(m => ( + + ))} +
+ )} + {state.mode === 'literal' ? ( + updateSlot(i, { value: e.target.value })} + /> + ) : ( +

+ {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 ( +
+ +
+ {(['plaintext', 'pick', 'filter'] as RecordSlotMode[]).map(m => ( + + ))} +
+ + {state.mode === 'plaintext' && ( +
+