diff --git a/.changeset/derived-inputs.md b/.changeset/derived-inputs.md new file mode 100644 index 0000000..bea36b7 --- /dev/null +++ b/.changeset/derived-inputs.md @@ -0,0 +1,25 @@ +--- +'@provablehq/aleo-types': minor +'@provablehq/aleo-wallet-standard': minor +'@provablehq/aleo-wallet-adaptor-core': minor +'@provablehq/aleo-wallet-adaptor-leo': minor +'@provablehq/aleo-wallet-adaptor-fox': minor +'@provablehq/aleo-wallet-adaptor-soter': minor +'@provablehq/aleo-wallet-adaptor-puzzle': minor +'@provablehq/aleo-wallet-adaptor-shield': minor +'@provablehq/aleo-wallet-adaptor-react': minor +--- + +Add `type: "derived"` InputRequest for wallet-evaluated cryptographic algorithms + +A new `InputRequest` variant lets a dapp ask the wallet to compute a value by running a named cryptographic algorithm over the wallet's own state (view key, wallet-maintained counters, etc.) plus dapp-supplied `args`, and substitute the result into a transaction input slot. The dapp never observes the wallet-side inputs — only the output. + +Strictly opt-in: a new `algorithmsAllowed?: AlgorithmGrant[]` field on `ConnectOptions` authorizes derived inputs at exact `(algorithm, program, function, inputPosition)` call sites. All four fields are required and exact-match; there is no broad default. The wallet refuses every derived request whose tuple is not present. + +A new adapter method `algorithmsSupported(): Promise` lets a dapp discover which algorithms a wallet implements before populating `algorithmsAllowed`. Wallets without derived-input support return `[]` (the base implementation's default). + +Inaugural algorithm: `program-scoped-address-blind`. Inputs (dapp-provided): `{ "domain-separator": field }`. Output type: `address`. Valid input slot positions: `address`, `group`, `scalar`, `field`. The output is a per-program blinded address whose link to the active address is hidden by a BHP256 commitment. + +The `` React component accepts a new optional `algorithmsAllowed` prop and forwards it on connect; the `useWallet()` context exposes `algorithmsSupported`. Existing usages without these are unaffected. + +See `docs/adapter-privacy-extension.md` § "Derived inputs" for the full spec, and `docs/dapp-privacy-quickstart.md` for an implementor's guide. diff --git a/docs/adapter-privacy-extension.md b/docs/adapter-privacy-extension.md index 7d04ed5..c73966d 100644 --- a/docs/adapter-privacy-extension.md +++ b/docs/adapter-privacy-extension.md @@ -2,7 +2,7 @@ ## Goal -Let dapps emit `TransactionOptions` whose `inputs` slots are not always literal Aleo values. Each non-literal slot is a **request** to the wallet — to prompt the user or to auto-select an owned record matching dapp-supplied criteria. The wallet fulfills the request before passing the transaction to the SDK. +Let dapps emit `TransactionOptions` whose `inputs` slots are not always literal Aleo values. Each non-literal slot is a **request** to the wallet — to prompt the user, to auto-select an owned record matching dapp-supplied criteria, or to compute a value by running a named cryptographic algorithm over the user's wallet state. The wallet fulfills the request before passing the transaction to the SDK. ## Wire-level types @@ -11,7 +11,8 @@ type Input = string | InputRequest; type InputRequest = | { type: "address"; label?: string } // Specification to fill the input field with the active address. Allowed in an input position with an aleo type of: `address, group, scalar, or field`. - | { type: "record"; program: string; filters?: RecordFilters; uid?: string }; // Specification to use a record from a specific program. When `uid` is present, it pins the exact record previously returned by `requestRecords` and `filters` is ignored. When absent, the wallet picks any unspent record matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`. + | { type: "record"; program: string; filters?: RecordFilters; uid?: string } // Specification to use a record from a specific program. When `uid` is present, it pins the exact record previously returned by `requestRecords` and `filters` is ignored. When absent, the wallet picks any unspent record matching `filters`. Allowed in an input position with an aleo type of: `record, dynamic_record, or external_record`. + | { type: "derived"; algorithm: AlgorithmName; args: Record; label?: string }; // Specification to fill the input field with the output of a wallet-evaluated cryptographic algorithm. Each algorithm declares its expected `args` schema and its output Aleo type; the output type determines which input positions are valid. Strictly opt-in — the wallet refuses every derived request whose (algorithm, program, function, inputPosition) tuple is not in the connection's `algorithmsAllowed`. See "Derived inputs" below. type RecordFilters = Record; // keys are top-level record field names or dotted paths into struct fields, e.g. "amount" or "data.amount". type RecordFieldFilter = { eq?: string, gte?: string, lte?: string, neq?: string, }; // potential matching conditions, AND-combined. @@ -19,6 +20,23 @@ type RecordFieldFilter = { eq?: string, gte?: string, lte?: string, neq?: string interface RecordView { fields: Record; // parsed structured form of the record's plaintext. Only fields the dapp has read access to are present; redacted fields are omitted (not present-with-undefined). } + +// Re-exports the existing LiteralType enum from `@provablehq/aleo-types` (`packages/aleo-types/src/data.ts`). +type LiteralType = + | "address" | "bool" | "group" + | "u8" | "u16" | "u32" | "u64" | "u128" + | "i8" | "i16" | "i32" | "i64" | "i128" + | "field" | "scalar" | "signature"; + +// New algorithms are added to this literal union as they're standardized. The `(string & {})` permits +// unknown values for forward-compat — the wallet validates against its own `algorithmsSupported()` list at runtime. +type KnownAlgorithm = "program-scoped-address-blind"; +type AlgorithmName = KnownAlgorithm | (string & {}); + +interface AlgorithmArg { + type: LiteralType; // parsing directive — the wallet decodes `value` according to this Aleo primitive type + value: string; // an Aleo literal in canonical string form (e.g. "12345field", "100u64", "true") +} ``` `requestRecords` continues to return its existing wallet-defined record shape (e.g. Shield's `OwnedRecord` at `shield-extension/src/background/types/RecordScanner.ts:83`). Two optional fields are added additively to each returned record: @@ -33,6 +51,7 @@ The remaining (legacy) fields — including `recordPlaintext`, `commitment`, `ta The `InputRequest` sends a request to the wallet (which is then authorized by the user) to do the following: 1. Input the user's address into a position where there's an address, group, scalar, or field input. 2. Use a record whose fields match the `filters` on specific record's members and filter for records that match them if applicable, returning an error if the condition cannot be applied or a record matching it cannot be found. +3. Run a named cryptographic algorithm over the wallet's state (view key, program-scoped counters, etc.) plus dapp-supplied arguments, and use the output as input. Authorized only at exact `(algorithm, program, function, inputPosition)` call sites. The wallet has the program's source, so it reads a function's parameter signature for input position `i` and renders the form control accordingly. `label` is UX-only. @@ -55,6 +74,7 @@ interface ConnectHistory { programs?: string[]; // unchanged — program-level gate for both transaction execution and record operations readAddress?: boolean; // new — opt-in address withholding; default true recordAccess?: RecordAccessGrant; // new — opt-in record/field narrowing + algorithmsAllowed?: AlgorithmGrant[]; // new — strict opt-in derived-input allowlist; default undefined → every `type: "derived"` request is refused } type RecordAccessGrant = @@ -75,6 +95,16 @@ interface FieldGrant { name: string; // a record-body field name (e.g. "amount", "data.amount"), or a `$`-prefixed envelope-metadata name from the reserved set: `$commitment`, `$tag`, `$transitionId`, `$transactionId`, `$outputIndex`, `$transactionIndex`, `$transitionIndex`, `$owner`, `$sender`. The `$` prefix prevents collision with any body field named identically. readAccess?: boolean; // undefined → true; false → field is usable as a filter key but plaintext is withheld on decrypt } + +// Each grant authorizes one specific call site. All four fields are required and exact-match; +// there is no wildcard. A dapp that wants to use the same algorithm at multiple call sites lists +// each one as its own entry. +interface AlgorithmGrant { + algorithm: AlgorithmName; // must appear in the wallet's `algorithmsSupported()` list + program: string; // must also appear in `programs` + function: string; // exact transition name within `program` + inputPosition: number; // 0-based index into the function's input slots +} ``` | Configuration | Meaning | @@ -121,6 +151,55 @@ Rationale: when the dapp asks for narrow field access, it has explicitly given u Under `readAddress: false`, the strip rules tighten further independently of the grant: `owner`, `sender`, `commitment`, `tag`, `transitionId`, `transactionId`, and `recordPlaintext` are always omitted, and any `recordView.fields` whose Aleo type is `address` and whose plaintext equals the active address is omitted. `uid` and `recordView` (with non-address fields) remain the dapp's only handles to the record. +### Derived inputs + +A `type: "derived"` request asks the wallet to compute a value by running a named cryptographic algorithm over the wallet's own state (the view key, a per-(origin, program) counter, etc.) combined with the dapp's `args`, then substitute the result into the input slot. The dapp never observes the wallet-side inputs — only the final output. + +Derived inputs are **strictly opt-in**: the wallet refuses every derived request whose `(algorithm, program, function, inputPosition)` tuple is not present in `algorithmsAllowed`. There is no broad default. The four fields are required and exact-match — a grant for `(algorithm: X, program: "p.aleo", function: "f", inputPosition: 0)` does not authorize the same algorithm at `inputPosition: 1`, at a different function, or at a different program. A dapp that wants the same algorithm at multiple call sites lists each one as its own entry. + +#### Discovery + +Adapters expose `algorithmsSupported(): Promise`. A dapp calls this before connect to learn which algorithms a wallet implements, then requests a matching subset in `algorithmsAllowed`. Wallets that don't implement derived inputs at all return an empty array or throw `MethodNotImplementedError`. + +Every `algorithmsAllowed[].algorithm` is validated at connect time against the wallet's `algorithmsSupported()`. Unknown names are rejected. + +#### Output type and slot compatibility + +Each `KnownAlgorithm` has a fixed Aleo output type, declared in the catalog below. The wallet additionally validates at execute time that the function's signature at `inputs[i]` is a valid position for that output type — same rules as `type: "address"` (e.g. an `address`-typed output is valid in `address` / `group` / `scalar` / `field` slots). + +#### Algorithm catalog + +##### `program-scoped-address-blind` + +Produces a blinded address scoped to a specific program. Two dapps using the same wallet against different programs derive different blinded addresses. Whether the same dapp derives the same blinded address across executions is governed by wallet-internal state; the dapp can neither control nor observe that state. + +| Property | Value | +|---|---| +| `args` (dapp-provided) | `{ "domain-separator": AlgorithmArg }` | +| wallet-derived inputs | program address (as field), active view key (as field), wallet-maintained counter (as field) | +| output type | `address` | +| valid input slot positions | `address`, `group`, `scalar`, `field` | + +Algorithm (pseudo, matching the wallet's reference implementation): + +``` +r = Poseidon4.hashToScalar([programAddrField, domainSeparatorField, viewKeyField, counterField]) +blinded = BHP256.commitToGroup(signerGroup.toBitsLE(), r) +result = Address.fromGroup(blinded) +``` + +The dapp never observes the view key, the counter, or the intermediate scalar `r`. The output is an address whose link to the active address is hidden by the BHP256 commitment — recovering the active address from the output is computationally infeasible without `r`. + +Future algorithms are added to `KnownAlgorithm` and documented under their own catalog subsection in this spec. + +#### Interaction with `readAddress: false` + +Derived inputs are allowed under `readAddress: false`. The dapp does not learn the active address through the derived flow — it only sees the algorithm's output. For `program-scoped-address-blind` the output is itself a fresh blinded address, so it does not leak the active address even when the dapp inspects the resulting transaction. Algorithms whose output is the active address itself (or trivially reversible to it) must not be admitted to `KnownAlgorithm` for this reason. + +#### Interaction with `programs` + +`algorithmsAllowed[].program` must appear in `programs`. The wallet rejects mismatches at connect time. This mirrors the subset constraint already enforced for `recordAccess.programs[].program`. + ### Address exposure `readAddress?: boolean` controls whether the dapp learns the user's address. Defaults to `true` (undefined treated as `true`); `false` is opt-in for privacy-preserving dapps. @@ -165,8 +244,10 @@ flowchart TD D --> R["fulfillInputRequests.ts"] R -- "type: record" --> R1["filter unspent records
by where clause"] R -- "type: user" --> R3["render typed form
from program signature"] + R -- "type: derived" --> R4["run algorithm via SDK
using wallet-derived secrets"] R1 --> F["confirm screen
shows every fulfilled value"] R3 --> F + R4 --> F F -- user confirms --> G["initializeGenericTransaction
(fulfilled string[], lockedRecords)"] G ===> H["worker.ts → SDK
UNCHANGED"] @@ -195,3 +276,10 @@ The worker boundary still receives `string[]`. All fulfillment is wallet-side; t | `connect()` | `recordAccess.programs[].program` not present in `programs` | connect-time validation error | | `requestRecords` | called against a program in `programs` but absent from `recordAccess.programs[]` (when `recordAccess` is set) | permission error at gate | | `requestRecords` | `includePlaintext: true` while `decryptPermission: NoDecrypt` | permission error at gate (today's behavior, restated) | +| `type: "derived"` | `(algorithm, program, function, inputPosition)` tuple not in `algorithmsAllowed` | permission error at gate | +| `type: "derived"` | `algorithm` not in the wallet's `algorithmsSupported()` list | permission error at gate (also caught at connect time if the dapp listed it in `algorithmsAllowed`) | +| `type: "derived"` | `args` missing a key required by the algorithm's schema, has an extra key, or an `AlgorithmArg.type` mismatches the algorithm's expected type for that key | validation error before gate | +| `type: "derived"` | `AlgorithmArg.value` does not parse as a literal of `AlgorithmArg.type` | validation error before gate | +| `type: "derived"` | algorithm output type incompatible with the function signature at `inputs[i]` (e.g. an `address`-producing algorithm used in a `u64` slot) | validation error before gate | +| `connect()` | `algorithmsAllowed[].program` not present in `programs` | connect-time validation error | +| `connect()` | `algorithmsAllowed[].algorithm` not in the wallet's `algorithmsSupported()` | connect-time validation error | diff --git a/docs/dapp-privacy-quickstart.md b/docs/dapp-privacy-quickstart.md index ebf327c..c4a47a2 100644 --- a/docs/dapp-privacy-quickstart.md +++ b/docs/dapp-privacy-quickstart.md @@ -4,18 +4,20 @@ How to use the wallet-adapter's new privacy features from a dapp. For the full s ## What's new -Two connect-time grants and two transaction-input request types: +Three connect-time grants and three transaction-input request types: | Grant | Type | Effect | |---|---|---| | `readAddress` | `boolean` (default `true`) | When `false`, the dapp transacts without learning the active address. Requires `decryptPermission: NoDecrypt`. | | `recordAccess` | `RecordAccessGrant` (default `undefined` = broad) | Per-program / per-record / per-field narrowing of record reads. | +| `algorithmsAllowed` | `AlgorithmGrant[]` (default `undefined` = none) | Strict opt-in allowlist for `type: "derived"` requests. Each entry authorizes one exact `(algorithm, program, function, inputPosition)` call site. | | `InputRequest` slot type | Shape | Valid in | |---|---|---| | `{ type: "address" }` | wallet injects active address | `address`, `group`, `scalar`, `field` | | `{ type: "record", program, uid }` | pin specific record by handle | `record`, `dynamic_record`, `external_record` | | `{ type: "record", program, filters }` | wallet auto-selects matching record | same | +| `{ type: "derived", algorithm, args }` | wallet runs a named crypto algorithm | depends on algorithm — see catalog | ## Wiring connect-time options @@ -133,6 +135,49 @@ await executeTransaction({ }); ``` +## Derived inputs (`type: "derived"`) + +A `type: "derived"` slot tells the wallet to compute a value by running a named cryptographic algorithm over its own state (view key, wallet-maintained counters, etc.) plus your `args`, and substitute the result. **You never see the wallet-side inputs — only the output.** + +Strictly opt-in via `algorithmsAllowed` at connect time. Each grant authorizes exactly one `(algorithm, program, function, inputPosition)` call site: + +```ts +import { ALGORITHM_SCHEMAS } from '@provablehq/aleo-types'; + + +``` + +Discovery: call `useWallet().algorithmsSupported()` (no connection required) to see which algorithms the active adapter implements. Wallets without derived-input support return `[]`. + +At execute time, pass `{ type: "derived", algorithm, args }` in the matching slot: + +```ts +await executeTransaction({ + program: 'myapp.aleo', + function: 'vote', + inputs: [ + { type: 'derived', + algorithm: 'program-scoped-address-blind', + args: { + // Pre-encode anything non-primitive to an Aleo literal; the wallet only + // accepts AlgorithmArg values that are LiteralType-parseable strings. + 'domain-separator': { type: 'field', value: '12345field' }, + }, + label: 'Your private voter handle', + }, + // ...rest of the function's inputs + ], +}); +``` + +`ALGORITHM_SCHEMAS` from `@provablehq/aleo-types` ships the args schema, output type, and valid slot positions for every known algorithm — use it to render correct forms or pre-validate shapes. Full algorithm catalog: see [`adapter-privacy-extension.md`](./adapter-privacy-extension.md) § "Algorithm catalog". + ## Error classes Imported from `@provablehq/aleo-wallet-adaptor-core`: @@ -152,3 +197,4 @@ If your existing dapp: - **Reads records via `requestRecords`** — return shape is unchanged when no grant is set. The new `recordView` / `uid` fields are additive optional keys you can ignore. - **Composes `TransactionOptions.inputs` as plain strings** — keep doing that. The new `InputRequest` shapes are opt-in per-slot. - **Wants to adopt narrowed grants** — populate `recordAccess` at the provider level; use `RecordGrant.fields: []` to mint records usable purely for `uid` pinning with zero plaintext leakage. +- **Wants to use derived inputs** — populate `algorithmsAllowed` with one grant per call site, then place `{ type: "derived", algorithm, args }` in the corresponding `inputs[i]`. There is no broad default — empty `algorithmsAllowed` refuses every derived request. diff --git a/examples/react-app/src/App.tsx b/examples/react-app/src/App.tsx index da6d818..1cfd6b0 100644 --- a/examples/react-app/src/App.tsx +++ b/examples/react-app/src/App.tsx @@ -10,6 +10,7 @@ import { toast, Toaster } from 'sonner'; import { ThemeProvider } from 'next-themes'; import { useAtomValue } from 'jotai'; import { + algorithmsAllowedAtom, autoConnectAtom, decryptPermissionAtom, networkAtom, @@ -41,6 +42,7 @@ export function App() { const programs = useAtomValue(programsAtom); const recordAccess = useAtomValue(recordAccessAtom); const readAddress = useAtomValue(readAddressAtom); + const algorithmsAllowed = useAtomValue(algorithmsAllowedAtom); return ( @@ -53,6 +55,7 @@ export function App() { programs={programs} recordAccess={recordAccess} readAddress={readAddress} + algorithmsAllowed={algorithmsAllowed} > diff --git a/examples/react-app/src/components/functions/PrivateInputs.tsx b/examples/react-app/src/components/functions/PrivateInputs.tsx index 6cf537f..6438027 100644 --- a/examples/react-app/src/components/functions/PrivateInputs.tsx +++ b/examples/react-app/src/components/functions/PrivateInputs.tsx @@ -22,6 +22,10 @@ import { toast } from 'sonner'; import { useWallet } from '@provablehq/aleo-wallet-adaptor-react'; import { useWalletModal } from '@provablehq/aleo-wallet-adaptor-react-ui'; import { + ALGORITHM_SCHEMAS, + AlgorithmArg, + AlgorithmName, + KnownAlgorithm, Network, RecordEnvelope, RecordFieldFilter, @@ -30,6 +34,7 @@ import { TransactionStatus, } from '@provablehq/aleo-types'; import { + AlgorithmGrant, FieldGrant, ProgramGrant, RecordAccessGrant, @@ -38,6 +43,7 @@ import { import { CodePanel } from '../CodePanel'; import { codeExamples, PLACEHOLDERS } from '@/lib/codeExamples'; import { + algorithmsAllowedAtom, decryptPermissionAtom, readAddressAtom, recordAccessAtom, @@ -61,9 +67,15 @@ type ParsedSlot = }; type RecordSlotMode = 'plaintext' | 'pick' | 'filter'; -type PrimitiveSlotMode = 'literal' | 'address'; +type PrimitiveSlotMode = 'literal' | 'address' | 'derived'; type SlotState = - | { kind: 'primitive'; mode: PrimitiveSlotMode; value: string } + | { + kind: 'primitive'; + mode: PrimitiveSlotMode; + value: string; + derivedAlgorithm: KnownAlgorithm | ''; + derivedArgs: Record; // arg name → user-typed value (parsed lazily at submit) + } | { kind: 'record'; mode: RecordSlotMode; @@ -82,9 +94,18 @@ const ADDRESS_REQUEST_ALLOWED = new Set(['address', 'group', 'scalar', 'field']) function primitiveSlotModes(baseType: string): PrimitiveSlotMode[] { const modes: PrimitiveSlotMode[] = ['literal']; if (ADDRESS_REQUEST_ALLOWED.has(baseType)) modes.push('address'); + // Derived is offered when at least one known algorithm's `validSlotTypes` + // includes this baseType. Grant validation happens wallet-side at execute. + if (eligibleAlgorithmsForBaseType(baseType).length > 0) modes.push('derived'); return modes; } +function eligibleAlgorithmsForBaseType(baseType: string): KnownAlgorithm[] { + return (Object.keys(ALGORITHM_SCHEMAS) as KnownAlgorithm[]).filter(name => + (ALGORITHM_SCHEMAS[name].validSlotTypes as readonly string[]).includes(baseType), + ); +} + function parseTypeExpr(name: string, typeExpr: string): ParsedSlot | null { const lastDot = typeExpr.lastIndexOf('.'); if (lastDot < 0) return null; @@ -142,7 +163,7 @@ function defaultSlotState(slot: ParsedSlot, fallbackProgram: string): SlotState if (slot.kind === 'primitive') { // Default address-typed slots to wallet-provided active address (privacy-preserving default). const mode: PrimitiveSlotMode = slot.baseType === 'address' ? 'address' : 'literal'; - return { kind: 'primitive', mode, value: '' }; + return { kind: 'primitive', mode, value: '', derivedAlgorithm: '', derivedArgs: {} }; } const slotProgram = slot.program || fallbackProgram; const isCredits = slotProgram === 'credits.aleo' && slot.recordname === 'credits'; @@ -177,6 +198,23 @@ function buildInputs( if (!state) throw new Error(`slot ${i} (${slot.name}) has no state`); if (state.kind === 'primitive') { if (state.mode === 'address') return { type: 'address' }; + if (state.mode === 'derived') { + if (!state.derivedAlgorithm) { + throw new Error(`slot ${i} (${slot.name}) — pick an algorithm for the derived input`); + } + const schema = ALGORITHM_SCHEMAS[state.derivedAlgorithm]; + const args: Record = {}; + for (const [argName, argSchema] of Object.entries(schema.args)) { + const raw = (state.derivedArgs[argName] ?? '').trim(); + if (!raw) { + throw new Error( + `slot ${i} (${slot.name}) — derived arg "${argName}" (${argSchema.type}) is empty`, + ); + } + args[argName] = { type: argSchema.type, value: raw }; + } + return { type: 'derived', algorithm: state.derivedAlgorithm as AlgorithmName, args }; + } if (!state.value.trim()) { throw new Error(`slot ${i} (${slot.name}: ${slot.raw}) is empty`); } @@ -271,6 +309,7 @@ export function PrivateInputs() { const { setVisible: openWalletModal } = useWalletModal(); const [, setRecordAccess] = useAtom(recordAccessAtom); const [readAddress, setReadAddress] = useAtom(readAddressAtom); + const [algorithmsAllowed, setAlgorithmsAllowed] = useAtom(algorithmsAllowedAtom); const [decryptPermission, setDecryptPermission] = useAtom(decryptPermissionAtom); const [form, setForm] = useState(DEFAULTS); @@ -407,6 +446,7 @@ export function PrivateInputs() { const clearGrantAndDisconnect = async () => { setRecordAccess(undefined); setReadAddress(undefined); + setAlgorithmsAllowed(undefined); toast.success('All grants cleared. Reconnect to apply (broad legacy behavior restored).'); if (connected) { try { @@ -477,7 +517,12 @@ export function PrivateInputs() { }; const handleExecute = async () => { - console.log('[PrivateInputs] handleExecute: connected=', connected, 'readAddress=', readAddress); + console.log( + '[PrivateInputs] handleExecute: connected=', + connected, + 'readAddress=', + readAddress, + ); if (!connected) { openWalletModal(true); return; @@ -649,6 +694,8 @@ export function PrivateInputs() { const state = slotStates[i]; if (!state || state.kind !== 'primitive') return null; const modes = primitiveSlotModes(slot.baseType); + const eligibleAlgs = eligibleAlgorithmsForBaseType(slot.baseType); + const algSchema = state.derivedAlgorithm ? ALGORITHM_SCHEMAS[state.derivedAlgorithm] : null; return (
{modes.length > 1 && ( -
+
{modes.map(m => ( ))}
)} - {state.mode === 'literal' ? ( + {state.mode === 'literal' && ( updateSlot(i, { value: e.target.value })} /> - ) : ( + )} + {state.mode === 'address' && (

Sends {`{type:"address"}`}. The wallet fills the slot with the active address; the dapp never sees it.

)} + {state.mode === 'derived' && ( +
+
+ + +
+ {algSchema && ( +
+

+ Output type: {algSchema.outputType}. Sends{' '} + {`{type:"derived", algorithm, args}`}. Authorized only if a matching{' '} + algorithmsAllowed grant exists for{' '} + + {form.programName.trim()}/{form.functionName.trim()}@{i} + + . +

+ {Object.entries(algSchema.args).map(([argName, argSchema]) => ( +
+ + + updateSlot(i, { + derivedArgs: { ...state.derivedArgs, [argName]: e.target.value }, + }) + } + /> +
+ ))} +
+ )} +
+ )}
); }; @@ -912,6 +1028,141 @@ export function PrivateInputs() { )}
+ {/* Algorithm grants (derived inputs) */} +
+
+ +

