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 new file mode 100644 index 0000000..8aa036e --- /dev/null +++ b/docs/adapter-privacy-extension.md @@ -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; + +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`. + +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: +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
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 -- "type: record" --> R1["filter unspent records
by where clause"] + R -- "type: viewKey" --> R2["derive value via SDK"] + R -- "type: user" --> R3["render typed form
from program signature"] + R1 --> F["confirm screen
shows every fulfilled value"] + R2 --> F + R3 --> F + F -- user confirms --> G["initializeGenericTransaction
(fulfilled string[], lockedRecords)"] + G ===> H["worker.ts → SDK
UNCHANGED"] + + 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 | 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 + ); +}