From 609eb749bbdc9b884d89a56f145765953bad3786 Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Thu, 19 Feb 2026 18:10:23 +0000 Subject: [PATCH 1/5] feat: new session keys support --- .../synapse-core/src/session-key/actions.ts | 108 ------- .../src/session-key/authorization-expiry.ts | 110 ++++++- .../synapse-core/src/session-key/index.ts | 53 +++- .../synapse-core/src/session-key/login.ts | 148 +++++++++ .../src/session-key/permissions.ts | 24 +- .../synapse-core/src/session-key/revoke.ts | 143 +++++++++ .../synapse-core/src/session-key/secp256k1.ts | 300 +++++++++++------- .../synapse-core/src/session-key/types.ts | 36 +++ 8 files changed, 675 insertions(+), 247 deletions(-) delete mode 100644 packages/synapse-core/src/session-key/actions.ts create mode 100644 packages/synapse-core/src/session-key/login.ts create mode 100644 packages/synapse-core/src/session-key/revoke.ts create mode 100644 packages/synapse-core/src/session-key/types.ts diff --git a/packages/synapse-core/src/session-key/actions.ts b/packages/synapse-core/src/session-key/actions.ts deleted file mode 100644 index 340fca2a1..000000000 --- a/packages/synapse-core/src/session-key/actions.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Account, Address, Chain, Client, Transport } from 'viem' -import { simulateContract, writeContract } from 'viem/actions' -import { asChain } from '../chains.ts' -import { authorizationExpiry } from './authorization-expiry.ts' -import { SESSION_KEY_PERMISSIONS, type SessionKeyPermissions } from './permissions.ts' - -export type IsExpiredOptions = { - /** - * The address of the account to query. - */ - address: Address - sessionKeyAddress: Address - permission: SessionKeyPermissions -} - -/** - * Check if the session key is expired. - * - * @param client - The client to use. - * @param options - The options to use. - * @returns The account info including funds, lockup details, and available balance. - * @throws - {@link ReadContractErrorType} if the read contract fails. - */ -export async function isExpired(client: Client, options: IsExpiredOptions): Promise { - const expiry = await authorizationExpiry(client, options) - - return expiry < BigInt(Math.floor(Date.now() / 1000)) -} - -export type LoginOptions = { - /** - * Session key address. - */ - address: Address - /** - * The permissions of the session key. - */ - permissions: SessionKeyPermissions[] - /** - * The expiry time of the session key. - */ - expiresAt?: bigint - /** - * The origin of the session key. - */ - origin?: string -} - -export async function login(client: Client, options: LoginOptions) { - const chain = asChain(client.chain) - const expiresAt = BigInt(Math.floor(Date.now() / 1000) + 3600) - - const { request } = await simulateContract(client, { - address: chain.contracts.sessionKeyRegistry.address, - abi: chain.contracts.sessionKeyRegistry.abi, - functionName: 'login', - args: [ - options.address, - options.expiresAt ?? expiresAt, - [...new Set(options.permissions)].map((permission) => SESSION_KEY_PERMISSIONS[permission]), - options.origin ?? 'synapse', - ], - }) - - const hash = await writeContract(client, request) - return hash -} - -export type RevokeOptions = { - /** - * Session key address. - */ - address: Address - /** - * The permissions of the session key. - */ - permissions: SessionKeyPermissions[] - /** - * The origin of the session key. - */ - origin?: string -} - -/** - * Revoke the session key. - * - * @param client - The client to use. - * @param options - The options to use. - * @returns The hash of the revoke transaction. - * @throws - {@link SimulateContractErrorType} if the simulate contract fails. - * @throws - {@link WriteContractErrorType} if the write contract fails. - */ -export async function revoke(client: Client, options: RevokeOptions) { - const chain = asChain(client.chain) - - const { request } = await simulateContract(client, { - address: chain.contracts.sessionKeyRegistry.address, - abi: chain.contracts.sessionKeyRegistry.abi, - functionName: 'revoke', - args: [ - options.address, - [...new Set(options.permissions)].map((permission) => SESSION_KEY_PERMISSIONS[permission]), - options.origin ?? 'synapse', - ], - }) - const hash = await writeContract(client, request) - return hash -} diff --git a/packages/synapse-core/src/session-key/authorization-expiry.ts b/packages/synapse-core/src/session-key/authorization-expiry.ts index f8514ab62..02b5fe310 100644 --- a/packages/synapse-core/src/session-key/authorization-expiry.ts +++ b/packages/synapse-core/src/session-key/authorization-expiry.ts @@ -1,18 +1,25 @@ import type { Simplify } from 'type-fest' -import type { - Address, - Chain, - Client, - ContractFunctionParameters, - ContractFunctionReturnType, - ReadContractErrorType, - Transport, +import { + type Address, + type Chain, + type Client, + ContractFunctionExecutionError, + type ContractFunctionParameters, + type ContractFunctionReturnType, + type MulticallErrorType, + type ReadContractErrorType, + type Transport, } from 'viem' -import { readContract } from 'viem/actions' +import { multicall, readContract } from 'viem/actions' import type { sessionKeyRegistry as sessionKeyRegistryAbi } from '../abis/index.ts' import { asChain } from '../chains.ts' import type { ActionCallChain } from '../types.ts' -import { SESSION_KEY_PERMISSIONS, type SessionKeyPermissions } from './permissions.ts' +import { + ALL_PERMISSIONS, + EMPTY_EXPIRATIONS, + SESSION_KEY_PERMISSIONS, + type SessionKeyPermissions, +} from './permissions.ts' export namespace authorizationExpiry { export type OptionsType = { @@ -140,3 +147,86 @@ export function authorizationExpiryCall(options: authorizationExpiryCall.Options args: [options.address, options.sessionKeyAddress, SESSION_KEY_PERMISSIONS[options.permission]], } satisfies authorizationExpiryCall.OutputType } + +export namespace isExpired { + export type OptionsType = Simplify + export type ErrorType = authorizationExpiry.ErrorType + export type OutputType = boolean +} + +/** + * Check if the session key is expired. + * + * @param client - The client to use. + * @param options - The options to use. + * @returns Whether the session key is expired. + * @throws - {@link isExpired.ErrorType} if the read contract fails. + */ +export async function isExpired( + client: Client, + options: isExpired.OptionsType +): Promise { + const expiry = await authorizationExpiry(client, options) + + return expiry < BigInt(Math.floor(Date.now() / 1000)) +} + +export namespace getExpirations { + export type OptionsType = Simplify> + export type ErrorType = authorizationExpiry.ErrorType | MulticallErrorType + export type OutputType = Record +} + +/** + * Get the expirations for all permissions. + * + * @param client - The client to use. + * @param options - {@link getExpirations.OptionsType} + * @returns The expirations as a record of {@link SessionKeyPermissions} to {@link bigint} {@link getExpirations.OutputType} + * @throws Errors {@link getExpirations.ErrorType} + * + * @example + * ```ts + * import { getExpirations } from '@filoz/synapse-core/session-key' + * import { createPublicClient, http } from 'viem' + * import { calibration } from '@filoz/synapse-core/chains' + * + * const client = createPublicClient({ + * chain: calibration, + * transport: http(), + * }) + * + * const expirations = await getExpirations(client, { + * address: '0x1234567890123456789012345678901234567890', + * sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + * }) + * + * console.log(expirations) + */ +export async function getExpirations(client: Client, options: getExpirations.OptionsType) { + const expirations: Record = EMPTY_EXPIRATIONS + + try { + const result = await multicall(client, { + allowFailure: false, + contracts: ALL_PERMISSIONS.map((permission) => + authorizationExpiryCall({ + chain: client.chain, + address: options.address, + sessionKeyAddress: options.sessionKeyAddress, + permission, + }) + ), + }) + + for (let i = 0; i < ALL_PERMISSIONS.length; i++) { + expirations[ALL_PERMISSIONS[i]] = result[i] + } + } catch (e) { + if (!(e instanceof ContractFunctionExecutionError && e.details.includes('actor not found'))) { + throw e + } + } + + return expirations +} diff --git a/packages/synapse-core/src/session-key/index.ts b/packages/synapse-core/src/session-key/index.ts index bc5b57544..0e3ba2331 100644 --- a/packages/synapse-core/src/session-key/index.ts +++ b/packages/synapse-core/src/session-key/index.ts @@ -4,11 +4,60 @@ * @example * ```ts * import * as SessionKey from '@filoz/synapse-core/session-key' + * import { privateKeyToAccount } from 'viem/accounts' + * import { type Hex } from 'viem' + * import { mainnet } from '@filoz/synapse-core/chains' + * + * const rootAccount = privateKeyToAccount('0xaa14e25eaea762df1533e72394b85e56dd0c7aa61cf6df3b1f13a842ca0361e5' as Hex) + * + * const sessionKey = SessionKey.fromSecp256k1({ + * privateKey: '0xaa14e25eaea762df1533e72394b85e56dd0c7aa61cf6df3b1f13a842ca0361e5' as Hex, + * root: rootAccount, + * chain: mainnet, + * }) + * sessionKey.on('expirationsUpdated', (e) => {console.log(e.detail)}) + * sessionKey.on('connected', (e) => {console.log(e.detail)}) + * sessionKey.on('disconnected', () => {console.log('disconnected')}) + * sessionKey.on('error', (e) => {console.log(e.detail)}) + * + * + * const { event: loginEvent } = await SessionKey.loginSync(client, { + * address: sessionKey.address, + * onHash(hash) { + * console.log(`Waiting for tx ${hash} to be mined...`) + * }, + * }) + * + * console.log('event login:', loginEvent.args) + * + * await sessionKey.connect() + * + * if(sessionKey.hasPermission('CreateDataSet')) { + * const hash = await createDataSet(sessionKey.client, { + * payee: '0x1234567890123456789012345678901234567890', + * payer: sessionKey.rootAddress, + * serviceURL: 'https://example.com', + * }) + * console.log('event created dataset:', hash) + * } + * + * const { event: revokeEvent } = await SessionKey.revokeSync(client, { + * address: sessionKey.address, + * onHash(hash) { + * console.log(`Waiting for tx ${hash} to be mined...`) + * }, + * }) + * console.log('event revoked:', revokeEvent.args) + * sessionKey.disconnect() * ``` * * @module session-key */ -export * from './actions.ts' + export * from './authorization-expiry.ts' +export * from './login.ts' export * from './permissions.ts' -export * from './secp256k1.ts' +export * from './revoke.ts' +export type { AccountFromSecp256k1Options, FromSecp256k1Options } from './secp256k1.ts' +export { accountFromSecp256k1, fromSecp256k1 } from './secp256k1.ts' +export * from './types.ts' diff --git a/packages/synapse-core/src/session-key/login.ts b/packages/synapse-core/src/session-key/login.ts new file mode 100644 index 000000000..6cc7f5386 --- /dev/null +++ b/packages/synapse-core/src/session-key/login.ts @@ -0,0 +1,148 @@ +import type { Simplify } from 'type-fest' +import type { + Account, + Address, + Chain, + Client, + ContractFunctionParameters, + Hash, + Log, + SimulateContractErrorType, + Transport, + WaitForTransactionReceiptErrorType, + WriteContractErrorType, +} from 'viem' +import { parseEventLogs } from 'viem' +import { simulateContract, waitForTransactionReceipt, writeContract } from 'viem/actions' +import type { sessionKeyRegistry as sessionKeyRegistryAbi } from '../abis/index.ts' +import * as Abis from '../abis/index.ts' +import { asChain } from '../chains.ts' +import type { ActionCallChain, ActionSyncCallback, ActionSyncOutput } from '../types.ts' +import { ALL_PERMISSIONS, SESSION_KEY_PERMISSIONS, type SessionKeyPermissions } from './permissions.ts' + +export namespace login { + export type OptionsType = { + /** Session key address. */ + address: Address + /** The permissions to authorize for the session key. Defaults to all permissions. */ + permissions?: SessionKeyPermissions[] + /** The expiry time as Unix timestamp (seconds). Defaults to now + 1 hour. */ + expiresAt?: bigint + /** The origin of the session key authorization. Defaults to 'synapse'. */ + origin?: string + /** Session key registry contract address. If not provided, defaults to the chain contract address. */ + contractAddress?: Address + } + + export type OutputType = Hash + + export type ErrorType = asChain.ErrorType | SimulateContractErrorType | WriteContractErrorType +} + +/** + * Authorize a session key with permissions until expiry. + * + * @param client - The client to use to authorize the session key. + * @param options - {@link login.OptionsType} + * @returns The transaction hash {@link login.OutputType} + * @throws Errors {@link login.ErrorType} + */ +export async function login( + client: Client, + options: login.OptionsType +): Promise { + const { request } = await simulateContract( + client, + loginCall({ + chain: client.chain, + address: options.address, + permissions: options.permissions, + expiresAt: options.expiresAt, + origin: options.origin, + contractAddress: options.contractAddress, + }) + ) + + return writeContract(client, request) +} + +export namespace loginSync { + export type OptionsType = Simplify + export type OutputType = ActionSyncOutput + export type ErrorType = + | loginCall.ErrorType + | SimulateContractErrorType + | WriteContractErrorType + | WaitForTransactionReceiptErrorType +} + +/** + * Authorize a session key and wait for confirmation. + * + * @param client - The client to use to authorize the session key. + * @param options - {@link loginSync.OptionsType} + * @returns The transaction receipt and extracted event {@link loginSync.OutputType} + * @throws Errors {@link loginSync.ErrorType} + */ +export async function loginSync( + client: Client, + options: loginSync.OptionsType +): Promise { + const hash = await login(client, options) + + if (options.onHash) { + options.onHash(hash) + } + + const receipt = await waitForTransactionReceipt(client, { hash }) + const event = extractLoginEvent(receipt.logs) + + return { receipt, event } +} + +export namespace loginCall { + export type OptionsType = Simplify + export type ErrorType = asChain.ErrorType + export type OutputType = ContractFunctionParameters +} + +/** + * Create a call to the login function. + * + * @param options - {@link loginCall.OptionsType} + * @returns The call object {@link loginCall.OutputType} + * @throws Errors {@link loginCall.ErrorType} + */ +export function loginCall(options: loginCall.OptionsType) { + const chain = asChain(options.chain) + const expiresAt = BigInt(Math.floor(Date.now() / 1000) + 3600) + const permissions = options.permissions ?? ALL_PERMISSIONS + return { + abi: chain.contracts.sessionKeyRegistry.abi, + address: options.contractAddress ?? chain.contracts.sessionKeyRegistry.address, + functionName: 'login', + args: [ + options.address, + options.expiresAt ?? expiresAt, + [...new Set(permissions)].map((permission) => SESSION_KEY_PERMISSIONS[permission]), + options.origin ?? 'synapse', + ], + } satisfies loginCall.OutputType +} + +/** + * Extracts the AuthorizationsUpdated event from transaction logs. + * + * @param logs - The transaction logs. + * @returns The AuthorizationsUpdated event. + */ +export function extractLoginEvent(logs: Log[]) { + const [log] = parseEventLogs({ + abi: Abis.sessionKeyRegistry, + logs, + eventName: 'AuthorizationsUpdated', + strict: true, + }) + if (!log) throw new Error('`AuthorizationsUpdated` event not found.') + return log +} diff --git a/packages/synapse-core/src/session-key/permissions.ts b/packages/synapse-core/src/session-key/permissions.ts index 0075f1d3c..de1295269 100644 --- a/packages/synapse-core/src/session-key/permissions.ts +++ b/packages/synapse-core/src/session-key/permissions.ts @@ -9,6 +9,13 @@ function typeHash(type: TypedData.encodeType.Value) { return keccak256(stringToHex(TypedData.encodeType(type))) } +export const EMPTY_EXPIRATIONS: Record = { + CreateDataSet: 0n, + AddPieces: 0n, + SchedulePieceRemovals: 0n, + DeleteDataSet: 0n, +} + export const ALL_PERMISSIONS: SessionKeyPermissions[] = [ 'CreateDataSet', 'AddPieces', @@ -38,12 +45,17 @@ export const SESSION_KEY_PERMISSIONS: Record = { }), } +export const TYPE_HASH_TO_PERMISSION: Record = { + [SESSION_KEY_PERMISSIONS.CreateDataSet]: 'CreateDataSet', + [SESSION_KEY_PERMISSIONS.AddPieces]: 'AddPieces', + [SESSION_KEY_PERMISSIONS.SchedulePieceRemovals]: 'SchedulePieceRemovals', + [SESSION_KEY_PERMISSIONS.DeleteDataSet]: 'DeleteDataSet', +} + export function getPermissionFromTypeHash(typeHash: Hex): SessionKeyPermissions { - for (const permission of Object.entries(SESSION_KEY_PERMISSIONS)) { - if (permission[1] === typeHash) { - return permission[0] as SessionKeyPermissions - } + const permission = TYPE_HASH_TO_PERMISSION[typeHash] + if (!permission) { + throw new Error(`Permission not found for type hash: ${typeHash}`) } - - throw new Error(`Permission not found for type hash: ${typeHash}`) + return permission } diff --git a/packages/synapse-core/src/session-key/revoke.ts b/packages/synapse-core/src/session-key/revoke.ts new file mode 100644 index 000000000..2ce3921d3 --- /dev/null +++ b/packages/synapse-core/src/session-key/revoke.ts @@ -0,0 +1,143 @@ +import type { Simplify } from 'type-fest' +import type { + Account, + Address, + Chain, + Client, + ContractFunctionParameters, + Hash, + Log, + SimulateContractErrorType, + Transport, + WaitForTransactionReceiptErrorType, + WriteContractErrorType, +} from 'viem' +import { parseEventLogs } from 'viem' +import { simulateContract, waitForTransactionReceipt, writeContract } from 'viem/actions' +import type { sessionKeyRegistry as sessionKeyRegistryAbi } from '../abis/index.ts' +import * as Abis from '../abis/index.ts' +import { asChain } from '../chains.ts' +import type { ActionCallChain, ActionSyncCallback, ActionSyncOutput } from '../types.ts' +import { ALL_PERMISSIONS, SESSION_KEY_PERMISSIONS, type SessionKeyPermissions } from './permissions.ts' + +export namespace revoke { + export type OptionsType = { + /** Session key address. */ + address: Address + /** The permissions to revoke from the session key. Defaults to all permissions. */ + permissions?: SessionKeyPermissions[] + /** The origin of the revoke operation. Defaults to 'synapse'. */ + origin?: string + /** Session key registry contract address. If not provided, defaults to the chain contract address. */ + contractAddress?: Address + } + + export type OutputType = Hash + + export type ErrorType = asChain.ErrorType | SimulateContractErrorType | WriteContractErrorType +} + +/** + * Revoke session key permissions. + * + * @param client - The client to use to revoke session key permissions. + * @param options - {@link revoke.OptionsType} + * @returns The transaction hash {@link revoke.OutputType} + * @throws Errors {@link revoke.ErrorType} + */ +export async function revoke( + client: Client, + options: revoke.OptionsType +): Promise { + const { request } = await simulateContract( + client, + revokeCall({ + chain: client.chain, + address: options.address, + permissions: options.permissions, + origin: options.origin, + contractAddress: options.contractAddress, + }) + ) + + return writeContract(client, request) +} + +export namespace revokeSync { + export type OptionsType = Simplify + export type OutputType = ActionSyncOutput + export type ErrorType = + | revokeCall.ErrorType + | SimulateContractErrorType + | WriteContractErrorType + | WaitForTransactionReceiptErrorType +} + +/** + * Revoke session key permissions and wait for confirmation. + * + * @param client - The client to use to revoke session key permissions. + * @param options - {@link revokeSync.OptionsType} + * @returns The transaction receipt and extracted event {@link revokeSync.OutputType} + * @throws Errors {@link revokeSync.ErrorType} + */ +export async function revokeSync( + client: Client, + options: revokeSync.OptionsType +): Promise { + const hash = await revoke(client, options) + + if (options.onHash) { + options.onHash(hash) + } + + const receipt = await waitForTransactionReceipt(client, { hash }) + const event = extractRevokeEvent(receipt.logs) + + return { receipt, event } +} + +export namespace revokeCall { + export type OptionsType = Simplify + export type ErrorType = asChain.ErrorType + export type OutputType = ContractFunctionParameters +} + +/** + * Create a call to the revoke function. + * + * @param options - {@link revokeCall.OptionsType} + * @returns The call object {@link revokeCall.OutputType} + * @throws Errors {@link revokeCall.ErrorType} + */ +export function revokeCall(options: revokeCall.OptionsType) { + const chain = asChain(options.chain) + const permissions = options.permissions ?? ALL_PERMISSIONS + return { + abi: chain.contracts.sessionKeyRegistry.abi, + address: options.contractAddress ?? chain.contracts.sessionKeyRegistry.address, + functionName: 'revoke', + args: [ + options.address, + [...new Set(permissions)].map((permission) => SESSION_KEY_PERMISSIONS[permission]), + options.origin ?? 'synapse', + ], + } satisfies revokeCall.OutputType +} + +/** + * Extracts the AuthorizationsUpdated event from transaction logs. + * + * @param logs - The transaction logs. + * @returns The AuthorizationsUpdated event. + */ +export function extractRevokeEvent(logs: Log[]) { + const [log] = parseEventLogs({ + abi: Abis.sessionKeyRegistry, + logs, + eventName: 'AuthorizationsUpdated', + strict: true, + }) + if (!log) throw new Error('`AuthorizationsUpdated` event not found.') + return log +} diff --git a/packages/synapse-core/src/session-key/secp256k1.ts b/packages/synapse-core/src/session-key/secp256k1.ts index 2099ca76e..c45f86ce2 100644 --- a/packages/synapse-core/src/session-key/secp256k1.ts +++ b/packages/synapse-core/src/session-key/secp256k1.ts @@ -1,159 +1,217 @@ import { TypedEventTarget } from 'iso-web/event-target' -import type { Hex } from 'ox/Hex' import { type Chain, type Client, - createWalletClient, - type TransactionReceipt, + createClient, + type FallbackTransport, + type Hex, + type HttpTransport, + http, type Transport, - type TransportConfig, + type WatchContractEventReturnType, + type WebSocketTransport, } from 'viem' -import { type Account, generatePrivateKey, privateKeyToAccount } from 'viem/accounts' -import { waitForTransactionReceipt } from 'viem/actions' -import { transportFromTransportConfig } from '../utils/viem.ts' -import { isExpired, login } from './actions.ts' -import type { SessionKeyPermissions } from './permissions.ts' +import { type Account, type Address, privateKeyToAccount } from 'viem/accounts' +import { watchContractEvent } from 'viem/actions' +import { asChain, type Chain as SynapseChain } from '../chains.ts' +import { getExpirations } from './authorization-expiry.ts' +import { extractLoginEvent } from './login.ts' +import { EMPTY_EXPIRATIONS, getPermissionFromTypeHash, type SessionKeyPermissions } from './permissions.ts' +import type { SessionKey, SessionKeyAccount, SessionKeyEvents } from './types.ts' -export interface Secp256k1SessionKeyProps { - privateKey: Hex - expiresAt: number | undefined - permissions: SessionKeyPermissions[] +interface Secp256k1SessionKeyOptions { + client: Client> + expirations: Record } -export interface Secp256k1SessionKeyCreateOptions { - privateKey?: Hex +/** + * Secp256k1SessionKey - A session key for a secp256k1 private key. + */ +class Secp256k1SessionKey extends TypedEventTarget implements SessionKey<'Secp256k1'> { + #client: Client> + #type: 'Secp256k1' + #expirations: Record + #unsubscribe: WatchContractEventReturnType | undefined + /** - * The expiration time of the session key in seconds. - * @default Date.now() / 1000 + 1 hour + * Create a new Secp256k1SessionKey. + * @param options - {@link Secp256k1SessionKeyOptions} */ - expiresAt?: number - permissions?: SessionKeyPermissions[] -} - -export class Secp256k1Key extends TypedEventTarget implements SessionKey { - private privateKey: Hex - permissions: SessionKeyPermissions[] - expiresAt: number | undefined - type: 'secp256k1' - account: Account - private isConnecting: boolean = false - private isConnected: boolean = false - private connectPromise: Promise | undefined - - constructor(props: Secp256k1SessionKeyProps) { + constructor(options: Secp256k1SessionKeyOptions) { super() - this.privateKey = props.privateKey - this.expiresAt = props.expiresAt - this.type = 'secp256k1' - this.permissions = props.permissions - this.account = privateKeyToAccount(this.privateKey) + this.#type = 'Secp256k1' + this.#expirations = options.expirations + this.#client = options.client } - static create(options?: Secp256k1SessionKeyCreateOptions) { - const key = options?.privateKey ?? generatePrivateKey() - return new Secp256k1Key({ - privateKey: key, - expiresAt: options?.expiresAt, - permissions: options?.permissions ?? ['CreateDataSet', 'AddPieces', 'SchedulePieceRemovals', 'DeleteDataSet'], - }) + get client() { + return this.#client } - get connecting() { - return this.isConnecting + get type() { + return this.#type } - get connected() { - return this.isConnected + get expirations() { + return this.#expirations } - async connect(client: Client) { - if (this.isConnecting) { - throw new Error('Already connecting') - } - this.isConnecting = true - try { - const _isExpired = await this.isValid(client, this.permissions[0]) - if (_isExpired) { - const hash = await this.refresh(client) - this.connectPromise = waitForTransactionReceipt(client, { hash }).then( - (receipt) => { - this.isConnected = true - this.emit('connected', this.account) - this.connectPromise = undefined - return receipt - }, - (error) => { - this.connectPromise = undefined - this.emit('error', new Error('Failed to wait for connect', { cause: error })) - return undefined + get address() { + return this.#client.account.address + } + + get rootAddress() { + return this.#client.account.rootAddress + } + + get account() { + return this.#client.account + } + + async connect() { + await this.syncExpirations() + + if (!this.#unsubscribe) { + this.#unsubscribe = watchContractEvent(this.client, { + address: this.#client.chain.contracts.sessionKeyRegistry.address, + abi: this.#client.chain.contracts.sessionKeyRegistry.abi, + eventName: 'AuthorizationsUpdated', + args: { identity: this.#client.account.rootAddress }, + onLogs: (logs) => { + const event = extractLoginEvent(logs) + if (event.args.identity === this.#client.account.rootAddress) { + for (const hash of event.args.permissions) { + const permission = getPermissionFromTypeHash(hash) + this.expirations[permission] = event.args.expiry + } + this.emit('expirationsUpdated', this.#expirations) } - ) - } - } catch (error) { - throw new Error('Failed to connect', { cause: error }) - } finally { - this.isConnecting = false + }, + }) + this.emit('connected', this.#expirations) } } disconnect() { - this.isConnected = false - this.emit('disconnected') - this.connectPromise = undefined - return Promise.resolve() + if (this.#unsubscribe) { + this.#unsubscribe() + this.#unsubscribe = undefined + this.emit('disconnected') + } } - async refresh(client: Client) { - const hash = await login(client, { - address: this.account.address, - permissions: this.permissions, - expiresAt: this.expiresAt ? BigInt(this.expiresAt) : undefined, - }) - return hash + /** + * Check if the session key has a permission. + * + * @param permission - {@link SessionKeyPermissions} + * @returns boolean + */ + hasPermission(permission: SessionKeyPermissions) { + return this.expirations[permission] > BigInt(Math.floor(Date.now() / 1000)) } - async isValid(client: Client, permission: SessionKeyPermissions) { - if (!this.permissions.includes(permission)) { - return false - } - if (this.connectPromise) { - await this.connectPromise - } - return isExpired(client, { - address: client.account.address, - sessionKeyAddress: this.account.address, - permission: permission, + /** + * Sync the expirations of the session key from the contract. + * + * @returns Promise + * @throws Errors {@link getExpirations.ErrorType} + */ + async syncExpirations() { + this.#expirations = await getExpirations(this.#client, { + address: this.#client.account.rootAddress, + sessionKeyAddress: this.#client.account.address, }) + this.emit('expirationsUpdated', this.#expirations) } +} - client(chain: Chain, transportConfig?: TransportConfig): Client { - if (!this.connected) { - throw new Error('Not connected') - } - return createWalletClient({ - chain, - transport: transportFromTransportConfig({ transportConfig }), - account: this.account, - }) +export interface FromSecp256k1Options { + privateKey: Hex + expirations?: Record + root: Account | Address + transport?: HttpTransport | WebSocketTransport | FallbackTransport + chain: Chain +} + +/** + * Create a session key from a secp256k1 private key. + * + * @param options - {@link FromSecp256k1Options} + * @returns SessionKey {@link SessionKey} + * + * @example + * ```ts + * import { SessionKey, Account } from '@filoz/synapse-core/session-key' + * import { mainnet } from '@filoz/synapse-core/chains' + * import type { Hex } from 'viem' + * + * const account = Account.fromSecp256k1({ + * privateKey: '0xaa14e25eaea762df1533e72394b85e56dd0c7aa61cf6df3b1f13a842ca0361e5' as Hex, + * rootAddress: '0x1234567890123456789012345678901234567890', + * }) + * const sessionKey = SessionKey.fromSecp256k1({ + * account, + * chain: mainnet, + * }) + * ``` + */ +export function fromSecp256k1(options: FromSecp256k1Options) { + const rootAddress = typeof options.root === 'string' ? options.root : options.root.address + + if (rootAddress === undefined) { + throw new Error('Root address is required') } + + const account = accountFromSecp256k1({ + privateKey: options.privateKey, + rootAddress: rootAddress, + }) + + const chain = asChain(options.chain) + + const client = createClient>({ + chain: chain, + transport: options.transport ?? http(), + account, + name: 'Secp256k1 Session Key', + key: 'secp256k1-session-key', + type: 'sessionClient', + }) + + return new Secp256k1SessionKey({ + client: client, + expirations: options.expirations ?? EMPTY_EXPIRATIONS, + }) } -export type WalletEvents = { - connected: CustomEvent - disconnected: CustomEvent - connectHash: CustomEvent - error: CustomEvent +export interface AccountFromSecp256k1Options { + privateKey: Hex + rootAddress: Address } -export interface SessionKey extends TypedEventTarget { - readonly connecting: boolean - readonly connected: boolean - readonly account: Account | undefined - readonly type: 'secp256k1' - - connect: (client: Client) => Promise - disconnect: () => Promise - refresh: (client: Client) => Promise - isValid: (client: Client, permission: SessionKeyPermissions) => Promise - client: (chain: Chain, transportConfig?: TransportConfig) => Client +/** + * Create a session key account from a secp256k1 private key. + * + * @param options - {@link AccountFromSecp256k1Options} + * @returns Account {@link SessionKeyAccount} + * + * @example + * ```ts + * import { Account } from '@filoz/synapse-core/session-key' + * import type { Hex } from 'viem' + * + * const account = Account.fromSecp256k1({ + * privateKey: '0xaa14e25eaea762df1533e72394b85e56dd0c7aa61cf6df3b1f13a842ca0361e5' as Hex, + * rootAddress: '0x1234567890123456789012345678901234567890', + * }) + * ``` + */ +export function accountFromSecp256k1(options: AccountFromSecp256k1Options) { + const account: SessionKeyAccount<'Secp256k1'> = { + ...privateKeyToAccount(options.privateKey), + source: 'sessionKey', + keyType: 'Secp256k1', + rootAddress: options.rootAddress, + } + return account } diff --git a/packages/synapse-core/src/session-key/types.ts b/packages/synapse-core/src/session-key/types.ts new file mode 100644 index 000000000..49c62ae23 --- /dev/null +++ b/packages/synapse-core/src/session-key/types.ts @@ -0,0 +1,36 @@ +import type { TypedEventTarget } from 'iso-web/event-target' +import type { Simplify } from 'type-fest' +import type { Account, Address, Client, CustomSource, LocalAccount, Transport } from 'viem' +import type { Chain as SynapseChain } from '../chains.ts' +import type { SessionKeyPermissions } from './permissions.ts' + +export type SessionKeyEvents = { + expirationsUpdated: CustomEvent> + connected: CustomEvent> + disconnected: CustomEvent + error: CustomEvent +} + +export type SessionKeyType = 'Secp256k1' | 'P-256' + +export type SessionKeyAccount = Simplify< + LocalAccount<'sessionKey'> & { + sign: NonNullable + signAuthorization: NonNullable + keyType: T + rootAddress: Address + } +> + +export interface SessionKey extends TypedEventTarget { + readonly client: Client> + readonly address: Address + readonly rootAddress: Address + readonly account: Account + readonly type: KeyType + readonly expirations: Record + hasPermission: (permission: SessionKeyPermissions) => boolean + syncExpirations: () => Promise + connect: () => void + disconnect: () => void +} From 2d6fef5c5b52b79e6ba4730d787bba0192fb3860 Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Thu, 19 Feb 2026 18:10:41 +0000 Subject: [PATCH 2/5] chore: remove old sessions from react --- .../synapse-react/src/warm-storage/use-delete-piece.ts | 10 +--------- packages/synapse-react/src/warm-storage/use-upload.ts | 10 +--------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/synapse-react/src/warm-storage/use-delete-piece.ts b/packages/synapse-react/src/warm-storage/use-delete-piece.ts index d9d34d8e8..b16e93fe0 100644 --- a/packages/synapse-react/src/warm-storage/use-delete-piece.ts +++ b/packages/synapse-react/src/warm-storage/use-delete-piece.ts @@ -1,5 +1,3 @@ -import { getChain } from '@filoz/synapse-core/chains' -import type { SessionKey } from '@filoz/synapse-core/session-key' import * as SP from '@filoz/synapse-core/sp' import type { PdpDataSet } from '@filoz/synapse-core/warm-storage' import { type MutateOptions, useMutation, useQueryClient } from '@tanstack/react-query' @@ -13,7 +11,6 @@ export interface UseDeletePieceProps { * The callback to call when the hash is available. */ onHash?: (hash: string) => void - sessionKey?: SessionKey mutation?: Omit, 'mutationFn'> } @@ -24,7 +21,6 @@ export interface UseDeletePieceVariables { export function useDeletePiece(props: UseDeletePieceProps) { const config = useConfig() const chainId = useChainId({ config }) - const chain = getChain(chainId) const account = useAccount({ config }) const queryClient = useQueryClient() const client = config.getClient() @@ -32,15 +28,11 @@ export function useDeletePiece(props: UseDeletePieceProps) { return useMutation({ ...props?.mutation, mutationFn: async ({ dataSet, pieceId }: UseDeletePieceVariables) => { - let connectorClient = await getConnectorClient(config, { + const connectorClient = await getConnectorClient(config, { account: account.address, chainId, }) - if (props?.sessionKey && (await props?.sessionKey.isValid(connectorClient, 'SchedulePieceRemovals'))) { - connectorClient = props?.sessionKey.client(chain, client.transport) - } - const deletePieceRsp = await SP.schedulePieceDeletion(connectorClient, { serviceURL: dataSet.provider.pdp.serviceURL, dataSetId: dataSet.dataSetId, diff --git a/packages/synapse-react/src/warm-storage/use-upload.ts b/packages/synapse-react/src/warm-storage/use-upload.ts index bcca3b37a..66be7a226 100644 --- a/packages/synapse-react/src/warm-storage/use-upload.ts +++ b/packages/synapse-react/src/warm-storage/use-upload.ts @@ -1,5 +1,3 @@ -import { getChain } from '@filoz/synapse-core/chains' -import type { SessionKey } from '@filoz/synapse-core/session-key' import * as SP from '@filoz/synapse-core/sp' import { type AddPiecesSuccess, upload } from '@filoz/synapse-core/sp' import { type MutateOptions, useMutation, useQueryClient } from '@tanstack/react-query' @@ -11,7 +9,6 @@ export interface UseUploadProps { * The callback to call when the hash is available. */ onHash?: (hash: string) => void - sessionKey?: SessionKey mutation?: Omit, 'mutationFn'> } @@ -22,21 +19,16 @@ export interface UseUploadVariables { export function useUpload(props: UseUploadProps) { const config = useConfig() const chainId = useChainId({ config }) - const chain = getChain(chainId) const account = useAccount({ config }) const queryClient = useQueryClient() - const client = config.getClient() return useMutation({ ...props?.mutation, mutationFn: async ({ files, dataSetId }: UseUploadVariables) => { - let connectorClient = await getConnectorClient(config, { + const connectorClient = await getConnectorClient(config, { account: account.address, chainId, }) - if (props?.sessionKey && (await props?.sessionKey.isValid(connectorClient, 'AddPieces'))) { - connectorClient = props?.sessionKey.client(chain, client.transport) - } const uploadRsp = await upload(connectorClient, { dataSetId, From 049457605324cfabcb13a51b1e16b72ef778042d Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Mon, 23 Feb 2026 19:46:43 +0000 Subject: [PATCH 3/5] chore: cli testing --- examples/cli/src/commands/datasets.ts | 2 +- examples/cli/src/commands/session-keys.ts | 90 +++++++++++++++++++ examples/cli/src/index.ts | 2 + .../synapse-core/src/session-key/secp256k1.ts | 1 + 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 examples/cli/src/commands/session-keys.ts diff --git a/examples/cli/src/commands/datasets.ts b/examples/cli/src/commands/datasets.ts index be9045ef1..fb32d3fe2 100644 --- a/examples/cli/src/commands/datasets.ts +++ b/examples/cli/src/commands/datasets.ts @@ -33,7 +33,7 @@ export const datasets: Command = command( spinner.stop('Data sets:') dataSets.forEach(async (dataSet) => { p.log.info( - `#${dataSet.dataSetId} ${dataSet.cdn ? 'CDN' : ''} ${dataSet.provider.pdp.serviceURL} ${dataSet.pdpEndEpoch > 0n ? `Terminating at epoch ${dataSet.pdpEndEpoch}` : ''} ${dataSet.live ? 'Live' : ''} ${dataSet.managed ? 'Managed' : ''}` + `#${dataSet.dataSetId} ${dataSet.provider.payee} ${dataSet.cdn ? 'CDN' : ''} ${dataSet.provider.pdp.serviceURL} ${dataSet.pdpEndEpoch > 0n ? `Terminating at epoch ${dataSet.pdpEndEpoch}` : ''} ${dataSet.live ? 'Live' : ''} ${dataSet.managed ? 'Managed' : ''}` ) }) p.log.warn(`Block number: ${blockNumber}`) diff --git a/examples/cli/src/commands/session-keys.ts b/examples/cli/src/commands/session-keys.ts new file mode 100644 index 000000000..eb2fd29bc --- /dev/null +++ b/examples/cli/src/commands/session-keys.ts @@ -0,0 +1,90 @@ +import * as p from '@clack/prompts' +import * as SessionKey from '@filoz/synapse-core/session-key' +import { createDataSet, waitForCreateDataSet } from '@filoz/synapse-core/sp' +import { type Command, command } from 'cleye' +import type { Hex } from 'viem' +import { privateKeyClient } from '../client.ts' +import { globalFlags } from '../flags.ts' +import { hashLink } from '../utils.ts' + +export const sessionKeys: Command = command( + { + name: 'session-keys', + description: 'Manage session keys', + alias: 'sk', + flags: { + ...globalFlags, + }, + help: { + description: 'Manage session keys', + }, + }, + async (argv) => { + const { client, chain } = privateKeyClient(argv.flags.chain) + + console.log('🚀 ~ client.account.address:', client.account.address) + + const sessionKey = SessionKey.fromSecp256k1({ + privateKey: + '0xaa14e25eaea762df1533e72394b85e56dd0c7aa61cf6df3b1f13a842ca0361e5' as Hex, + root: client.account, + chain, + }) + + sessionKey.on('expirationsUpdated', (e) => { + console.log('🚀 ~ expirations:', e.detail) + }) + sessionKey.on('connected', (e) => { + console.log('🚀 ~ connected:', e.detail) + }) + sessionKey.on('disconnected', () => { + console.log('🚀 ~ disconnected') + }) + sessionKey.on('error', (e) => { + console.log('🚀 ~ error:', e.detail) + }) + + const { event: loginEvent } = await SessionKey.loginSync(client, { + address: sessionKey.address, + onHash(hash) { + p.log.info(`Waiting for tx ${hashLink(hash, chain)} to be mined...`) + }, + }) + console.log('🚀 ~ event:', loginEvent.args) + + await sessionKey.connect() + + if (sessionKey.hasPermission('CreateDataSet')) { + const result = await createDataSet(sessionKey.client, { + payee: '0xa3971A7234a3379A1813d9867B531e7EeB20ae07', + payer: sessionKey.rootAddress, + serviceURL: 'https://calib.ezpdpz.net', + cdn: false, + }) + p.log.info( + `Waiting for tx ${hashLink(result.txHash, chain)} to be mined...` + ) + const dataset = await waitForCreateDataSet(result) + p.log.info(`Data set created #${dataset.dataSetId}`) + } else { + p.log.error('Session key does not have permission to create data set') + } + + // const { event: revokeEvent } = await SessionKey.revokeSync(client, { + // address: sessionKey.address, + // onHash(hash) { + // p.log.info(`Waiting for tx ${hashLink(hash, chain)} to be mined...`) + // }, + // }) + // console.log('🚀 ~ event revoked:', revokeEvent.args) + sessionKey.disconnect() + // try { + // } catch (error) { + // if (argv.flags.debug) { + // console.error(error) + // } else { + // p.log.error((error as Error).message) + // } + // } + } +) diff --git a/examples/cli/src/index.ts b/examples/cli/src/index.ts index 06fdcd015..9e9cfb99f 100755 --- a/examples/cli/src/index.ts +++ b/examples/cli/src/index.ts @@ -13,6 +13,7 @@ import { pay } from './commands/pay.ts' import { pieces } from './commands/pieces.ts' import { piecesRemoval } from './commands/pieces-removal.ts' import { piecesUpload } from './commands/pieces-upload.ts' +import { sessionKeys } from './commands/session-keys.ts' import { upload } from './commands/upload.ts' import { uploadDataset } from './commands/upload-dataset.ts' import { withdraw } from './commands/withdraw.ts' @@ -37,6 +38,7 @@ const argv = cli({ piecesUpload, uploadDataset, getSpPeerIds, + sessionKeys, ], }) diff --git a/packages/synapse-core/src/session-key/secp256k1.ts b/packages/synapse-core/src/session-key/secp256k1.ts index c45f86ce2..cd5d31210 100644 --- a/packages/synapse-core/src/session-key/secp256k1.ts +++ b/packages/synapse-core/src/session-key/secp256k1.ts @@ -77,6 +77,7 @@ class Secp256k1SessionKey extends TypedEventTarget implements abi: this.#client.chain.contracts.sessionKeyRegistry.abi, eventName: 'AuthorizationsUpdated', args: { identity: this.#client.account.rootAddress }, + onLogs: (logs) => { const event = extractLoginEvent(logs) if (event.args.identity === this.#client.account.rootAddress) { From adc3d6deea622b9708d5899194130e11eeb3ee37 Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Mon, 23 Feb 2026 21:36:51 +0000 Subject: [PATCH 4/5] feat: use hash based enum like session key permissions instead of strings, making it generic --- examples/cli/src/commands/session-keys.ts | 2 +- .../src/session-key/authorization-expiry.ts | 38 +++--- .../synapse-core/src/session-key/login.ts | 10 +- .../src/session-key/permissions.ts | 90 +++++++------ .../synapse-core/src/session-key/revoke.ts | 27 +--- .../synapse-core/src/session-key/secp256k1.ts | 33 +++-- .../synapse-core/src/session-key/types.ts | 12 +- .../test/authorization-expiry.test.ts | 24 ++-- packages/synapse-sdk/src/session/index.ts | 11 -- packages/synapse-sdk/src/session/key.ts | 123 ------------------ 10 files changed, 114 insertions(+), 256 deletions(-) delete mode 100644 packages/synapse-sdk/src/session/index.ts delete mode 100644 packages/synapse-sdk/src/session/key.ts diff --git a/examples/cli/src/commands/session-keys.ts b/examples/cli/src/commands/session-keys.ts index eb2fd29bc..e42c8d627 100644 --- a/examples/cli/src/commands/session-keys.ts +++ b/examples/cli/src/commands/session-keys.ts @@ -54,7 +54,7 @@ export const sessionKeys: Command = command( await sessionKey.connect() - if (sessionKey.hasPermission('CreateDataSet')) { + if (sessionKey.hasPermission(SessionKey.CreateDataSetPermission)) { const result = await createDataSet(sessionKey.client, { payee: '0xa3971A7234a3379A1813d9867B531e7EeB20ae07', payer: sessionKey.rootAddress, diff --git a/packages/synapse-core/src/session-key/authorization-expiry.ts b/packages/synapse-core/src/session-key/authorization-expiry.ts index 02b5fe310..292ce4690 100644 --- a/packages/synapse-core/src/session-key/authorization-expiry.ts +++ b/packages/synapse-core/src/session-key/authorization-expiry.ts @@ -14,12 +14,7 @@ import { multicall, readContract } from 'viem/actions' import type { sessionKeyRegistry as sessionKeyRegistryAbi } from '../abis/index.ts' import { asChain } from '../chains.ts' import type { ActionCallChain } from '../types.ts' -import { - ALL_PERMISSIONS, - EMPTY_EXPIRATIONS, - SESSION_KEY_PERMISSIONS, - type SessionKeyPermissions, -} from './permissions.ts' +import { DefaultFwssPermissions, type Expirations, type Permission } from './permissions.ts' export namespace authorizationExpiry { export type OptionsType = { @@ -28,7 +23,7 @@ export namespace authorizationExpiry { /** The address of the session key. */ sessionKeyAddress: Address /** The session key permission. */ - permission: SessionKeyPermissions + permission: Permission /** Session key registry contract address. If not provided, the default is the session key registry contract address for the chain. */ contractAddress?: Address } @@ -58,7 +53,7 @@ export namespace authorizationExpiry { * * @example * ```ts - * import { authorizationExpiry } from '@filoz/synapse-core/session-key' + * import { authorizationExpiry, CreateDataSetPermission } from '@filoz/synapse-core/session-key' * import { createPublicClient, http } from 'viem' * import { calibration } from '@filoz/synapse-core/chains' * @@ -70,7 +65,7 @@ export namespace authorizationExpiry { * const expiry = await authorizationExpiry(client, { * address: '0x1234567890123456789012345678901234567890', * sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - * permission: 'CreateDataSet', + * permission: CreateDataSetPermission, * }) * * console.log('Authorization expires at:', expiry) @@ -114,7 +109,7 @@ export namespace authorizationExpiryCall { * * @example * ```ts - * import { authorizationExpiryCall } from '@filoz/synapse-core/session-key' + * import { authorizationExpiryCall, CreateDataSetPermission } from '@filoz/synapse-core/session-key' * import { createPublicClient, http } from 'viem' * import { multicall } from 'viem/actions' * import { calibration } from '@filoz/synapse-core/chains' @@ -130,7 +125,7 @@ export namespace authorizationExpiryCall { * chain: calibration, * address: '0x1234567890123456789012345678901234567890', * sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - * permission: 'CreateDataSet', + * permission: CreateDataSetPermission, * }), * ], * }) @@ -144,7 +139,7 @@ export function authorizationExpiryCall(options: authorizationExpiryCall.Options abi: chain.contracts.sessionKeyRegistry.abi, address: options.contractAddress ?? chain.contracts.sessionKeyRegistry.address, functionName: 'authorizationExpiry', - args: [options.address, options.sessionKeyAddress, SESSION_KEY_PERMISSIONS[options.permission]], + args: [options.address, options.sessionKeyAddress, options.permission], } satisfies authorizationExpiryCall.OutputType } @@ -172,17 +167,19 @@ export async function isExpired( } export namespace getExpirations { - export type OptionsType = Simplify> + export type OptionsType = Simplify< + Omit & { permissions?: Permission[] } + > export type ErrorType = authorizationExpiry.ErrorType | MulticallErrorType - export type OutputType = Record + export type OutputType = Record } /** - * Get the expirations for all permissions. + * Get the expirations for all FWSS permissions. * * @param client - The client to use. * @param options - {@link getExpirations.OptionsType} - * @returns The expirations as a record of {@link SessionKeyPermissions} to {@link bigint} {@link getExpirations.OutputType} + * @returns Expirations {@link getExpirations.OutputType} * @throws Errors {@link getExpirations.ErrorType} * * @example @@ -204,12 +201,13 @@ export namespace getExpirations { * console.log(expirations) */ export async function getExpirations(client: Client, options: getExpirations.OptionsType) { - const expirations: Record = EMPTY_EXPIRATIONS + const permissions = options.permissions ?? DefaultFwssPermissions + const expirations: Expirations = Object.fromEntries(permissions.map((permission) => [permission, 0n])) try { const result = await multicall(client, { allowFailure: false, - contracts: ALL_PERMISSIONS.map((permission) => + contracts: permissions.map((permission) => authorizationExpiryCall({ chain: client.chain, address: options.address, @@ -219,8 +217,8 @@ export async function getExpirations(client: Client, options: ), }) - for (let i = 0; i < ALL_PERMISSIONS.length; i++) { - expirations[ALL_PERMISSIONS[i]] = result[i] + for (let i = 0; i < permissions.length; i++) { + expirations[permissions[i]] = result[i] } } catch (e) { if (!(e instanceof ContractFunctionExecutionError && e.details.includes('actor not found'))) { diff --git a/packages/synapse-core/src/session-key/login.ts b/packages/synapse-core/src/session-key/login.ts index 6cc7f5386..90fcd0976 100644 --- a/packages/synapse-core/src/session-key/login.ts +++ b/packages/synapse-core/src/session-key/login.ts @@ -18,14 +18,14 @@ import type { sessionKeyRegistry as sessionKeyRegistryAbi } from '../abis/index. import * as Abis from '../abis/index.ts' import { asChain } from '../chains.ts' import type { ActionCallChain, ActionSyncCallback, ActionSyncOutput } from '../types.ts' -import { ALL_PERMISSIONS, SESSION_KEY_PERMISSIONS, type SessionKeyPermissions } from './permissions.ts' +import { DefaultFwssPermissions, type Permission } from './permissions.ts' export namespace login { export type OptionsType = { /** Session key address. */ address: Address - /** The permissions to authorize for the session key. Defaults to all permissions. */ - permissions?: SessionKeyPermissions[] + /** The permissions to authorize for the session key. Defaults to all FWSS permissions. */ + permissions?: Permission[] /** The expiry time as Unix timestamp (seconds). Defaults to now + 1 hour. */ expiresAt?: bigint /** The origin of the session key authorization. Defaults to 'synapse'. */ @@ -116,7 +116,7 @@ export namespace loginCall { export function loginCall(options: loginCall.OptionsType) { const chain = asChain(options.chain) const expiresAt = BigInt(Math.floor(Date.now() / 1000) + 3600) - const permissions = options.permissions ?? ALL_PERMISSIONS + const permissions = options.permissions ?? DefaultFwssPermissions return { abi: chain.contracts.sessionKeyRegistry.abi, address: options.contractAddress ?? chain.contracts.sessionKeyRegistry.address, @@ -124,7 +124,7 @@ export function loginCall(options: loginCall.OptionsType) { args: [ options.address, options.expiresAt ?? expiresAt, - [...new Set(permissions)].map((permission) => SESSION_KEY_PERMISSIONS[permission]), + Array.from(new Set(permissions)), options.origin ?? 'synapse', ], } satisfies loginCall.OutputType diff --git a/packages/synapse-core/src/session-key/permissions.ts b/packages/synapse-core/src/session-key/permissions.ts index de1295269..6fd4dc39f 100644 --- a/packages/synapse-core/src/session-key/permissions.ts +++ b/packages/synapse-core/src/session-key/permissions.ts @@ -1,61 +1,59 @@ import { TypedData } from 'ox' +import type { Tagged } from 'type-fest' import type { Hex } from 'viem' import { keccak256, stringToHex } from 'viem' import { EIP712Types } from '../typed-data/type-definitions.ts' -export type SessionKeyPermissions = 'CreateDataSet' | 'AddPieces' | 'SchedulePieceRemovals' | 'DeleteDataSet' +export type CreateDataSetPermission = Tagged +export type AddPiecesPermission = Tagged +export type SchedulePieceRemovalsPermission = Tagged +export type DeleteDataSetPermission = Tagged function typeHash(type: TypedData.encodeType.Value) { return keccak256(stringToHex(TypedData.encodeType(type))) } -export const EMPTY_EXPIRATIONS: Record = { - CreateDataSet: 0n, - AddPieces: 0n, - SchedulePieceRemovals: 0n, - DeleteDataSet: 0n, -} +export const CreateDataSetPermission = typeHash({ + types: EIP712Types, + primaryType: 'CreateDataSet', +}) as CreateDataSetPermission -export const ALL_PERMISSIONS: SessionKeyPermissions[] = [ - 'CreateDataSet', - 'AddPieces', - 'SchedulePieceRemovals', - 'DeleteDataSet', -] - -/** - * Session key permissions type hash map - */ -export const SESSION_KEY_PERMISSIONS: Record = { - CreateDataSet: typeHash({ - types: EIP712Types, - primaryType: 'CreateDataSet', - }), - AddPieces: typeHash({ - types: EIP712Types, - primaryType: 'AddPieces', - }), - SchedulePieceRemovals: typeHash({ - types: EIP712Types, - primaryType: 'SchedulePieceRemovals', - }), - DeleteDataSet: typeHash({ - types: EIP712Types, - primaryType: 'DeleteDataSet', - }), -} +export const AddPiecesPermission = typeHash({ + types: EIP712Types, + primaryType: 'AddPieces', +}) as AddPiecesPermission + +export const SchedulePieceRemovalsPermission = typeHash({ + types: EIP712Types, + primaryType: 'SchedulePieceRemovals', +}) as SchedulePieceRemovalsPermission + +export const DeleteDataSetPermission = typeHash({ + types: EIP712Types, + primaryType: 'DeleteDataSet', +}) as DeleteDataSetPermission + +export const DefaultFwssPermissions = [ + CreateDataSetPermission, + AddPiecesPermission, + SchedulePieceRemovalsPermission, + DeleteDataSetPermission, +] as const + +export type Permission = + | CreateDataSetPermission + | AddPiecesPermission + | SchedulePieceRemovalsPermission + | DeleteDataSetPermission + | Hex -export const TYPE_HASH_TO_PERMISSION: Record = { - [SESSION_KEY_PERMISSIONS.CreateDataSet]: 'CreateDataSet', - [SESSION_KEY_PERMISSIONS.AddPieces]: 'AddPieces', - [SESSION_KEY_PERMISSIONS.SchedulePieceRemovals]: 'SchedulePieceRemovals', - [SESSION_KEY_PERMISSIONS.DeleteDataSet]: 'DeleteDataSet', +export type Expirations = { + [key in Permission]: bigint } -export function getPermissionFromTypeHash(typeHash: Hex): SessionKeyPermissions { - const permission = TYPE_HASH_TO_PERMISSION[typeHash] - if (!permission) { - throw new Error(`Permission not found for type hash: ${typeHash}`) - } - return permission +export const DefaultEmptyExpirations: Expirations = { + [CreateDataSetPermission]: 0n, + [AddPiecesPermission]: 0n, + [SchedulePieceRemovalsPermission]: 0n, + [DeleteDataSetPermission]: 0n, } diff --git a/packages/synapse-core/src/session-key/revoke.ts b/packages/synapse-core/src/session-key/revoke.ts index 2ce3921d3..227d993f6 100644 --- a/packages/synapse-core/src/session-key/revoke.ts +++ b/packages/synapse-core/src/session-key/revoke.ts @@ -6,26 +6,24 @@ import type { Client, ContractFunctionParameters, Hash, - Log, SimulateContractErrorType, Transport, WaitForTransactionReceiptErrorType, WriteContractErrorType, } from 'viem' -import { parseEventLogs } from 'viem' import { simulateContract, waitForTransactionReceipt, writeContract } from 'viem/actions' import type { sessionKeyRegistry as sessionKeyRegistryAbi } from '../abis/index.ts' -import * as Abis from '../abis/index.ts' import { asChain } from '../chains.ts' import type { ActionCallChain, ActionSyncCallback, ActionSyncOutput } from '../types.ts' -import { ALL_PERMISSIONS, SESSION_KEY_PERMISSIONS, type SessionKeyPermissions } from './permissions.ts' +import { extractLoginEvent } from './login.ts' +import { DefaultFwssPermissions, type Permission } from './permissions.ts' export namespace revoke { export type OptionsType = { /** Session key address. */ address: Address /** The permissions to revoke from the session key. Defaults to all permissions. */ - permissions?: SessionKeyPermissions[] + permissions?: Permission[] /** The origin of the revoke operation. Defaults to 'synapse'. */ origin?: string /** Session key registry contract address. If not provided, defaults to the chain contract address. */ @@ -112,16 +110,12 @@ export namespace revokeCall { */ export function revokeCall(options: revokeCall.OptionsType) { const chain = asChain(options.chain) - const permissions = options.permissions ?? ALL_PERMISSIONS + const permissions = options.permissions ?? DefaultFwssPermissions return { abi: chain.contracts.sessionKeyRegistry.abi, address: options.contractAddress ?? chain.contracts.sessionKeyRegistry.address, functionName: 'revoke', - args: [ - options.address, - [...new Set(permissions)].map((permission) => SESSION_KEY_PERMISSIONS[permission]), - options.origin ?? 'synapse', - ], + args: [options.address, Array.from(new Set(permissions)), options.origin ?? 'synapse'], } satisfies revokeCall.OutputType } @@ -131,13 +125,4 @@ export function revokeCall(options: revokeCall.OptionsType) { * @param logs - The transaction logs. * @returns The AuthorizationsUpdated event. */ -export function extractRevokeEvent(logs: Log[]) { - const [log] = parseEventLogs({ - abi: Abis.sessionKeyRegistry, - logs, - eventName: 'AuthorizationsUpdated', - strict: true, - }) - if (!log) throw new Error('`AuthorizationsUpdated` event not found.') - return log -} +export const extractRevokeEvent = extractLoginEvent diff --git a/packages/synapse-core/src/session-key/secp256k1.ts b/packages/synapse-core/src/session-key/secp256k1.ts index cd5d31210..36128e3e7 100644 --- a/packages/synapse-core/src/session-key/secp256k1.ts +++ b/packages/synapse-core/src/session-key/secp256k1.ts @@ -16,12 +16,12 @@ import { watchContractEvent } from 'viem/actions' import { asChain, type Chain as SynapseChain } from '../chains.ts' import { getExpirations } from './authorization-expiry.ts' import { extractLoginEvent } from './login.ts' -import { EMPTY_EXPIRATIONS, getPermissionFromTypeHash, type SessionKeyPermissions } from './permissions.ts' +import { DefaultEmptyExpirations, type Expirations, type Permission } from './permissions.ts' import type { SessionKey, SessionKeyAccount, SessionKeyEvents } from './types.ts' interface Secp256k1SessionKeyOptions { client: Client> - expirations: Record + expirations: Expirations } /** @@ -30,7 +30,7 @@ interface Secp256k1SessionKeyOptions { class Secp256k1SessionKey extends TypedEventTarget implements SessionKey<'Secp256k1'> { #client: Client> #type: 'Secp256k1' - #expirations: Record + #expirations: Expirations #unsubscribe: WatchContractEventReturnType | undefined /** @@ -79,13 +79,16 @@ class Secp256k1SessionKey extends TypedEventTarget implements args: { identity: this.#client.account.rootAddress }, onLogs: (logs) => { - const event = extractLoginEvent(logs) - if (event.args.identity === this.#client.account.rootAddress) { - for (const hash of event.args.permissions) { - const permission = getPermissionFromTypeHash(hash) - this.expirations[permission] = event.args.expiry + try { + const event = extractLoginEvent(logs) + if (event.args.identity === this.#client.account.rootAddress) { + for (const hash of event.args.permissions) { + this.#expirations[hash] = event.args.expiry + } + this.emit('expirationsUpdated', this.#expirations) } - this.emit('expirationsUpdated', this.#expirations) + } catch (error) { + this.emit('error', error as Error) } }, }) @@ -104,23 +107,25 @@ class Secp256k1SessionKey extends TypedEventTarget implements /** * Check if the session key has a permission. * - * @param permission - {@link SessionKeyPermissions} + * @param permission - {@link Permission} * @returns boolean */ - hasPermission(permission: SessionKeyPermissions) { + hasPermission(permission: Permission) { return this.expirations[permission] > BigInt(Math.floor(Date.now() / 1000)) } /** * Sync the expirations of the session key from the contract. * + * @param permissions - The permissions to sync the expirations for. Defaults to all FWSS permissions. * @returns Promise * @throws Errors {@link getExpirations.ErrorType} */ - async syncExpirations() { + async syncExpirations(permissions?: Permission[]) { this.#expirations = await getExpirations(this.#client, { address: this.#client.account.rootAddress, sessionKeyAddress: this.#client.account.address, + permissions: permissions, }) this.emit('expirationsUpdated', this.#expirations) } @@ -128,7 +133,7 @@ class Secp256k1SessionKey extends TypedEventTarget implements export interface FromSecp256k1Options { privateKey: Hex - expirations?: Record + expirations?: Expirations root: Account | Address transport?: HttpTransport | WebSocketTransport | FallbackTransport chain: Chain @@ -181,7 +186,7 @@ export function fromSecp256k1(options: FromSecp256k1Options) { return new Secp256k1SessionKey({ client: client, - expirations: options.expirations ?? EMPTY_EXPIRATIONS, + expirations: options.expirations ?? DefaultEmptyExpirations, }) } diff --git a/packages/synapse-core/src/session-key/types.ts b/packages/synapse-core/src/session-key/types.ts index 49c62ae23..7925e404d 100644 --- a/packages/synapse-core/src/session-key/types.ts +++ b/packages/synapse-core/src/session-key/types.ts @@ -2,11 +2,11 @@ import type { TypedEventTarget } from 'iso-web/event-target' import type { Simplify } from 'type-fest' import type { Account, Address, Client, CustomSource, LocalAccount, Transport } from 'viem' import type { Chain as SynapseChain } from '../chains.ts' -import type { SessionKeyPermissions } from './permissions.ts' +import type { Expirations, Permission } from './permissions.ts' export type SessionKeyEvents = { - expirationsUpdated: CustomEvent> - connected: CustomEvent> + expirationsUpdated: CustomEvent + connected: CustomEvent disconnected: CustomEvent error: CustomEvent } @@ -28,9 +28,9 @@ export interface SessionKey extends TypedEventTa readonly rootAddress: Address readonly account: Account readonly type: KeyType - readonly expirations: Record - hasPermission: (permission: SessionKeyPermissions) => boolean + readonly expirations: Expirations + hasPermission: (permission: Permission) => boolean syncExpirations: () => Promise - connect: () => void + connect: () => Promise disconnect: () => void } diff --git a/packages/synapse-core/test/authorization-expiry.test.ts b/packages/synapse-core/test/authorization-expiry.test.ts index 9ff282ff6..20409bfd0 100644 --- a/packages/synapse-core/test/authorization-expiry.test.ts +++ b/packages/synapse-core/test/authorization-expiry.test.ts @@ -4,6 +4,12 @@ import { createPublicClient, http } from 'viem' import { calibration, mainnet } from '../src/chains.ts' import { JSONRPC, presets } from '../src/mocks/jsonrpc/index.ts' import { authorizationExpiry, authorizationExpiryCall } from '../src/session-key/authorization-expiry.ts' +import { + AddPiecesPermission, + CreateDataSetPermission, + DeleteDataSetPermission, + SchedulePieceRemovalsPermission, +} from '../src/session-key/permissions.ts' describe('authorizationExpiry', () => { const server = setup() @@ -26,7 +32,7 @@ describe('authorizationExpiry', () => { chain: calibration, address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'CreateDataSet', + permission: CreateDataSetPermission, }) assert.equal(call.functionName, 'authorizationExpiry') @@ -43,7 +49,7 @@ describe('authorizationExpiry', () => { chain: mainnet, address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'AddPieces', + permission: AddPiecesPermission, }) assert.equal(call.functionName, 'authorizationExpiry') @@ -58,7 +64,7 @@ describe('authorizationExpiry', () => { chain: calibration, address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'SchedulePieceRemovals', + permission: SchedulePieceRemovalsPermission, contractAddress: customAddress, }) @@ -70,7 +76,7 @@ describe('authorizationExpiry', () => { chain: calibration, address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'DeleteDataSet', + permission: DeleteDataSetPermission, }) assert.ok(typeof call.args[2] === 'string') @@ -91,7 +97,7 @@ describe('authorizationExpiry', () => { const expiry = await authorizationExpiry(client, { address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'CreateDataSet', + permission: CreateDataSetPermission, }) assert.equal(typeof expiry, 'bigint') @@ -118,7 +124,7 @@ describe('authorizationExpiry', () => { const expiry = await authorizationExpiry(client, { address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'AddPieces', + permission: AddPiecesPermission, }) assert.equal(expiry, customExpiry) @@ -150,13 +156,13 @@ describe('authorizationExpiry', () => { const expirySchedule = await authorizationExpiry(client, { address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'SchedulePieceRemovals', + permission: SchedulePieceRemovalsPermission, }) const expiryDelete = await authorizationExpiry(client, { address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'DeleteDataSet', + permission: DeleteDataSetPermission, }) assert.equal(expirySchedule, expiry1) @@ -182,7 +188,7 @@ describe('authorizationExpiry', () => { const expiry = await authorizationExpiry(client, { address: '0x1234567890123456789012345678901234567890', sessionKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', - permission: 'CreateDataSet', + permission: CreateDataSetPermission, }) assert.equal(expiry, 0n) diff --git a/packages/synapse-sdk/src/session/index.ts b/packages/synapse-sdk/src/session/index.ts deleted file mode 100644 index df8337c05..000000000 --- a/packages/synapse-sdk/src/session/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Session components - * - * @module Session - * @example - * ```ts - * import { SessionKey } from '@filoz/synapse-sdk/session' - * ``` - */ - -export * from './key.ts' diff --git a/packages/synapse-sdk/src/session/key.ts b/packages/synapse-sdk/src/session/key.ts deleted file mode 100644 index a9f8c3340..000000000 --- a/packages/synapse-sdk/src/session/key.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * SessionKey - Tracks the user's approval of a session key - * - * Session keys allow the user to authorize an app to take actions on - * their behalf without prompting their wallet for signatures. - * Session keys have a scope and an expiration. - * Session keys should be generated on the user's computer and persisted - * in a safe place or discarded. - * - * @example - * ```typescript - * const sessionKey = synapse.createSessionkey(privateKey) - * const expiries = await sessionKey.fetchExpiries([ADD_PIECES_TYPEHASH]) - * if (expiries[ADD_PIECES_TYPEHASH] * BigInt(1000) < BigInt(Date.now()) + HOUR_MILLIS) { - * const DAY_MILLIS = BigInt(24) * HOUR_MILLIS - * const loginTx = await sessionKey.login(BigInt(Date.now()) / BigInt(1000 + 30 * DAY_MILLIS), PDP_PERMISSIONS, "example.com") - * const loginReceipt = await loginTx.wait() - * } - * synapse.setSession(sessionKey) - * const context = await synapse.storage.createContext() - * ``` - */ - -import { asChain } from '@filoz/synapse-core/chains' -import * as SK from '@filoz/synapse-core/session-key' -import { type Account, type Chain, type Client, createWalletClient, type Hash, http, type Transport } from 'viem' -import { multicall } from 'viem/actions' - -const DEFAULT_ORIGIN: string = (globalThis as any).location?.hostname || 'unknown' - -export class SessionKey { - private readonly _ownerClient: Client - private readonly _client: Client - private readonly _account: Account - private readonly _chain: Chain - - public constructor(client: Client, account: Account) { - this._ownerClient = client - this._account = account - this._chain = asChain(client.chain) - this._client = createWalletClient({ - chain: this._chain, - transport: http(), - account: this._account, - }) - } - - get account(): Account { - return this._account - } - - get chain(): Chain { - return this._chain - } - - get client(): Client { - return this._client - } - - /** - * Queries current permission expiries from the registry - * @param permissions Expiries to fetch, as a list of bytes32 hex strings - * @return map of each permission to its expiry for this session key - */ - async fetchExpiries( - permissions: SK.SessionKeyPermissions[] = SK.ALL_PERMISSIONS - ): Promise> { - const expiries: Record = {} - const result = await multicall(this._ownerClient, { - allowFailure: false, - contracts: permissions.map((permission) => - SK.authorizationExpiryCall({ - chain: this._chain, - address: this._ownerClient.account.address, - sessionKeyAddress: this._account.address, - permission, - }) - ), - }) - - for (let i = 0; i < permissions.length; i++) { - expiries[permissions[i]] = result[i] - } - - return expiries - } - - /** - * Authorize signer with permissions until expiry. This can also be used to - * renew existing authorization by updating the expiry. - * - * @param expiry unix time (block.timestamp) that the permissions expire - * @param permissions list of permissions granted to the signer, as a list of bytes32 hex strings - * @param origin the name of the application prompting this login - * @return signed and broadcasted login transaction details - */ - async login( - expiry: bigint, - permissions: SK.SessionKeyPermissions[] = SK.ALL_PERMISSIONS, - origin = DEFAULT_ORIGIN - ): Promise { - return await SK.login(this._ownerClient, { - address: this._account.address, - expiresAt: expiry, - permissions, - origin, - }) - } - - /** - * Invalidate signer permissions, setting their expiry to zero. - * - * @param permissions list of permissions removed from the signer, as a list of bytes32 hex strings - * @return signed and broadcasted revoke transaction details - */ - async revoke(permissions: SK.SessionKeyPermissions[] = SK.ALL_PERMISSIONS, origin = DEFAULT_ORIGIN): Promise { - return await SK.revoke(this._ownerClient, { - address: this._account.address, - permissions, - origin, - }) - } -} From 8ba72b0fb76d35453f9ec01fc46150ef6e201e5a Mon Sep 17 00:00:00 2001 From: Hugo Dias Date: Tue, 24 Feb 2026 13:01:11 +0000 Subject: [PATCH 5/5] feat: wire back session keys --- .../src/session-key/permissions.ts | 2 +- .../synapse-core/src/session-key/secp256k1.ts | 10 + .../synapse-core/src/session-key/types.ts | 1 + packages/synapse-sdk/src/storage/context.ts | 7 +- packages/synapse-sdk/src/synapse.ts | 17 +- .../synapse-sdk/src/test/session-keys.test.ts | 210 ++++++++++++++++++ packages/synapse-sdk/src/test/storage.test.ts | 6 - packages/synapse-sdk/src/test/synapse.test.ts | 103 --------- packages/synapse-sdk/src/types.ts | 5 + 9 files changed, 246 insertions(+), 115 deletions(-) create mode 100644 packages/synapse-sdk/src/test/session-keys.test.ts diff --git a/packages/synapse-core/src/session-key/permissions.ts b/packages/synapse-core/src/session-key/permissions.ts index 6fd4dc39f..e5df0c682 100644 --- a/packages/synapse-core/src/session-key/permissions.ts +++ b/packages/synapse-core/src/session-key/permissions.ts @@ -38,7 +38,7 @@ export const DefaultFwssPermissions = [ AddPiecesPermission, SchedulePieceRemovalsPermission, DeleteDataSetPermission, -] as const +] export type Permission = | CreateDataSetPermission diff --git a/packages/synapse-core/src/session-key/secp256k1.ts b/packages/synapse-core/src/session-key/secp256k1.ts index 36128e3e7..5fad5a742 100644 --- a/packages/synapse-core/src/session-key/secp256k1.ts +++ b/packages/synapse-core/src/session-key/secp256k1.ts @@ -114,6 +114,16 @@ class Secp256k1SessionKey extends TypedEventTarget implements return this.expirations[permission] > BigInt(Math.floor(Date.now() / 1000)) } + /** + * Check if the session key has all the permissions. + * + * @param permissions - {@link Permission} + * @returns boolean - True if the session key has all the permissions, false otherwise. + */ + hasPermissions(permissions: Permission[]) { + return permissions.every((permission) => this.hasPermission(permission)) + } + /** * Sync the expirations of the session key from the contract. * diff --git a/packages/synapse-core/src/session-key/types.ts b/packages/synapse-core/src/session-key/types.ts index 7925e404d..b1b066d63 100644 --- a/packages/synapse-core/src/session-key/types.ts +++ b/packages/synapse-core/src/session-key/types.ts @@ -30,6 +30,7 @@ export interface SessionKey extends TypedEventTa readonly type: KeyType readonly expirations: Expirations hasPermission: (permission: Permission) => boolean + hasPermissions: (permissions: Permission[]) => boolean syncExpirations: () => Promise connect: () => Promise disconnect: () => void diff --git a/packages/synapse-sdk/src/storage/context.ts b/packages/synapse-sdk/src/storage/context.ts index 493a6db78..fc10e0db0 100644 --- a/packages/synapse-sdk/src/storage/context.ts +++ b/packages/synapse-sdk/src/storage/context.ts @@ -1046,7 +1046,7 @@ export class StorageContext { this.getClientDataSetId(), ]) // Add pieces to the data set - const addPiecesResult = await SP.addPieces(this._client, { + const addPiecesResult = await SP.addPieces(this._synapse.sessionClient ?? this._client, { dataSetId: this.dataSetId, // PDPVerifier data set ID clientDataSetId, // Client's dataset nonce pieces: batch.map((item) => ({ pieceCid: item.pieceCid, metadata: item.metadata })), @@ -1072,7 +1072,7 @@ export class StorageContext { }) } else { // Create a new data set and add pieces to it - const result = await SP.createDataSetAndAddPieces(this._client, { + const result = await SP.createDataSetAndAddPieces(this._synapse.sessionClient ?? this._client, { cdn: this._withCDN, payee: this._provider.serviceProvider, payer: this._client.account.address, @@ -1240,9 +1240,10 @@ export class StorageContext { throw createError('StorageContext', 'deletePiece', 'Data set not found') } const pieceId = typeof piece === 'bigint' ? piece : await this._getPieceIdByCID(piece) + const clientDataSetId = await this.getClientDataSetId() - const { hash } = await schedulePieceDeletion(this._synapse.client, { + const { hash } = await schedulePieceDeletion(this._synapse.sessionClient ?? this._synapse.client, { serviceURL: this._pdpEndpoint, dataSetId: this.dataSetId, pieceId: pieceId, diff --git a/packages/synapse-sdk/src/synapse.ts b/packages/synapse-sdk/src/synapse.ts index 8375efe7b..db2dfcda5 100644 --- a/packages/synapse-sdk/src/synapse.ts +++ b/packages/synapse-sdk/src/synapse.ts @@ -1,4 +1,6 @@ import { asChain, type Chain } from '@filoz/synapse-core/chains' +import type { SessionKeyAccount } from '@filoz/synapse-core/session-key' +import * as SessionKey from '@filoz/synapse-core/session-key' import { type Account, type Address, @@ -31,10 +33,12 @@ export class Synapse { private readonly _providers: SPRegistryService private readonly _client: Client> + private readonly _sessionClient: Client> | undefined private readonly _chain: Chain /** - * Create a new Synapse instance with async initialization. + * Create a new Synapse instance. + * * @param options - Configuration options for Synapse * @returns A fully initialized Synapse instance */ @@ -53,11 +57,16 @@ export class Synapse { throw new Error('Transport must be a custom transport. See https://viem.sh/docs/clients/transports/custom.') } - return new Synapse({ client, withCDN: options.withCDN }) + if (options.sessionKey != null && !options.sessionKey.hasPermissions(SessionKey.DefaultFwssPermissions)) { + throw new Error('Session key does not have the required permissions. Please login with the session key first.') + } + + return new Synapse({ client, withCDN: options.withCDN, sessionClient: options.sessionKey?.client }) } public constructor(options: SynapseFromClientOptions) { this._client = options.client.extend(publicActions) + this._sessionClient = options.sessionClient this._chain = asChain(options.client.chain) this._withCDN = options.withCDN ?? false this._providers = new SPRegistryService({ client: options.client }) @@ -77,6 +86,10 @@ export class Synapse { return this._client } + get sessionClient(): Client> | undefined { + return this._sessionClient + } + get chain(): Chain { return this._chain } diff --git a/packages/synapse-sdk/src/test/session-keys.test.ts b/packages/synapse-sdk/src/test/session-keys.test.ts new file mode 100644 index 000000000..3ebad3222 --- /dev/null +++ b/packages/synapse-sdk/src/test/session-keys.test.ts @@ -0,0 +1,210 @@ +/* globals describe it beforeEach */ + +/** + * Basic tests for Synapse class + */ + +import { calibration } from '@filoz/synapse-core/chains' +import * as Mocks from '@filoz/synapse-core/mocks' +import * as SessionKey from '@filoz/synapse-core/session-key' +import * as TypedData from '@filoz/synapse-core/typed-data' +import { assert } from 'chai' + +import { setup } from 'iso-web/msw' +import { HttpResponse, http } from 'msw' +import { createWalletClient, decodeAbiParameters, recoverTypedDataAddress, http as viemHttp } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { Synapse } from '../synapse.ts' + +// mock server for testing +const server = setup() + +const account = privateKeyToAccount(Mocks.PRIVATE_KEYS.key1) +const client = createWalletClient({ + chain: calibration, + transport: viemHttp(), + account, +}) + +describe('Synapse', () => { + before(async () => { + await server.start() + }) + + after(() => { + server.stop() + }) + beforeEach(() => { + server.resetHandlers() + }) + + describe('Session Keys', () => { + const FAKE_TX_HASH = '0x3816d82cb7a6f5cde23f4d63c0763050d13c6b6dc659d0a7e6eba80b0ec76a18' + beforeEach(() => { + server.use(Mocks.PING()) + }) + + it('should create dataset with session key', async () => { + server.use( + Mocks.JSONRPC({ + ...Mocks.presets.basic, + debug: false, + warmStorageView: { + ...Mocks.presets.basic.warmStorageView, + getApprovedProviders: () => [[1n]], + }, + }), + ...Mocks.pdp.streamingUploadHandlers(), + Mocks.pdp.findAnyPieceHandler(true), + Mocks.pdp.dataSetCreationStatusHandler(FAKE_TX_HASH, { + createMessageHash: FAKE_TX_HASH, + dataSetCreated: true, + service: 'test-service', + txStatus: 'confirmed', + ok: true, + dataSetId: 123, + }), + Mocks.pdp.pieceAdditionStatusHandler(123, FAKE_TX_HASH, { + txHash: FAKE_TX_HASH, + txStatus: 'confirmed', + dataSetId: 123, + pieceCount: 1, + addMessageOk: true, + piecesAdded: true, + confirmedPieceIds: [0], + }), + http.post(`https://pdp.example.com/pdp/data-sets/create-and-add`, async ({ request }) => { + const body = (await request.json()) as any + const decoded = decodeAbiParameters([{ type: 'bytes' }, { type: 'bytes' }], body.extraData) + const createDataSetDecoded = decodeAbiParameters(TypedData.signCreateDataSetAbiParameters, decoded[0]) + + const actualPayer = createDataSetDecoded[0] + const clientDataSetId = createDataSetDecoded[1] + const signature = createDataSetDecoded[4] + + const actualSigner = await recoverTypedDataAddress({ + domain: TypedData.getStorageDomain({ chain: calibration }), + types: TypedData.EIP712Types, + primaryType: 'CreateDataSet', + message: { + clientDataSetId, + payee: Mocks.ADDRESSES.serviceProvider1, + metadata: [], + }, + signature, + }) + + assert.equal(actualPayer, client.account.address) + assert.equal(actualSigner, sessionKey.account.address) + return new HttpResponse(null, { + status: 201, + headers: { Location: `/pdp/data-sets/created/${FAKE_TX_HASH}` }, + }) + }) + ) + const sessionKey = SessionKey.fromSecp256k1({ + chain: calibration, + privateKey: Mocks.PRIVATE_KEYS.key2, + root: client.account, + }) + const synapse = new Synapse({ client, sessionClient: sessionKey.client }) + const firstData = new Uint8Array(127).fill(1) // 127 bytes + await synapse.storage.upload(firstData, { + forceCreateDataSet: true, + providerAddress: Mocks.ADDRESSES.serviceProvider1, + }) + }) + + it('should schedule deletion with session key', async () => { + const pieceCid = 'bafkzcibcaabffs4jcd4iheeo5wisbmurjb7l4xgpmzgyzrenebvjjhsbwgx4smy' + server.use( + Mocks.JSONRPC({ + ...Mocks.presets.basic, + debug: false, + warmStorageView: { + ...Mocks.presets.basic.warmStorageView, + getApprovedProviders: () => [[1n]], + }, + }), + http.get(`https://pdp.example.com/pdp/data-sets/:id`, ({ params }) => { + return HttpResponse.json( + { + id: Number(params.id), + nextChallengeEpoch: 5000, + pieces: [ + { + pieceCid: pieceCid, + pieceId: 0, + subPieceCid: pieceCid, + subPieceOffset: 0, + }, + ], + }, + { status: 200 } + ) + }), + ...Mocks.pdp.streamingUploadHandlers(), + Mocks.pdp.findAnyPieceHandler(true), + Mocks.pdp.dataSetCreationStatusHandler(FAKE_TX_HASH, { + createMessageHash: FAKE_TX_HASH, + dataSetCreated: true, + service: 'test-service', + txStatus: 'confirmed', + ok: true, + dataSetId: 1, + }), + Mocks.pdp.pieceAdditionStatusHandler(1, FAKE_TX_HASH, { + txHash: FAKE_TX_HASH, + txStatus: 'confirmed', + dataSetId: 1, + pieceCount: 1, + addMessageOk: true, + piecesAdded: true, + confirmedPieceIds: [0], + }), + http.post(`https://pdp.example.com/pdp/data-sets/create-and-add`, () => { + return new HttpResponse(null, { + status: 201, + headers: { Location: `/pdp/data-sets/created/${FAKE_TX_HASH}` }, + }) + }), + http.delete<{ id: string; pieceId: string }>( + `https://pdp.example.com/pdp/data-sets/:id/pieces/:pieceId`, + async ({ request }) => { + const body = (await request.json()) as any + const decoded = decodeAbiParameters([{ type: 'bytes' }], body.extraData) + const actualSigner = await recoverTypedDataAddress({ + domain: TypedData.getStorageDomain({ chain: calibration }), + types: TypedData.EIP712Types, + primaryType: 'SchedulePieceRemovals', + message: { + clientDataSetId: 0n, + pieceIds: [0n], + }, + signature: decoded[0], + }) + + assert.equal(actualSigner, sessionKey.account.address) + return HttpResponse.json( + { + txHash: FAKE_TX_HASH, + }, + { status: 200 } + ) + } + ) + ) + const sessionKey = SessionKey.fromSecp256k1({ + chain: calibration, + privateKey: Mocks.PRIVATE_KEYS.key2, + root: client.account, + }) + const synapse = new Synapse({ client, sessionClient: sessionKey.client }) + const firstData = new Uint8Array(127).fill(1) // 127 bytes + const context = await synapse.storage.getDefaultContext() + const result = await context.upload(firstData) + + await context.deletePiece({ piece: result.pieceCid }) + }) + }) +}) diff --git a/packages/synapse-sdk/src/test/storage.test.ts b/packages/synapse-sdk/src/test/storage.test.ts index 94336d415..194abcadd 100644 --- a/packages/synapse-sdk/src/test/storage.test.ts +++ b/packages/synapse-sdk/src/test/storage.test.ts @@ -286,12 +286,6 @@ describe('StorageService', () => { assert.equal(service.dataSetId, 1n) }) - it.skip('should create new data set when none exist', async () => { - // Skip: Requires real PDPServer for createDataSet - // This would need mocking of PDPServer which is created internally - // TODO: Implement PDPServer mocking and get this working - }) - it('should prefer data sets with existing pieces', async () => { const expectedDataSetBase = { cacheMissRailId: 0n, diff --git a/packages/synapse-sdk/src/test/synapse.test.ts b/packages/synapse-sdk/src/test/synapse.test.ts index 2b5e0ef66..a8cab8760 100644 --- a/packages/synapse-sdk/src/test/synapse.test.ts +++ b/packages/synapse-sdk/src/test/synapse.test.ts @@ -87,109 +87,6 @@ describe('Synapse', () => { }) }) - // describe('Session Keys', () => { - // const FAKE_TX_HASH = '0x3816d82cb7a6f5cde23f4d63c0763050d13c6b6dc659d0a7e6eba80b0ec76a18' - // const FAKE_TX = { - // hash: FAKE_TX_HASH, - // from: Mocks.ADDRESSES.serviceProvider1, - // gas: '0x5208', - // value: '0x0', - // nonce: '0x444', - // input: '0x', - // v: '0x01', - // r: '0x4e2eef88cc6f2dc311aa3b1c8729b6485bd606960e6ae01522298278932c333a', - // s: '0x5d0e08d8ecd6ed8034aa956ff593de9dc1d392e73909ef0c0f828918b58327c9', - // } - // const FAKE_RECEIPT = { - // ...FAKE_TX, - // transactionHash: FAKE_TX_HASH, - // transactionIndex: '0x10', - // blockHash: '0xb91b7314248aaae06f080ad427dbae78b8c5daf72b2446cf843739aef80c6417', - // status: '0x1', - // blockNumber: '0x127001', - // cumulativeGasUsed: '0x52080', - // gasUsed: '0x5208', - // } - // beforeEach(() => { - // const pdpOptions: Mocks.PingMockOptions = { - // baseUrl: 'https://pdp.example.com', - // } - // server.use(Mocks.PING(pdpOptions)) - // }) - - // it('should storage.createContext with session key', async () => { - // const signerAddress = client.account.address - // const sessionKeySigner = new ethers.Wallet(Mocks.PRIVATE_KEYS.key2) - // const sessionKeyAddress = await sessionKeySigner.getAddress() - // const EXPIRY = BigInt(1757618883) - // server.use( - // Mocks.JSONRPC({ - // ...Mocks.presets.basic, - // sessionKeyRegistry: { - // authorizationExpiry: (args) => { - // const client = args[0] - // const signer = args[1] - // assert.equal(client, signerAddress) - // assert.equal(signer, sessionKeyAddress) - // const permission = args[2] - // assert.isTrue(PDP_PERMISSIONS.includes(permission)) - // return [EXPIRY] - // }, - // }, - // payments: { - // ...Mocks.presets.basic.payments, - // operatorApprovals: ([token, client, operator]) => { - // assert.equal(token, Mocks.ADDRESSES.calibration.usdfcToken) - // assert.equal(client, signerAddress) - // assert.equal(operator, Mocks.ADDRESSES.calibration.warmStorage) - // return [ - // true, // isApproved - // BigInt(127001 * 635000000), // rateAllowance - // BigInt(127001 * 635000000), // lockupAllowance - // BigInt(0), // rateUsage - // BigInt(0), // lockupUsage - // BigInt(28800), // maxLockupPeriod - // ] - // }, - // accounts: ([token, user]) => { - // assert.equal(user, signerAddress) - // assert.equal(token, Mocks.ADDRESSES.calibration.usdfcToken) - // return [BigInt(127001 * 635000000), BigInt(0), BigInt(0), BigInt(0)] - // }, - // }, - // eth_getTransactionByHash: (params) => { - // const hash = params[0] - // assert.equal(hash, FAKE_TX_HASH) - // return FAKE_TX - // }, - // eth_getTransactionReceipt: (params) => { - // const hash = params[0] - // assert.equal(hash, FAKE_TX_HASH) - // return FAKE_RECEIPT - // }, - // }) - // ) - // const synapse = await Synapse.create({ client }) - // const sessionKey = synapse.createSessionKey(sessionKeySigner) - // synapse.setSession(sessionKey) - // assert.equal(sessionKey.getSigner(), sessionKeySigner) - - // const expiries = await sessionKey.fetchExpiries(PDP_PERMISSIONS) - // for (const permission of PDP_PERMISSIONS) { - // assert.equal(expiries[permission], EXPIRY) - // } - - // const context = await synapse.storage.createContext() - // assert.equal((context as any)._synapse.getSigner(), sessionKeySigner) - // const info = await context.preflightUpload(127) - // assert.isTrue(info.allowanceCheck.sufficient) - - // // Payments uses the original signer - // const accountInfo = await synapse.payments.accountInfo() - // assert.equal(accountInfo.funds, BigInt(127001 * 635000000)) - // }) - // }) - describe('Payment access', () => { it('should provide read-only access to payments', async () => { server.use(Mocks.JSONRPC(Mocks.presets.basic)) diff --git a/packages/synapse-sdk/src/types.ts b/packages/synapse-sdk/src/types.ts index 1a583eb41..bd8eb09cf 100644 --- a/packages/synapse-sdk/src/types.ts +++ b/packages/synapse-sdk/src/types.ts @@ -7,6 +7,7 @@ import type { Chain } from '@filoz/synapse-core/chains' import type { PieceCID } from '@filoz/synapse-core/piece' +import type { SessionKey, SessionKeyAccount } from '@filoz/synapse-core/session-key' import type { PDPProvider } from '@filoz/synapse-core/sp-registry' import type { MetadataObject } from '@filoz/synapse-core/utils' import type { Account, Address, Client, Hex, Transport } from 'viem' @@ -58,6 +59,8 @@ export interface SynapseOptions { */ account: Account | Address + sessionKey?: SessionKey<'Secp256k1'> + /** Whether to use CDN for retrievals (default: false) */ withCDN?: boolean } @@ -69,7 +72,9 @@ export interface SynapseFromClientOptions { * @see https://viem.sh/docs/clients/wallet#optional-hoist-the-account */ client: Client + // Advanced Configuration + sessionClient?: Client> /** Whether to use CDN for retrievals (default: false) */ withCDN?: boolean