Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/wallet-specified-inputs.md
Original file line number Diff line number Diff line change
@@ -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 `<AleoWalletProvider>` 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.
172 changes: 172 additions & 0 deletions docs/adapter-privacy-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# 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;
Comment thread
iamalwaysuncomfortable marked this conversation as resolved.

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: "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`.
Comment thread
marshacb marked this conversation as resolved.

type RecordFilters = Record<string, RecordFieldFilter>; // 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:
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. Two gates consult `programs` via `programs.includes(target)`: `executeTransaction` at `AdapterService.ts:240` and `requestRecords` at `:494`. Permissions are scoped per-dapp via `siteInfo.origin`; every gated method runs `getMatchingConnectHistoryAndDecryptPermission` (`:412-437`) first, which loads exactly one `ConnectHistory` row keyed on `(origin, network, address)`.

### Proposed

Add 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 {
// ...existing fields...
decryptPermission: DecryptPermission; // unchanged
programs?: string[]; // unchanged — program-level gate for both transaction execution and record operations
readAddress?: boolean; // new — opt-in address withholding; default true
recordAccess?: RecordAccessGrant; // new — opt-in record/field narrowing
viewKeyExposure?: "DENY" | "PER_TX_PROMPT"; // new — default DENY
}

type RecordAccessGrant =
| { level: "none" }
| { level: "byProgram"; programs: ProgramGrant[] };

interface ProgramGrant {
program: string;
records?: RecordGrant[]; // undefined → all records of this program; present → only the listed records
}

interface RecordGrant {
recordname: string;
fields?: FieldGrant[]; // undefined → all fields; present → only the listed fields
}

interface FieldGrant {
name: string;
readAccess?: boolean; // undefined → true; false → field is usable as a filter key but plaintext is withheld on decrypt
}
```

| 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

When `recordAccess` is set, these rules apply on top of the unchanged `programs` allowlist:

1. **Subset constraint**: every `recordAccess.programs[].program` must appear in `programs`. Connect-time validation rejects mismatches.
2. **Programs without record grants lose record access**: a program in `programs` but not in `recordAccess.programs[]` keeps transaction-execution access (literal inputs only). It cannot be queried via `requestRecords` and cannot be the target of a `type: "record"` request.
3. **Record narrowing**: when `ProgramGrant.records` is present, only the listed `recordname`s of that program are accessible. `undefined` → all records.
4. **Field narrowing**: when `RecordGrant.fields` is present, only the listed field names may be referenced as filter keys in a `type: "record"` request. `undefined` → all fields. Filter keys outside the listed fields are a permission error at the gate. Plaintext exposure is further controlled by `FieldGrant.readAccess`: when `readAccess` is `true` (or omitted, which defaults to `true`) the field's plaintext value is included in `requestRecords` decrypt output; when `false` the field remains usable as a filter key but its plaintext is redacted from decrypt results.
5. **`level: "none"`** refuses all record operations regardless of `programs`. Transaction execution with literal inputs is unaffected.

### Address exposure

`readAddress?: boolean` controls whether the dapp learns the user's address. Defaults to `true` (undefined treated as `true`); `false` is opt-in for privacy-preserving dapps.

#### `readAddress: undefined | true` — current behavior, with notification

The dapp learns the address through the same paths as today. The only change is **UX**: the connect dialog adds an explicit line item disclosing that the dapp will see the address. The user already approves the connection itself; this surfaces what the approval implies. No API or return-type change.

#### `readAddress: false` — withholding

The dapp transacts on the user's behalf without learning the address. Every direct-exposure path is closed and every operation that would let the dapp enumerate, decrypt, or otherwise derive the address from wallet-mediated state is refused.

| Surface | Behavior under `readAddress: false` |
|---|---|
| `connect()` return value (`Account.address`) | `""` (empty string) — type stays `string` |
| `provider._publicKey` getter | returns `""` regardless of connection state |
| `init` message handler | must 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 |
| `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`.

## Fulfillment flow

```mermaid
flowchart TD
A["dapp: executeTransaction<br/>inputs: Input[]"] --> B["validation.ts<br/>schema accepts string | InputRequest"]
B --> C{"AdapterService<br/>permission gate"}
C -- "violates recordAccess<br/>or viewKeyExposure" --> X["error to dapp"]
C -- ok --> D["ExecuteTransaction page"]
D --> R["fulfillInputRequests.ts"]
R -- "type: record" --> R1["filter unspent records<br/>by where clause"]
R -- "type: viewKey" --> R2["derive value via SDK"]
R -- "type: user" --> R3["render typed form<br/>from program signature"]
R1 --> F["confirm screen<br/>shows every fulfilled value"]
R2 --> F
R3 --> F
F -- user confirms --> G["initializeGenericTransaction<br/>(fulfilled string[], lockedRecords)"]
G ===> H["worker.ts → SDK<br/>UNCHANGED"]

classDef boundary stroke-dasharray: 5 5;
class H boundary;
```

The worker boundary still receives `string[]`. All fulfillment is wallet-side; the SDK call sites and the `imports` path are untouched.

## Failure modes

| Request | Condition | Result |
|---|---|---|
| `type: "record"` | zero matches for `where` | fail loudly (matches `imports` precedent) |
| `type: "record"` | filter key does not resolve to a field in the record's signature (including dotted struct paths) | validation error before gate |
| `type: "record"` | operator illegal for the field's Aleo type (e.g. `gte` on `boolean`) | validation error before gate |
| `type: "record"` | filter value does not parse as a literal of the field's Aleo type | validation error before gate |
| `type: "record"` | `gte` and `lte` form an empty range, or `eq` contradicts `neq` | validation error before gate |
| `type: "record"` | field outside `ProgramGrant.fields` | permission error at gate |
| `type: "user"` | parameter declared `.public` | fulfillment error before prompting |
| `type: "viewKey"` | `viewKeyExposure: "DENY"` | permission error at gate |
60 changes: 58 additions & 2 deletions packages/aleo-types/src/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,61 @@ export interface Transaction {
data?: Record<string, unknown>;
}

/**
* 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<string, RecordFieldFilter>;

/**
* 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
*/
Expand All @@ -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
Expand Down
Loading