diff --git a/src/actions/permissions.test.ts b/src/actions/permissions.test.ts new file mode 100644 index 00000000..d79d5f1b --- /dev/null +++ b/src/actions/permissions.test.ts @@ -0,0 +1,478 @@ +import { type Address, erc20Abi, toFunctionSelector } from 'viem' +import { base } from 'viem/chains' +import { describe, expect, test } from 'vitest' +import { accountA } from '../../test/consts' +import { getSessionData } from '../modules/validators/smart-sessions' +import type { Session } from '../types' +import { definePermissions } from './permissions' + +const USDC: Address = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' +const RECIPIENT: Address = '0x1111111111111111111111111111111111111111' + +// --------------------------------------------------------------------------- +// A. Basic param rules +// --------------------------------------------------------------------------- + +describe('definePermissions', () => { + test('ERC-20 transfer with param rules', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + params: { + recipient: { condition: 'equal', value: RECIPIENT }, + amount: { condition: 'lessThan', value: 1000n }, + }, + }, + }, + }) + + expect(actions).toHaveLength(1) + const action = actions[0] + expect(action.target).toBe(USDC) + expect(action.selector).toBe( + toFunctionSelector( + 'function transfer(address recipient, uint256 amount)', + ), + ) + expect(action.policies).toHaveLength(1) + + const policy = action.policies![0] + expect(policy.type).toBe('universal-action') + if (policy.type !== 'universal-action') throw new Error('wrong type') + + expect(policy.valueLimitPerUse).toBe(0n) + expect(policy.rules).toHaveLength(2) + + const recipientRule = policy.rules.find((r) => r.calldataOffset === 0n)! + expect(recipientRule.condition).toBe('equal') + expect(recipientRule.referenceValue).toBe(RECIPIENT) + + const amountRule = policy.rules.find((r) => r.calldataOffset === 32n)! + expect(amountRule.condition).toBe('lessThan') + expect(amountRule.referenceValue).toBe(1000n) + }) + + // --------------------------------------------------------------------------- + // B. Multiple functions + // --------------------------------------------------------------------------- + + test('multiple functions on the same contract', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + policies: [{ type: 'usage-limit', limit: 10n }], + }, + approve: { + policies: [{ type: 'usage-limit', limit: 5n }], + }, + }, + }) + + expect(actions).toHaveLength(2) + const names = actions.map((a) => a.selector).sort() + const expectedSelectors = [ + toFunctionSelector('function transfer(address to, uint256 value)'), + toFunctionSelector('function approve(address spender, uint256 value)'), + ].sort() + expect(names).toEqual(expectedSelectors) + }) + + // --------------------------------------------------------------------------- + // C. Policies without params + // --------------------------------------------------------------------------- + + test('policies only — no universal-action generated', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + approve: { + policies: [{ type: 'sudo' }], + }, + }, + }) + + expect(actions).toHaveLength(1) + expect(actions[0].policies).toEqual([{ type: 'sudo' }]) + }) + + // --------------------------------------------------------------------------- + // D. Params + policies combined + // --------------------------------------------------------------------------- + + test('user policies come before generated universal-action', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + policies: [{ type: 'usage-limit', limit: 3n }], + params: { + recipient: { condition: 'equal', value: RECIPIENT }, + }, + }, + }, + }) + + const policies = actions[0].policies! + expect(policies).toHaveLength(2) + expect(policies[0].type).toBe('usage-limit') + expect(policies[1].type).toBe('universal-action') + }) + + // --------------------------------------------------------------------------- + // E. Third parameter offset + // --------------------------------------------------------------------------- + + test('calldataOffset for third parameter is 64n', () => { + const customAbi = [ + { + type: 'function', + name: 'foo', + inputs: [ + { name: 'a', type: 'address' }, + { name: 'b', type: 'uint256' }, + { name: 'c', type: 'bool' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + ] as const + + const actions = definePermissions({ + abi: customAbi, + address: USDC, + functions: { + foo: { + params: { + c: { condition: 'equal', value: true }, + }, + }, + }, + }) + + const policy = actions[0].policies![0] + if (policy.type !== 'universal-action') throw new Error('wrong type') + expect(policy.rules[0].calldataOffset).toBe(64n) + }) + + // --------------------------------------------------------------------------- + // F. Boolean conversion + // --------------------------------------------------------------------------- + + test('boolean true → 1n, false → 0n', () => { + const abi = [ + { + type: 'function', + name: 'setFlag', + inputs: [{ name: 'flag', type: 'bool' }], + outputs: [], + stateMutability: 'nonpayable', + }, + ] as const + + const actionsTrue = definePermissions({ + abi, + address: USDC, + functions: { + setFlag: { params: { flag: { condition: 'equal', value: true } } }, + }, + }) + const actionsFalse = definePermissions({ + abi, + address: USDC, + functions: { + setFlag: { params: { flag: { condition: 'equal', value: false } } }, + }, + }) + + const ruleT = (actionsTrue[0].policies![0] as any).rules[0] + const ruleF = (actionsFalse[0].policies![0] as any).rules[0] + expect(ruleT.referenceValue).toBe(1n) + expect(ruleF.referenceValue).toBe(0n) + }) + + // --------------------------------------------------------------------------- + // G. Dynamic param type → throws + // --------------------------------------------------------------------------- + + test('throws for dynamic parameter types', () => { + const abi = [ + { + type: 'function', + name: 'send', + inputs: [{ name: 'data', type: 'bytes' }], + outputs: [], + stateMutability: 'nonpayable', + }, + ] as const + + expect(() => + definePermissions({ + abi, + address: USDC, + functions: { + send: { + params: { + // @ts-expect-error — value is `never` for dynamic types + data: { condition: 'equal', value: '0x1234' }, + }, + }, + }, + }), + ).toThrow(/dynamic type/) + }) + + // --------------------------------------------------------------------------- + // H. Overloaded function → throws + // --------------------------------------------------------------------------- + + test('throws for overloaded functions', () => { + const abi = [ + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + ] as const + + expect(() => + definePermissions({ + abi, + address: USDC, + functions: { + transfer: { policies: [{ type: 'sudo' }] }, + }, + }), + ).toThrow(/overloaded/) + }) + + // --------------------------------------------------------------------------- + // I. Unknown param name → throws + // --------------------------------------------------------------------------- + + test('throws for unknown parameter name', () => { + const abi = [ + { + type: 'function', + name: 'foo', + inputs: [{ name: 'bar', type: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + }, + ] as const + + expect(() => + definePermissions({ + abi, + address: USDC, + functions: { + foo: { + params: { + // @ts-expect-error — 'baz' doesn't exist + baz: { condition: 'equal', value: 1n }, + }, + }, + }, + }), + ).toThrow(/not found/) + }) + + // --------------------------------------------------------------------------- + // J. Empty functions → [] + // --------------------------------------------------------------------------- + + test('empty functions object returns empty array', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: {}, + }) + expect(actions).toEqual([]) + }) + + // --------------------------------------------------------------------------- + // K. valueLimitPerUse without params → value-limit policy + // --------------------------------------------------------------------------- + + test('valueLimitPerUse without params becomes value-limit policy', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + approve: { + valueLimitPerUse: 100n, + }, + }, + }) + + expect(actions[0].policies).toEqual([{ type: 'value-limit', limit: 100n }]) + }) + + // --------------------------------------------------------------------------- + // L. valueLimitPerUse with params → part of universal-action + // --------------------------------------------------------------------------- + + test('valueLimitPerUse with params sets it on universal-action', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + valueLimitPerUse: 500n, + params: { + recipient: { condition: 'equal', value: RECIPIENT }, + }, + }, + }, + }) + + const policy = actions[0].policies![0] + if (policy.type !== 'universal-action') throw new Error('wrong type') + expect(policy.valueLimitPerUse).toBe(500n) + }) + + // --------------------------------------------------------------------------- + // M. usageLimit on param rule + // --------------------------------------------------------------------------- + + test('usageLimit is forwarded to param rule', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + params: { + recipient: { + condition: 'equal', + value: RECIPIENT, + usageLimit: 5n, + }, + }, + }, + }, + }) + + const policy = actions[0].policies![0] + if (policy.type !== 'universal-action') throw new Error('wrong type') + expect(policy.rules[0].usageLimit).toBe(5n) + }) + + // --------------------------------------------------------------------------- + // N. No policies and no params → action with no policies key + // --------------------------------------------------------------------------- + + test('function with no config produces action without policies', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: {}, + }, + }) + + expect(actions).toHaveLength(1) + expect(actions[0].policies).toBeUndefined() + }) + + // --------------------------------------------------------------------------- + // O. Composability — result works inside Session.actions + // --------------------------------------------------------------------------- + + test('result can be spread into Session.actions', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + policies: [{ type: 'sudo' }], + }, + }, + }) + + const session: Session = { + chain: base, + owners: { type: 'ecdsa', accounts: [accountA] }, + actions: [...actions], + } + + expect(session.actions).toHaveLength(1) + }) + + // --------------------------------------------------------------------------- + // P. End-to-end with getSessionData + // --------------------------------------------------------------------------- + + test('result feeds into getSessionData without errors', () => { + const actions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + transfer: { + policies: [{ type: 'usage-limit', limit: 10n }], + params: { + recipient: { condition: 'equal', value: RECIPIENT }, + }, + }, + }, + }) + + const session: Session = { + chain: base, + owners: { type: 'ecdsa', accounts: [accountA] }, + actions: [...actions], + } + + const data = getSessionData(session) + // User action + injected WETH deposit + injected intent-execution fallback + expect(data.actions.length).toBeGreaterThanOrEqual(2) + expect(data.actions[0].actionTarget).toBe(USDC) + }) + + // --------------------------------------------------------------------------- + // Q. Function not found → throws + // --------------------------------------------------------------------------- + + test('throws when function name not in ABI', () => { + const abi = [ + { + type: 'function', + name: 'foo', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + ] as const + + expect(() => + definePermissions({ + abi, + address: USDC, + functions: { + // @ts-expect-error — 'bar' doesn't exist in abi + bar: { policies: [{ type: 'sudo' }] }, + }, + }), + ).toThrow(/not found/) + }) +}) diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts new file mode 100644 index 00000000..d7a38be0 --- /dev/null +++ b/src/actions/permissions.ts @@ -0,0 +1,251 @@ +import type { Abi, AbiFunction, AbiParameter } from 'abitype' +import { type Address, type Hex, isAddress, toFunctionSelector } from 'viem' +import type { Policy, UniversalActionPolicyParamCondition } from '../types' + +// --------------------------------------------------------------------------- +// Type-level utilities +// --------------------------------------------------------------------------- + +/** Extract all `function` names from an ABI. */ +type FunctionNames = Extract< + TAbi[number], + { type: 'function' } +>['name'] + +/** Pull the AbiFunction entry for a given name (union if overloaded). */ +type GetFunction = Extract< + TAbi[number], + { type: 'function'; name: TName } +> + +/** Map a Solidity type string to the TypeScript type a developer provides as + * `value` in a param constraint. Dynamic types resolve to `never` so the + * compiler prevents rules on params the on-chain policy cannot compare. */ +type AbiTypeToValue = T extends 'address' + ? Address + : T extends 'bool' + ? boolean + : T extends `uint${string}` + ? bigint + : T extends `int${string}` + ? bigint + : T extends `bytes${infer N}` + ? N extends '' + ? never // bare `bytes` is dynamic + : Hex + : never // arrays, tuples, string, etc. + +/** Resolve the TS value type for a named parameter of a function. */ +type ParamValue< + TFn extends AbiFunction, + TParamName extends string, +> = AbiTypeToValue['type']> + +/** A single parameter constraint – autocomplete-friendly. */ +interface ParamConstraint { + condition: UniversalActionPolicyParamCondition + value: TValue + usageLimit?: bigint +} + +/** Only named inputs (excludes unnamed ABI params). */ +type NamedInputs = Extract< + TFn['inputs'][number], + { name: string } +> + +/** Per-function configuration inside `definePermissions`. */ +type FunctionConfig = { + policies?: Policy[] + valueLimitPerUse?: bigint + params?: { + [K in NamedInputs['name']]?: ParamConstraint> + } +} + +/** Top-level input to `definePermissions`. */ +type ContractPermissions = { + abi: TAbi + address: Address + functions: { + [K in FunctionNames]?: FunctionConfig< + GetFunction & AbiFunction + > + } +} + +// --------------------------------------------------------------------------- +// Runtime helpers +// --------------------------------------------------------------------------- + +function isStaticAbiType(type: string): boolean { + if (type === 'address' || type === 'bool') return true + if (/^u?int\d*$/.test(type)) return true + if (/^bytes\d+$/.test(type)) { + const n = Number.parseInt(type.slice(5), 10) + return n >= 1 && n <= 32 + } + return false +} + +function toReferenceValue(value: unknown, abiType: string): Hex | bigint { + if (abiType === 'address') { + if (typeof value === 'string' && isAddress(value)) return value + throw new Error(`Expected address value, got: ${typeof value}`) + } + if (abiType === 'bool') { + if (typeof value === 'boolean') return value ? 1n : 0n + throw new Error(`Expected boolean value, got: ${typeof value}`) + } + if (abiType.startsWith('uint') || abiType.startsWith('int')) { + if (typeof value === 'bigint') return value + if (typeof value === 'number') return BigInt(value) + throw new Error( + `Expected bigint value for ${abiType}, got: ${typeof value}`, + ) + } + if (/^bytes\d+$/.test(abiType)) { + if (typeof value === 'string') return value as Hex + throw new Error(`Expected hex string for ${abiType}, got: ${typeof value}`) + } + throw new Error(`Unsupported ABI type: ${abiType}`) +} + +// --------------------------------------------------------------------------- +// Main function +// --------------------------------------------------------------------------- + +interface ScopedAction { + target: Address + selector: Hex + policies?: Policy[] +} + +/** + * Build typed, ABI-aware `ScopedAction[]` for session-key permissions. + * + * Accepts a contract ABI (as `const`), an address, and a map of function + * names to permission configs. Returns actions that can be spread directly + * into `Session.actions`. + * + * @example + * ```ts + * import { erc20Abi } from 'viem' + * + * const actions = definePermissions({ + * abi: erc20Abi, + * address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + * functions: { + * transfer: { + * policies: [{ type: 'usage-limit', limit: 10n }], + * params: { + * to: { condition: 'equal', value: '0x...' }, + * value: { condition: 'lessThan', value: 1000n }, + * }, + * }, + * }, + * }) + * ``` + */ +function definePermissions( + input: ContractPermissions, +): ScopedAction[] { + const { abi, address, functions } = input + const actions: ScopedAction[] = [] + + for (const [fnName, fnConfig] of Object.entries(functions)) { + if (!fnConfig) continue + const config = fnConfig as { + policies?: Policy[] + valueLimitPerUse?: bigint + params?: Record< + string, + { condition: string; value: unknown; usageLimit?: bigint } + > + } + + const abiEntries = (abi as readonly AbiParameter[]).filter( + (entry): entry is AbiFunction => + (entry as { type: string }).type === 'function' && + (entry as { name: string }).name === fnName, + ) + + if (abiEntries.length === 0) { + throw new Error(`Function "${fnName}" not found in the provided ABI.`) + } + if (abiEntries.length > 1) { + throw new Error( + `Function "${fnName}" is overloaded (${abiEntries.length} variants). ` + + 'definePermissions does not support overloaded functions. ' + + 'Use the raw Action API with manual selector and calldataOffset instead.', + ) + } + + const abiEntry = abiEntries[0] + const selector = toFunctionSelector(abiEntry) + + const policies: Policy[] = config.policies ? [...config.policies] : [] + const params = config.params ?? {} + const paramEntries = Object.entries(params).filter( + ([, v]) => v !== undefined, + ) + + if (paramEntries.length > 0) { + const rules = paramEntries.map(([paramName, rule]) => { + const paramIndex = abiEntry.inputs.findIndex( + (p) => p.name === paramName, + ) + if (paramIndex === -1) { + throw new Error( + `Parameter "${paramName}" not found in function "${fnName}". ` + + `Available: ${abiEntry.inputs.map((i) => i.name).join(', ')}`, + ) + } + + const param = abiEntry.inputs[paramIndex] + if (!isStaticAbiType(param.type)) { + throw new Error( + `Parameter "${paramName}" has dynamic type "${param.type}". ` + + 'definePermissions only supports rules on static types ' + + '(address, bool, uint*, int*, bytes1–bytes32). ' + + 'Use the raw Action API with manual calldataOffset for dynamic types.', + ) + } + + const calldataOffset = BigInt(paramIndex) * 32n + const referenceValue = toReferenceValue(rule.value, param.type) + + return { + condition: rule.condition as UniversalActionPolicyParamCondition, + calldataOffset, + referenceValue, + ...(rule.usageLimit !== undefined + ? { usageLimit: rule.usageLimit } + : {}), + } + }) + + policies.push({ + type: 'universal-action' as const, + valueLimitPerUse: config.valueLimitPerUse ?? 0n, + rules: rules as [(typeof rules)[number], ...(typeof rules)[number][]], + }) + } else if (config.valueLimitPerUse !== undefined) { + policies.push({ + type: 'value-limit' as const, + limit: config.valueLimitPerUse, + }) + } + + actions.push({ + target: address, + selector, + ...(policies.length > 0 ? { policies } : {}), + }) + } + + return actions +} + +export { definePermissions } +export type { ContractPermissions, ParamConstraint } diff --git a/src/examples/define-permissions.ts b/src/examples/define-permissions.ts new file mode 100644 index 00000000..0a3fc66f --- /dev/null +++ b/src/examples/define-permissions.ts @@ -0,0 +1,301 @@ +/** + * Example: Using definePermissions with complex, multi-param functions + * + * Shows how param-level rules (universal-action policy) work on functions + * with many static arguments — not just simple ERC-20 transfers. + */ +import type { Address } from 'viem' +import { base } from 'viem/chains' +import { definePermissions } from '../actions/permissions' +import type { Session } from '../types' + +// -- Constants ---------------------------------------------------------------- + +const USDC: Address = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' +const WETH: Address = '0x4200000000000000000000000000000000000006' +const TREASURY: Address = '0x000000000000000000000000000000000000dead' +const POOL: Address = '0x0000000000000000000000000000000000000042' +const LENDING_POOL: Address = '0x0000000000000000000000000000000000C0FFEE' + +// -- ABIs --------------------------------------------------------------------- + +// A lending protocol with complex multi-arg functions +const lendingPoolAbi = [ + { + type: 'function', + name: 'deposit', + inputs: [ + { name: 'asset', type: 'address' }, // which token to deposit + { name: 'amount', type: 'uint256' }, // how much + { name: 'onBehalfOf', type: 'address' }, // credit goes to + { name: 'referralCode', type: 'uint16' }, // referral tracking + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'borrow', + inputs: [ + { name: 'asset', type: 'address' }, // token to borrow + { name: 'amount', type: 'uint256' }, // how much + { name: 'interestRateMode', type: 'uint256' }, // 1=stable, 2=variable + { name: 'referralCode', type: 'uint16' }, // referral tracking + { name: 'onBehalfOf', type: 'address' }, // who takes the debt + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'repay', + inputs: [ + { name: 'asset', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'interestRateMode', type: 'uint256' }, + { name: 'onBehalfOf', type: 'address' }, + ], + outputs: [{ name: '', type: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'liquidationCall', + inputs: [ + { name: 'collateralAsset', type: 'address' }, + { name: 'debtAsset', type: 'address' }, + { name: 'user', type: 'address' }, + { name: 'debtToCover', type: 'uint256' }, + { name: 'receiveAToken', type: 'bool' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +const erc20Abi = [ + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'approve', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const + +// -- Permissions -------------------------------------------------------------- + +const now = Date.now() +const ONE_DAY = 24 * 60 * 60 * 1000 + +// 1) Lending pool: deposit, borrow, repay, liquidate — each with param rules +// +// Every `params` block generates a universal-action policy. The helper +// computes calldataOffset from the parameter's position in the ABI: +// index 0 → offset 0 (bytes 0–31) +// index 1 → offset 32 (bytes 32–63) +// index 2 → offset 64 (bytes 64–95) +// index 3 → offset 96 (bytes 96–127) +// index 4 → offset 128 (bytes 128–159) +// +const lendingActions = definePermissions({ + abi: lendingPoolAbi, + address: LENDING_POOL, + functions: { + // deposit: only USDC, only to our own account, max 50k per call + deposit: { + policies: [ + { type: 'usage-limit', limit: 100n }, + { type: 'time-frame', validAfter: now, validUntil: now + ONE_DAY }, + ], + // Generates a universal-action policy with 3 rules: + // rule 0: calldata[0:32] == USDC (asset must be USDC) + // rule 1: calldata[32:64] <= 50k (amount capped) + // rule 2: calldata[64:96] == TREASURY (credit goes to treasury) + params: { + asset: { condition: 'equal', value: USDC }, + amount: { condition: 'lessThanOrEqual', value: 50_000n * 10n ** 6n }, + onBehalfOf: { condition: 'equal', value: TREASURY }, + }, + }, + + // borrow: only WETH, variable rate only, max 10 WETH, debt to treasury + borrow: { + policies: [{ type: 'usage-limit', limit: 10n }], + // Generates a universal-action policy with 4 rules across 5 params: + // rule 0: calldata[0:32] == WETH (borrow WETH only) + // rule 1: calldata[32:64] <= 10 ETH (max borrow amount) + // rule 2: calldata[64:96] == 2 (variable rate mode) + // rule 3: calldata[128:160] == TREASURY (debt assigned to treasury) + // + // Note: referralCode (index 3, offset 96) is skipped — no constraint. + // Only params you list get rules. The rest are unconstrained. + params: { + asset: { condition: 'equal', value: WETH }, + amount: { condition: 'lessThanOrEqual', value: 10n * 10n ** 18n }, + interestRateMode: { condition: 'equal', value: 2n }, + onBehalfOf: { condition: 'equal', value: TREASURY }, + }, + }, + + // repay: allow repaying any amount of USDC, variable rate, for treasury + repay: { + policies: [{ type: 'usage-limit', limit: 50n }], + params: { + asset: { condition: 'equal', value: USDC }, + interestRateMode: { condition: 'equal', value: 2n }, + onBehalfOf: { condition: 'equal', value: TREASURY }, + }, + }, + + // liquidationCall: lock down all 5 params + // - only liquidate WETH collateral / USDC debt + // - only for a specific user (the pool) + // - max 5k USDC debt coverage per call + // - must receive aTokens (not underlying) + liquidationCall: { + policies: [ + { type: 'usage-limit', limit: 5n }, + { type: 'time-frame', validAfter: now, validUntil: now + ONE_DAY }, + ], + // All 5 params constrained: + // calldata[0:32] == WETH (collateralAsset) + // calldata[32:64] == USDC (debtAsset) + // calldata[64:96] == POOL (user to liquidate) + // calldata[96:128] <= 5000e6 (debtToCover) + // calldata[128:160] == true (receiveAToken) + params: { + collateralAsset: { condition: 'equal', value: WETH }, + debtAsset: { condition: 'equal', value: USDC }, + user: { condition: 'equal', value: POOL }, + debtToCover: { + condition: 'lessThanOrEqual', + value: 5_000n * 10n ** 6n, + }, + receiveAToken: { condition: 'equal', value: true }, + }, + }, + }, +}) + +// 2) Token approvals for the lending pool +const tokenActions = definePermissions({ + abi: erc20Abi, + address: USDC, + functions: { + approve: { + policies: [{ type: 'usage-limit', limit: 5n }], + params: { + spender: { condition: 'equal', value: LENDING_POOL }, + amount: { condition: 'lessThanOrEqual', value: 50_000n * 10n ** 6n }, + }, + }, + }, +}) + +// -- Session definition ------------------------------------------------------- + +// The session key holder — a separate ECDSA key with limited permissions +const sessionKeyAccount = {} as any // in practice: privateKeyToAccount('0x...') + +const session: Session = { + chain: base, + owners: { + type: 'ecdsa', + accounts: [sessionKeyAccount], + threshold: 1, + }, + actions: [...lendingActions, ...tokenActions], +} + +// Result: 5 scoped actions, each with a universal-action policy generated +// from params, plus additional policies (usage-limit, time-frame) stacked on. +// +// What the session key can do: +// 1. deposit(USDC, ≤50k, to=treasury, any referral) — 100 calls, 24h +// 2. borrow(WETH, ≤10, variable rate, to=treasury) — 10 calls +// 3. repay(USDC, any amount, variable rate, for=treasury) — 50 calls +// 4. liquidationCall(WETH/USDC, pool, ≤5k, aTokens) — 5 calls, 24h +// 5. approve(USDC, spender=lending pool, ≤50k) — 5 calls + +// -- Enable session on the smart session emissary ----------------------------- + +async function enableSession() { + // 1. Create the account with smart sessions enabled + const { createRhinestoneAccount } = await import('../index') + + const account = await createRhinestoneAccount({ + apiKey: 'YOUR_API_KEY', + owners: { + type: 'ecdsa', + accounts: [sessionKeyAccount], // the account owner + threshold: 1, + }, + experimental_sessions: { enabled: true }, + }) + + // 2. Prepare the session for signing — computes EIP-712 typed data + // and fetches nonces from the on-chain emissary + const details = await account.experimental_getSessionDetails([session]) + + // 3. Owner signs the session — this authorizes the session key to act + // within the defined permissions + const signature = await account.experimental_signEnableSession(details) + + // 4. Submit a transaction that enables the session on-chain. + // The emissary stores the permission config so it can verify + // future calls from the session key. + const { experimental_enableSession } = await import( + '../actions/smart-sessions' + ) + + await account.sendTransaction({ + chain: base, + calls: [ + experimental_enableSession( + session, + signature, + details.hashesAndChainIds, + 0, + ), + ], + }) + + // 5. Now the session key can execute scoped transactions. + // The session key holder signs with their own key, and the emissary + // validates each call against the stored permissions + policies. + await account.sendTransaction({ + chain: base, + calls: [ + { + to: LENDING_POOL, + data: '0x...', // encoded deposit(USDC, 10000e6, treasury, 0) + }, + ], + signers: { + type: 'experimental_session', + session, + enableData: { + userSignature: signature, + hashesAndChainIds: details.hashesAndChainIds, + sessionToEnableIndex: 0, + }, + verifyExecutions: true, + }, + }) +} + +void enableSession diff --git a/src/index.ts b/src/index.ts index dbd71bd8..bae2faf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,10 @@ import { } from './accounts' import { walletClientToAccount, wrapParaAccount } from './accounts/walletClient' import { deployAccountsForOwners } from './actions/deployment' +import { + type ContractPermissions, + definePermissions, +} from './actions/permissions' import { getIntentStatus as getIntentStatusInternal, getPortfolio as getPortfolioInternal, @@ -634,6 +638,8 @@ export { // Multi-chain permit2 signing signPermit2Batch, signPermit2Sequential, + // Permission builder + definePermissions, } export type { RhinestoneAccount, @@ -682,4 +688,6 @@ export type { MultiChainPermit2Config, MultiChainPermit2Result, BatchPermit2Result, + // Permission builder types + ContractPermissions, }