From 0dc4fb98d536ab1749abef70f62d520798048e50 Mon Sep 17 00:00:00 2001 From: Mike Turner Date: Thu, 30 Apr 2026 12:40:16 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 + ); +}