+ Strict opt-in allowlist for {`type: "derived"`} InputRequests. Each + grant authorizes exactly one{' '} + {`(algorithm, program, function, inputPosition)`} call site. Empty list + → every derived request is refused. +

+
+
+ {(algorithmsAllowed ?? []).length === 0 && ( +

+ (no grants — derived inputs disabled) +

+ )} + {(algorithmsAllowed ?? []).map((g, gi) => ( +
+ + + setAlgorithmsAllowed(prev => + (prev ?? []).map((row, j) => + j === gi ? { ...row, program: e.target.value } : row, + ), + ) + } + /> + + setAlgorithmsAllowed(prev => + (prev ?? []).map((row, j) => + j === gi ? { ...row, function: e.target.value } : row, + ), + ) + } + /> + + setAlgorithmsAllowed(prev => + (prev ?? []).map((row, j) => + j === gi ? { ...row, inputPosition: Number(e.target.value) || 0 } : row, + ), + ) + } + /> + +
+ ))} +
+ + +
+
+
+ {/* Record grant intro */}

Record access: one entry per program. Records narrows to specific record @@ -1086,6 +1337,7 @@ export function PrivateInputs() { { recordAccess: { level: 'byProgram', programs: programGrants }, readAddress, + algorithmsAllowed, }, null, 2, diff --git a/examples/react-app/src/lib/store/global.ts b/examples/react-app/src/lib/store/global.ts index 00cc21d..6870f2e 100644 --- a/examples/react-app/src/lib/store/global.ts +++ b/examples/react-app/src/lib/store/global.ts @@ -1,6 +1,10 @@ import { atomWithStorage } from 'jotai/utils'; import { Network } from '@provablehq/aleo-types'; -import { DecryptPermission, RecordAccessGrant } from '@provablehq/aleo-wallet-adaptor-core'; +import { + AlgorithmGrant, + DecryptPermission, + RecordAccessGrant, +} from '@provablehq/aleo-wallet-adaptor-core'; /** * Adapter default values @@ -23,6 +27,10 @@ export const recordAccessAtom = atomWithStorage( undefined, ); export const readAddressAtom = atomWithStorage('readAddress', undefined); +export const algorithmsAllowedAtom = atomWithStorage( + 'algorithmsAllowed', + undefined, +); /** * UI state diff --git a/packages/aleo-types/src/index.ts b/packages/aleo-types/src/index.ts index df73003..e111564 100644 --- a/packages/aleo-types/src/index.ts +++ b/packages/aleo-types/src/index.ts @@ -1,3 +1,4 @@ export * from './account'; +export * from './data'; export * from './transaction'; export * from './network'; diff --git a/packages/aleo-types/src/transaction.ts b/packages/aleo-types/src/transaction.ts index f737dce..3bf05bb 100644 --- a/packages/aleo-types/src/transaction.ts +++ b/packages/aleo-types/src/transaction.ts @@ -1,3 +1,5 @@ +import { LiteralType } from './data'; + /** * Status of a transaction */ @@ -78,8 +80,70 @@ export type InputRequest = program: string; filters?: RecordFilters; uid?: string; + } + | { + /** + * Fill the input slot with the output of a wallet-evaluated cryptographic + * algorithm. The wallet runs the named `algorithm` over its own state + * (view key, wallet-maintained counters, etc.) plus the dapp's `args`, + * and substitutes the result into the slot. The dapp never observes the + * wallet-side inputs — only the output. + * + * Strictly opt-in: the wallet refuses every derived request whose + * `(algorithm, program, function, inputPosition)` tuple is not present + * in the connection's `algorithmsAllowed`. Each algorithm declares its + * `args` schema and output Aleo type; the output type determines which + * input positions are valid (same rules as `type: "address"`). + */ + type: 'derived'; + algorithm: AlgorithmName; + args: Record; + label?: string; }; +/** + * Algorithms that conforming wallets are expected to implement. The + * `(string & {})` extension permits unknown values for forward-compat: + * a wallet shipping a new algorithm before this union is updated can still + * be addressed. The wallet validates at runtime against its own + * `algorithmsSupported()` list. + */ +export type KnownAlgorithm = 'program-scoped-address-blind'; +export type AlgorithmName = KnownAlgorithm | (string & {}); + +/** + * One typed argument passed to a wallet-side cryptographic algorithm. The + * wallet parses `value` according to `type` (an Aleo primitive type) before + * invoking the algorithm. + */ +export interface AlgorithmArg { + type: LiteralType; + value: string; +} + +/** + * Static catalog of known algorithms — their dapp-provided `args` schema, the + * Aleo type of their output, and the input-slot positions where they are + * valid. The wallet is the source of truth at runtime; this registry lets the + * SDK and dapp tooling render correct forms and pre-validate shapes. + */ +export const ALGORITHM_SCHEMAS = { + 'program-scoped-address-blind': { + args: { + 'domain-separator': { type: 'field' as LiteralType }, + }, + outputType: 'address' as LiteralType, + validSlotTypes: ['address', 'group', 'scalar', 'field'] as LiteralType[], + }, +} as const satisfies Record< + KnownAlgorithm, + { + args: Record; + outputType: LiteralType; + validSlotTypes: LiteralType[]; + } +>; + /** * One element of a transaction's `inputs` array. A literal Aleo value (string) * or an `InputRequest` describing a value the wallet should supply. diff --git a/packages/aleo-wallet-adaptor/core/src/adapter.ts b/packages/aleo-wallet-adaptor/core/src/adapter.ts index fe9508e..6910361 100644 --- a/packages/aleo-wallet-adaptor/core/src/adapter.ts +++ b/packages/aleo-wallet-adaptor/core/src/adapter.ts @@ -306,6 +306,18 @@ export abstract class BaseAleoWalletAdapter } return feature.requestTransactionHistory(program); } + + /** + * Return the algorithm names this wallet implements for `type: "derived"` + * InputRequests. A dapp calls this before connect to learn which entries + * are valid in `ConnectOptions.algorithmsAllowed`. Wallets that do not + * support derived inputs at all should return `[]`. + * + * Override in adapters that support derived inputs. + */ + async algorithmsSupported(): Promise { + return []; + } } export function scopePollingDetectionStrategy(detect: () => boolean): void { @@ -360,5 +372,34 @@ export function validateInputRequests(inputs: TransactionInput[]): void { `inputs[${i}]: type "record" cannot specify both \`uid\` and \`filters\`. \`uid\` pins a specific record returned by requestRecords; filters are ignored when \`uid\` is set.`, ); } + if (input.type === 'derived') { + if (typeof input.algorithm !== 'string' || input.algorithm.length === 0) { + throw new WalletInputRequestInvalidError( + `inputs[${i}]: type "derived" requires a non-empty \`algorithm\` string.`, + ); + } + if (input.args === null || typeof input.args !== 'object' || Array.isArray(input.args)) { + throw new WalletInputRequestInvalidError( + `inputs[${i}]: type "derived" requires \`args\` to be an object (Record).`, + ); + } + for (const [argName, arg] of Object.entries(input.args)) { + if (arg === null || typeof arg !== 'object') { + throw new WalletInputRequestInvalidError( + `inputs[${i}]: args["${argName}"] must be { type, value }.`, + ); + } + if (typeof (arg as { type?: unknown }).type !== 'string') { + throw new WalletInputRequestInvalidError( + `inputs[${i}]: args["${argName}"].type must be a LiteralType string.`, + ); + } + if (typeof (arg as { value?: unknown }).value !== 'string') { + throw new WalletInputRequestInvalidError( + `inputs[${i}]: args["${argName}"].value must be a string Aleo literal.`, + ); + } + } + } } } diff --git a/packages/aleo-wallet-adaptor/core/src/types.ts b/packages/aleo-wallet-adaptor/core/src/types.ts index 621b389..8499046 100644 --- a/packages/aleo-wallet-adaptor/core/src/types.ts +++ b/packages/aleo-wallet-adaptor/core/src/types.ts @@ -2,6 +2,7 @@ import { WalletDecryptPermission } from '@provablehq/aleo-wallet-standard'; export { WalletDecryptPermission as DecryptPermission }; export type { + AlgorithmGrant, ConnectOptions, FieldGrant, ProgramGrant, @@ -10,11 +11,15 @@ export type { } from '@provablehq/aleo-wallet-standard'; export { hasUnsupportedConnectOptions } from '@provablehq/aleo-wallet-standard'; export type { + AlgorithmArg, + AlgorithmName, InputRequest, + KnownAlgorithm, + LiteralType, RecordEnvelope, RecordFieldFilter, RecordFilters, RecordView, TransactionInput, } from '@provablehq/aleo-types'; -export { hasInputRequest, isLiteralInput } from '@provablehq/aleo-types'; +export { ALGORITHM_SCHEMAS, hasInputRequest, isLiteralInput } from '@provablehq/aleo-types'; diff --git a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx index 3a09bec..024e051 100644 --- a/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx +++ b/packages/aleo-wallet-adaptor/react/src/WalletProvider.tsx @@ -1,6 +1,7 @@ import type { FC, ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AlgorithmGrant, WalletName, WalletReadyState, WalletAdapter, @@ -41,6 +42,12 @@ export interface WalletProviderProps { * Defaults to `true`. Only valid with `decryptPermission: NoDecrypt`. */ readAddress?: boolean; + /** + * Strict opt-in allowlist for `type: "derived"` InputRequests. Each grant + * authorizes exactly one (algorithm, program, function, inputPosition) + * call site. Default undefined → every derived request is refused. + */ + algorithmsAllowed?: AlgorithmGrant[]; } const initialState: { @@ -68,13 +75,18 @@ export const AleoWalletProvider: FC = ({ programs, recordAccess, readAddress, + algorithmsAllowed, }) => { const connectOptions = useMemo(() => { - if (recordAccess === undefined && readAddress === undefined) { + if ( + recordAccess === undefined && + readAddress === undefined && + (algorithmsAllowed === undefined || algorithmsAllowed.length === 0) + ) { return undefined; } - return { recordAccess, readAddress }; - }, [recordAccess, readAddress]); + return { recordAccess, readAddress, algorithmsAllowed }; + }, [recordAccess, readAddress, algorithmsAllowed]); const [name, setName] = useLocalStorage(localStorageKey, null); const [{ wallet, adapter, publicKey, connected, network }, setState] = useState(initialState); const readyState = adapter?.readyState || WalletReadyState.UNSUPPORTED; @@ -510,6 +522,17 @@ export const AleoWalletProvider: FC = ({ [adapter, handleError, connected], ); + // Doesn't require a connection — dapps may call this before connect to discover + // which algorithms a wallet supports and to populate `algorithmsAllowed`. + const algorithmsSupported = useCallback(async () => { + if (!adapter || !('algorithmsSupported' in adapter)) return []; + try { + return await adapter.algorithmsSupported(); + } catch { + return []; + } + }, [adapter]); + const checkNetwork = useCallback(async () => { if (adapter && adapter.network !== initialNetwork) { const switchResult = await switchNetwork(initialNetwork); @@ -543,6 +566,7 @@ export const AleoWalletProvider: FC = ({ executeDeployment, transitionViewKeys, requestTransactionHistory, + algorithmsSupported, }} > {children} diff --git a/packages/aleo-wallet-adaptor/react/src/context.ts b/packages/aleo-wallet-adaptor/react/src/context.ts index a26b2cb..f505f54 100644 --- a/packages/aleo-wallet-adaptor/react/src/context.ts +++ b/packages/aleo-wallet-adaptor/react/src/context.ts @@ -133,6 +133,13 @@ export interface WalletContextState { * @returns array of transactionId */ requestTransactionHistory: (program: string) => Promise; + /** + * Return the algorithm names this wallet implements for `type: "derived"` + * InputRequests. A dapp calls this before connect to pick which entries to + * include in `algorithmsAllowed`. Wallets without derived-input support + * return `[]`. + */ + algorithmsSupported: () => Promise; } /** diff --git a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts index 2550038..1acbdd6 100644 --- a/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts +++ b/packages/aleo-wallet-adaptor/wallets/shield/src/ShieldWalletAdapter.ts @@ -415,6 +415,19 @@ export class ShieldWalletAdapter extends BaseAleoWalletAdapter { } } + /** + * Shield's currently-supported derived-input algorithms. Returns the SDK's + * known-algorithm catalog; the wallet itself is the source of truth at + * runtime and will reject any algorithm it doesn't implement. + * + * TODO(wallet): when the injector exposes an `algorithmsSupported` message, + * replace this static list with a real round-trip so dapps see what THIS + * Shield build supports, not just the SDK's static catalog. + */ + async algorithmsSupported(): Promise { + return ['program-scoped-address-blind']; + } + /** * EVENTS HANDLING */ diff --git a/packages/aleo-wallet-standard/src/adapter.ts b/packages/aleo-wallet-standard/src/adapter.ts index dfec58a..7ac8f9a 100644 --- a/packages/aleo-wallet-standard/src/adapter.ts +++ b/packages/aleo-wallet-standard/src/adapter.ts @@ -159,6 +159,13 @@ export interface WalletAdapterProps { * @returns array of transactionId */ requestTransactionHistory: (program: string) => Promise; + + /** + * Return the algorithm names this wallet implements for `type: "derived"` + * InputRequests. Wallets without derived-input support return `[]`. + * No connection required. + */ + algorithmsSupported: () => Promise; } export type WalletAdapter = WalletAdapterProps & diff --git a/packages/aleo-wallet-standard/src/wallet.ts b/packages/aleo-wallet-standard/src/wallet.ts index 48e6e02..87c518a 100644 --- a/packages/aleo-wallet-standard/src/wallet.ts +++ b/packages/aleo-wallet-standard/src/wallet.ts @@ -219,6 +219,26 @@ export type RecordAccessGrant = | { level: 'none' } | { level: 'byProgram'; programs: ProgramGrant[] }; +/** + * Authorization for a `type: "derived"` InputRequest at one specific call site. + * All four fields are required and exact-match; the wallet refuses every + * derived request whose `(algorithm, program, function, inputPosition)` tuple + * is not in the connection's `algorithmsAllowed`. A dapp that wants to use + * the same algorithm at multiple call sites lists each one as its own entry. + * + * See `docs/adapter-privacy-extension.md` § "Derived inputs". + */ +export interface AlgorithmGrant { + /** Must appear in the wallet's `algorithmsSupported()` list. */ + algorithm: string; + /** Must also appear in the connection's `programs` allowlist. */ + program: string; + /** Exact transition name within `program`. */ + function: string; + /** 0-based index into the function's input slots. */ + inputPosition: number; +} + /** * Optional, additive connect-time options. All fields are opt-in; omitting them * preserves today's behavior. @@ -228,6 +248,11 @@ export interface ConnectOptions { recordAccess?: RecordAccessGrant; /** When `false`, the dapp transacts without learning the user's address. Defaults to `true`. */ readAddress?: boolean; + /** + * Strict opt-in allowlist for `type: "derived"` InputRequests. Default + * undefined → every derived request is refused. There is no broad default. + */ + algorithmsAllowed?: AlgorithmGrant[]; } /** @@ -237,5 +262,9 @@ export interface ConnectOptions { */ export function hasUnsupportedConnectOptions(options?: ConnectOptions): boolean { if (!options) return false; - return options.recordAccess !== undefined || options.readAddress === false; + return ( + options.recordAccess !== undefined || + options.readAddress === false || + (options.algorithmsAllowed !== undefined && options.algorithmsAllowed.length > 0) + ); }