diff --git a/apps/backend/package.json b/apps/backend/package.json index 104ca366..b64f756d 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -29,6 +29,7 @@ "typecheck": "tsc --noEmit --incremental false" }, "dependencies": { + "@filoz/synapse-core": "0.3.3", "@filoz/synapse-sdk": "0.40.2", "@ipld/car": "^5.4.2", "@ipld/dag-pb": "^4.1.4", diff --git a/apps/backend/scripts/create-session-key-safe.mjs b/apps/backend/scripts/create-session-key-safe.mjs new file mode 100644 index 00000000..429e92c1 --- /dev/null +++ b/apps/backend/scripts/create-session-key-safe.mjs @@ -0,0 +1,125 @@ +/** + * Generate a session key and Safe multisig calldata for registering it + * on the SessionKeyRegistry contract. + * + * Usage: + * node scripts/create-session-key-safe.mjs [--network mainnet|calibration] [--expiry-days 90] [--session-key 0x...] + * + * If --session-key is omitted, a random key is generated. + * + * Outputs: + * 1. Session key address and private key + * 2. Permission hashes being registered + * 3. Safe transaction details (target, calldata, value) + * 4. Verification: decoded calldata for review + * 5. Env vars for DealBot deployment + * + * The calldata should be submitted as a custom transaction in the Safe UI + * (app.safe.global) from the DealBot multisig wallet. + */ + +import { calibration, mainnet } from "@filoz/synapse-core/chains"; +import { + AddPiecesPermission, + CreateDataSetPermission, + DefaultFwssPermissions, + DeleteDataSetPermission, + loginCall, + SchedulePieceRemovalsPermission, +} from "@filoz/synapse-core/session-key"; +import { decodeFunctionData, encodeFunctionData } from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; + +// Parse CLI args +const args = process.argv.slice(2); +function getArg(name) { + const idx = args.indexOf(name); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined; +} + +const networkName = getArg("--network") || "calibration"; +const expiryDays = Number(getArg("--expiry-days") || "90"); +const sessionPrivateKey = getArg("--session-key") || generatePrivateKey(); + +const chain = networkName === "mainnet" ? mainnet : calibration; +const sessionAccount = privateKeyToAccount(sessionPrivateKey); +const expiresAt = BigInt(Math.floor(Date.now() / 1000) + expiryDays * 24 * 60 * 60); +const expiryDate = new Date(Number(expiresAt) * 1000); + +// Use the SDK's loginCall to get the exact ABI and args +const call = loginCall({ + chain, + address: sessionAccount.address, + permissions: DefaultFwssPermissions, + expiresAt, + origin: "dealbot", +}); + +// Encode the calldata +const calldata = encodeFunctionData({ + abi: call.abi, + functionName: call.functionName, + args: call.args, +}); + +// Verify by decoding it back +const decoded = decodeFunctionData({ + abi: call.abi, + data: calldata, +}); + +// Permission labels for display +const permissionLabels = { + [CreateDataSetPermission]: "CreateDataSet", + [AddPiecesPermission]: "AddPieces", + [SchedulePieceRemovalsPermission]: "SchedulePieceRemovals", + [DeleteDataSetPermission]: "DeleteDataSet", +}; + +// Output +console.log("=== Session Key Registration for Safe Multisig ==="); +console.log(); +console.log(`Network: ${networkName} (chain ${chain.id})`); +console.log(`Session key addr: ${sessionAccount.address}`); +console.log(`Expiry: ${expiryDate.toISOString()} (${expiryDays} days)`); +console.log(`Origin: dealbot`); +console.log(); +console.log("--- Permissions ---"); +for (const hash of DefaultFwssPermissions) { + console.log(` ${permissionLabels[hash] || "Unknown"}: ${hash}`); +} +console.log(); +console.log("--- Safe Transaction ---"); +console.log(`Target (SessionKeyRegistry): ${call.address}`); +console.log(`Value: 0`); +console.log(`Calldata:`); +console.log(calldata); +console.log(); +console.log("--- Verification (decoded calldata) ---"); +console.log(`Function: ${decoded.functionName}`); +console.log(`Args:`); +console.log(` signer: ${decoded.args[0]}`); +console.log(` expiry: ${decoded.args[1]} (${new Date(Number(decoded.args[1]) * 1000).toISOString()})`); +console.log(` permissions: [`); +for (const p of decoded.args[2]) { + console.log(` ${p} (${permissionLabels[p] || "Unknown"})`); +} +console.log(` ]`); +console.log(` origin: "${decoded.args[3]}"`); +console.log(); +console.log("--- Safe UI Steps ---"); +console.log("1. Go to safe.filecoin.io and open the DealBot multisig"); +console.log("2. New Transaction > Transaction Builder"); +console.log(`3. Enter contract address: ${call.address}`); +console.log('4. Select "Custom data (hex encoded)"'); +console.log("5. Paste the calldata above"); +console.log("6. Value: 0"); +console.log("7. Review, sign, and collect required signatures"); +console.log(); +console.log("--- DealBot Env Vars (for SOPS secrets) ---"); +console.log(`SESSION_KEY_PRIVATE_KEY=${sessionPrivateKey}`); +console.log(); +console.log("--- Renewal ---"); +console.log("To renew, run this script again with the same --session-key"); +console.log("and submit the new calldata via Safe. The contract overwrites"); +console.log("the previous registration for the same signer address."); diff --git a/apps/backend/scripts/fund-safe.mjs b/apps/backend/scripts/fund-safe.mjs new file mode 100644 index 00000000..c407b730 --- /dev/null +++ b/apps/backend/scripts/fund-safe.mjs @@ -0,0 +1,110 @@ +/** + * Generate Safe multisig calldata for depositing USDFC into Filecoin Pay + * and approving FWSS as an operator. + * + * Usage: + * node scripts/fund-safe.mjs --network mainnet|calibration --amount 50 --wallet-address 0x... + * + * Outputs a 3-transaction batch for the Safe Transaction Builder: + * 1. USDFC.approve(FilecoinPay, amount) + * 2. FilecoinPay.deposit(USDFC, walletAddress, amount) + * 3. FilecoinPay.setOperatorApproval(USDFC, FWSS, true, maxUint256, maxUint256, maxUint256) + * + * Prerequisites: the multisig must hold USDFC tokens (ERC20 balance, not + * Filecoin Pay balance). Transfer USDFC to the multisig first if needed. + */ + +import { calibration, mainnet } from "@filoz/synapse-core/chains"; +import { encodeFunctionData, parseUnits } from "viem"; + +const args = process.argv.slice(2); +function getArg(name) { + const idx = args.indexOf(name); + return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined; +} + +const networkName = getArg("--network") || "calibration"; +const amountStr = getArg("--amount"); +const walletAddress = getArg("--wallet-address"); + +if (!amountStr) { + console.error("--amount is required (e.g. --amount 50 for 50 USDFC)"); + process.exit(1); +} +if (!walletAddress) { + console.error("--wallet-address is required (the multisig address to credit)"); + process.exit(1); +} + +const chain = networkName === "mainnet" ? mainnet : calibration; +const amount = parseUnits(amountStr, 18); +const maxUint256 = 2n ** 256n - 1n; + +const usdfcAddress = chain.contracts.usdfc.address; +const filecoinPayAddress = chain.contracts.filecoinPay.address; +const fwssAddress = chain.contracts.fwss.address; + +// Transaction 1: ERC20 approve +const approveCalldata = encodeFunctionData({ + abi: [ + { + type: "function", + name: "approve", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ type: "bool" }], + stateMutability: "nonpayable", + }, + ], + functionName: "approve", + args: [filecoinPayAddress, amount], +}); + +// Transaction 2: deposit +const depositCalldata = encodeFunctionData({ + abi: chain.contracts.filecoinPay.abi, + functionName: "deposit", + args: [usdfcAddress, walletAddress, amount], +}); + +// Transaction 3: setOperatorApproval +const approveOperatorCalldata = encodeFunctionData({ + abi: chain.contracts.filecoinPay.abi, + functionName: "setOperatorApproval", + args: [usdfcAddress, fwssAddress, true, maxUint256, maxUint256, maxUint256], +}); + +console.log("=== Payment Setup for Safe Multisig ==="); +console.log(); +console.log(`Network: ${networkName} (chain ${chain.id})`); +console.log(`Wallet: ${walletAddress}`); +console.log(`Deposit: ${amountStr} USDFC`); +console.log(`USDFC: ${usdfcAddress}`); +console.log(`FilecoinPay: ${filecoinPayAddress}`); +console.log(`FWSS: ${fwssAddress}`); +console.log(); +console.log("--- Transaction 1: Approve USDFC spend ---"); +console.log(`Target: ${usdfcAddress}`); +console.log(`Value: 0`); +console.log(`Data: ${approveCalldata}`); +console.log(); +console.log("--- Transaction 2: Deposit into Filecoin Pay ---"); +console.log(`Target: ${filecoinPayAddress}`); +console.log(`Value: 0`); +console.log(`Data: ${depositCalldata}`); +console.log(); +console.log("--- Transaction 3: Approve FWSS operator ---"); +console.log(`Target: ${filecoinPayAddress}`); +console.log(`Value: 0`); +console.log(`Data: ${approveOperatorCalldata}`); +console.log(); +console.log("--- Safe UI Steps ---"); +console.log("1. Go to safe.filecoin.io and open the multisig"); +console.log("2. New Transaction > Transaction Builder"); +console.log("3. Add all 3 transactions above (target + calldata for each)"); +console.log("4. Send Batch, review, sign, and collect required signatures"); +console.log(); +console.log("Note: The multisig must hold USDFC tokens before executing."); +console.log("Transfer USDFC to the multisig address first if needed."); diff --git a/apps/backend/src/config/app.config.ts b/apps/backend/src/config/app.config.ts index 062fc6e9..a8d82acf 100644 --- a/apps/backend/src/config/app.config.ts +++ b/apps/backend/src/config/app.config.ts @@ -24,11 +24,12 @@ export const configValidationSchema = Joi.object({ // Blockchain NETWORK: Joi.string().valid("mainnet", "calibration").default("calibration"), WALLET_ADDRESS: Joi.string().required(), - WALLET_PRIVATE_KEY: Joi.string().required(), + WALLET_PRIVATE_KEY: Joi.string().optional().empty(""), RPC_URL: Joi.string() .uri({ scheme: ["http", "https"] }) .optional() .allow(""), + SESSION_KEY_PRIVATE_KEY: Joi.string().optional().empty(""), CHECK_DATASET_CREATION_FEES: Joi.boolean().default(true), USE_ONLY_APPROVED_PROVIDERS: Joi.boolean().default(true), DEALBOT_DATASET_VERSION: Joi.string().optional(), @@ -82,7 +83,7 @@ export const configValidationSchema = Joi.object({ HTTP2_REQUEST_TIMEOUT_MS: Joi.number().min(1000).default(240000), // 4 minutes total for HTTP/2 requests (10MiB @ 170KB/s + overhead) IPNI_VERIFICATION_TIMEOUT_MS: Joi.number().min(1000).default(60000), // 60 seconds max time to wait for IPNI verification IPNI_VERIFICATION_POLLING_MS: Joi.number().min(250).default(2000), // 2 seconds between IPNI verification polls -}); +}).or("WALLET_PRIVATE_KEY", "SESSION_KEY_PRIVATE_KEY"); export interface IAppConfig { env: string; @@ -106,6 +107,7 @@ export interface IDatabaseConfig { export interface IBlockchainConfig { network: Network; rpcUrl?: string; + sessionKeyPrivateKey?: `0x${string}`; walletAddress: string; walletPrivateKey: `0x${string}`; checkDatasetCreationFees: boolean; @@ -266,8 +268,9 @@ export function loadConfig(): IConfig { blockchain: { network: (process.env.NETWORK || "calibration") as Network, rpcUrl: process.env.RPC_URL || undefined, + sessionKeyPrivateKey: (process.env.SESSION_KEY_PRIVATE_KEY || undefined) as `0x${string}` | undefined, walletAddress: process.env.WALLET_ADDRESS || "0x0000000000000000000000000000000000000000", - walletPrivateKey: process.env.WALLET_PRIVATE_KEY as "0x${string}", + walletPrivateKey: (process.env.WALLET_PRIVATE_KEY || undefined) as `0x${string}`, checkDatasetCreationFees: process.env.CHECK_DATASET_CREATION_FEES !== "false", useOnlyApprovedProviders: process.env.USE_ONLY_APPROVED_PROVIDERS !== "false", dealbotDataSetVersion: process.env.DEALBOT_DATASET_VERSION, diff --git a/apps/backend/src/deal/deal.service.ts b/apps/backend/src/deal/deal.service.ts index d5153547..0dd39803 100644 --- a/apps/backend/src/deal/deal.service.ts +++ b/apps/backend/src/deal/deal.service.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import * as SessionKey from "@filoz/synapse-core/session-key"; import { calibration, METADATA_KEYS, mainnet, SIZE_CONSTANTS, Synapse } from "@filoz/synapse-sdk"; import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -6,7 +7,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { executeUpload } from "filecoin-pin"; import { CID } from "multiformats/cid"; import type { Repository } from "typeorm"; -import { http } from "viem"; +import { custom, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { awaitWithAbort } from "../common/abort-utils.js"; import { buildUnixfsCar } from "../common/car-utils.js"; @@ -65,12 +66,12 @@ export class DealService implements OnModuleInit, OnModuleDestroy { this.blockchainConfig = this.configService.get("blockchain"); } - onModuleInit() { + async onModuleInit() { this.logger.log({ event: "synapse_initialization", message: "Creating shared Synapse instance", }); - this.sharedSynapse = this.createSynapseInstance(); + this.sharedSynapse = await this.createSynapseInstance(); } async onModuleDestroy(): Promise { @@ -92,7 +93,7 @@ export class DealService implements OnModuleInit, OnModuleDestroy { const { preprocessed, cleanup } = await this.prepareDealInput(options.signal, options.logContext); try { - const synapse = this.sharedSynapse ?? this.createSynapseInstance(); + const synapse = this.sharedSynapse ?? (await this.createSynapseInstance()); const uploadPayload = await this.prepareUploadPayload(preprocessed, options.signal); return await this.createDeal( synapse, @@ -478,7 +479,7 @@ export class DealService implements OnModuleInit, OnModuleDestroy { signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); - const synapse = this.sharedSynapse ?? this.createSynapseInstance(); + const synapse = this.sharedSynapse ?? (await this.createSynapseInstance()); const providerInfo = this.walletSdkService.getProviderInfo(providerAddress); if (!providerInfo) { throw new Error(`Provider ${providerAddress} not found in registry`); @@ -537,7 +538,7 @@ export class DealService implements OnModuleInit, OnModuleDestroy { let transactionHash: string | undefined; try { - const synapse = this.sharedSynapse ?? this.createSynapseInstance(); + const synapse = this.sharedSynapse ?? (await this.createSynapseInstance()); signal?.throwIfAborted(); const DATA_SET_CREATION_PIECE_SIZE = 200 * 1024; // 200 KiB @@ -676,13 +677,49 @@ export class DealService implements OnModuleInit, OnModuleDestroy { // Deal Creation Helpers // ============================================================================ - private createSynapseInstance(): Synapse { + private async createSynapseInstance(): Promise { try { + const chain = this.blockchainConfig.network === "mainnet" ? mainnet : calibration; + const rpcUrl = this.blockchainConfig.rpcUrl; + const transport = rpcUrl ? http(rpcUrl) : http(); + const sessionKeyPK = this.blockchainConfig.sessionKeyPrivateKey; + + if (sessionKeyPK) { + // Session key mode: walletAddress is the multisig (payer), + // sessionKeyPrivateKey provides the delegated signing key + const walletAddress = this.blockchainConfig.walletAddress as `0x${string}`; + const sessionKey = SessionKey.fromSecp256k1({ + privateKey: sessionKeyPK, + root: walletAddress, + chain, + transport, + }); + await sessionKey.syncExpirations(); + + // Synapse requires a custom transport for address-only (json-rpc) accounts + const resolved = transport({ chain, retryCount: 0 }); + + this.logger.log({ + event: "synapse_session_key_init", + message: "Initializing Synapse with session key", + walletAddress, + sessionKeyAddress: sessionKey.address, + }); + + return Synapse.create({ + account: walletAddress, + chain, + source: "dealbot", + transport: custom({ request: resolved.request }), + sessionKey, + }); + } + return Synapse.create({ account: privateKeyToAccount(this.blockchainConfig.walletPrivateKey), - chain: this.blockchainConfig.network === "mainnet" ? mainnet : calibration, + chain, source: "dealbot", - ...(this.blockchainConfig.rpcUrl ? { transport: http(this.blockchainConfig.rpcUrl) } : {}), + ...(rpcUrl ? { transport } : {}), }); } catch (error) { this.logger.error({ diff --git a/apps/backend/src/wallet-sdk/wallet-sdk.service.spec.ts b/apps/backend/src/wallet-sdk/wallet-sdk.service.spec.ts index db444778..9b0a7070 100644 --- a/apps/backend/src/wallet-sdk/wallet-sdk.service.spec.ts +++ b/apps/backend/src/wallet-sdk/wallet-sdk.service.spec.ts @@ -1,6 +1,6 @@ import type { ConfigService } from "@nestjs/config"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { IBlockchainConfig, IConfig } from "../config/app.config.js"; +import { configValidationSchema, type IBlockchainConfig, type IConfig } from "../config/app.config.js"; import { WalletSdkService } from "./wallet-sdk.service.js"; import type { PDPProviderEx } from "./wallet-sdk.types.js"; @@ -36,6 +36,56 @@ const makeProvider = (overrides: Partial): PDPProviderEx => ...overrides, }) as PDPProviderEx; +describe("config validation", () => { + const requiredEnv = { + DATABASE_HOST: "localhost", + DATABASE_USER: "test", + DATABASE_PASSWORD: "test", + DATABASE_NAME: "test", + WALLET_ADDRESS: "0x1234567890123456789012345678901234567890", + }; + + it("requires WALLET_PRIVATE_KEY when SESSION_KEY_PRIVATE_KEY is absent", () => { + const { error } = configValidationSchema.validate(requiredEnv, { allowUnknown: true }); + expect(error).toBeDefined(); + expect(error?.message).toMatch(/WALLET_PRIVATE_KEY/); + }); + + it("accepts missing WALLET_PRIVATE_KEY when SESSION_KEY_PRIVATE_KEY is set", () => { + const { error } = configValidationSchema.validate( + { ...requiredEnv, SESSION_KEY_PRIVATE_KEY: "0xdeadbeef" }, + { allowUnknown: true }, + ); + expect(error).toBeUndefined(); + }); + + it("accepts both WALLET_PRIVATE_KEY and SESSION_KEY_PRIVATE_KEY (session key takes precedence)", () => { + const { error } = configValidationSchema.validate( + { ...requiredEnv, WALLET_PRIVATE_KEY: "0xkey", SESSION_KEY_PRIVATE_KEY: "0xsession" }, + { allowUnknown: true }, + ); + expect(error).toBeUndefined(); + }); + + it("treats empty string WALLET_PRIVATE_KEY as absent", () => { + const { error } = configValidationSchema.validate( + { ...requiredEnv, WALLET_PRIVATE_KEY: "" }, + { allowUnknown: true }, + ); + // Empty string is normalized to undefined by .empty(""), so .or() treats it as absent + expect(error).toBeDefined(); + expect(error?.message).toMatch(/WALLET_PRIVATE_KEY|SESSION_KEY_PRIVATE_KEY/); + }); + + it("treats empty string SESSION_KEY_PRIVATE_KEY as absent", () => { + const { error } = configValidationSchema.validate( + { ...requiredEnv, WALLET_PRIVATE_KEY: "0xkey", SESSION_KEY_PRIVATE_KEY: "" }, + { allowUnknown: true }, + ); + expect(error).toBeUndefined(); + }); +}); + describe("WalletSdkService", () => { let service: WalletSdkService; let repoMock: { create: ReturnType; upsert: ReturnType }; @@ -198,4 +248,21 @@ describe("WalletSdkService", () => { expect(loadProvidersInternal).toHaveBeenCalledTimes(2); expect((service as any).providersLoadedOnce).toBe(true); }); + + describe("ensureWalletAllowances", () => { + it("performs read-only check in session key mode", async () => { + (service as any)._isSessionKeyMode = true; + // getUploadCosts needs _synapseClient but will fail without a real RPC + // Verify it doesn't fall through to the storageManager.prepare path + (service as any)._synapseClient = null; + await expect(service.ensureWalletAllowances()).rejects.toThrow(); + // storageManager.prepare was never called (it would also throw, but differently) + }); + + it("attempts allowances in direct key mode", async () => { + (service as any)._isSessionKeyMode = false; + // storageManager is not initialized so prepare() will throw + await expect(service.ensureWalletAllowances()).rejects.toThrow(); + }); + }); }); diff --git a/apps/backend/src/wallet-sdk/wallet-sdk.service.ts b/apps/backend/src/wallet-sdk/wallet-sdk.service.ts index e35d83d2..fa38f047 100644 --- a/apps/backend/src/wallet-sdk/wallet-sdk.service.ts +++ b/apps/backend/src/wallet-sdk/wallet-sdk.service.ts @@ -1,3 +1,4 @@ +import * as SessionKey from "@filoz/synapse-core/session-key"; import { calibration, mainnet, PDPProvider, Synapse } from "@filoz/synapse-sdk"; import type { PaymentsService } from "@filoz/synapse-sdk/payments"; import { SPRegistryService } from "@filoz/synapse-sdk/sp-registry"; @@ -7,7 +8,7 @@ import { Injectable, Logger, type OnModuleInit } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { InjectRepository } from "@nestjs/typeorm"; import type { Repository } from "typeorm"; -import { type Hex, http } from "viem"; +import { custom, type Hex, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { toStructuredError } from "../common/logging.js"; import type { IBlockchainConfig, IConfig } from "../config/app.config.js"; @@ -27,6 +28,8 @@ export class WalletSdkService implements OnModuleInit { private approvedProviderAddresses: Set = new Set(); private providersLoadPromise: Promise | null = null; private providersLoadedOnce = false; + private _isSessionKeyMode = false; + private _synapseClient: any; constructor( private readonly configService: ConfigService, @@ -53,14 +56,57 @@ export class WalletSdkService implements OnModuleInit { * Initialize wallet services with provider and signer */ private async initializeServices(): Promise { - const account = privateKeyToAccount(this.blockchainConfig.walletPrivateKey); const chain = this.blockchainConfig.network === "mainnet" ? mainnet : calibration; - const synapse = Synapse.create({ - account, - chain, - source: "dealbot", - ...(this.blockchainConfig.rpcUrl ? { transport: http(this.blockchainConfig.rpcUrl) } : {}), - }); + const rpcUrl = this.blockchainConfig.rpcUrl; + const transport = rpcUrl ? http(rpcUrl) : http(); + const sessionKeyPK = this.blockchainConfig.sessionKeyPrivateKey; + + let synapse: Synapse; + + if (sessionKeyPK) { + const walletAddress = this.blockchainConfig.walletAddress as `0x${string}`; + const sessionKey = SessionKey.fromSecp256k1({ + privateKey: sessionKeyPK, + root: walletAddress, + chain, + transport, + }); + await sessionKey.syncExpirations(); + + const resolved = transport({ chain, retryCount: 0 }); + + synapse = Synapse.create({ + account: walletAddress, + chain, + source: "dealbot", + transport: custom({ request: resolved.request }), + sessionKey, + }); + + this.logger.log({ + event: "wallet_sdk_initialized", + message: "Initialized wallet SDK services (session key mode)", + network: this.blockchainConfig.network, + chainId: chain.id, + walletAddress, + sessionKeyAddress: sessionKey.address, + }); + } else { + const account = privateKeyToAccount(this.blockchainConfig.walletPrivateKey); + synapse = Synapse.create({ + account, + chain, + source: "dealbot", + ...(rpcUrl ? { transport } : {}), + }); + + this.logger.log({ + event: "wallet_sdk_initialized", + message: "Initialized wallet SDK services", + network: this.blockchainConfig.network, + chainId: chain.id, + }); + } this.warmStorageService = new WarmStorageService({ client: synapse.client, @@ -70,13 +116,8 @@ export class WalletSdkService implements OnModuleInit { }); this.paymentsService = synapse.payments; this.storageManager = synapse.storage; - - this.logger.log({ - event: "wallet_sdk_initialized", - message: "Initialized wallet SDK services", - network: this.blockchainConfig.network, - chainId: chain.id, - }); + this._synapseClient = synapse.client; + this._isSessionKeyMode = sessionKeyPK != null; } /** @@ -297,9 +338,39 @@ export class WalletSdkService implements OnModuleInit { } /** - * Ensure wallet has sufficient allowances for operations + * Ensure wallet has sufficient allowances for operations. + * Skipped in session key mode, deposits and operator approvals must be + * done separately via the Safe multisig UI. */ async ensureWalletAllowances(): Promise { + if (this._isSessionKeyMode) { + const { getUploadCosts } = await import("@filoz/synapse-core/warm-storage"); + const costs = await getUploadCosts(this._synapseClient, { + clientAddress: this.blockchainConfig.walletAddress as `0x${string}`, + dataSize: 100n * 1024n * 1024n * 1024n, + }); + + if (costs.ready) { + this.logger.log({ + event: "wallet_status_check_completed", + message: "Session key mode: account is funded and approved", + costs: this.serializeBigInt(costs), + }); + } else { + this.logger.error({ + event: "wallet_not_ready", + message: + "Session key mode: account is NOT ready. Deposit USDFC and/or approve FWSS operator via the Safe multisig.", + depositNeeded: costs.depositNeeded.toString(), + needsApproval: costs.needsFwssMaxApproval, + costs: this.serializeBigInt(costs), + }); + throw new Error( + `Session key mode: wallet not ready (depositNeeded=${costs.depositNeeded.toString()}, needsFwssMaxApproval=${costs.needsFwssMaxApproval})`, + ); + } + return; + } const STORAGE_SIZE_GB = 100n; const { costs, transaction } = await this.storageManager.prepare({ dataSize: STORAGE_SIZE_GB * 1024n * 1024n * 1024n, diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 93687d4e..6da26e24 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -8,7 +8,7 @@ This document provides a comprehensive guide to all environment variables used b | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Application](#application-configuration) | `NODE_ENV`, `DEALBOT_PORT`, `DEALBOT_HOST`, `DEALBOT_RUN_MODE`, `DEALBOT_METRICS_PORT`, `DEALBOT_METRICS_HOST`, `DEALBOT_ALLOWED_ORIGINS`, `ENABLE_DEV_MODE` | | [Database](#database-configuration) | `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_POOL_MAX`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_NAME` | -| [Blockchain](#blockchain-configuration) | `NETWORK`, `RPC_URL`, `WALLET_ADDRESS`, `WALLET_PRIVATE_KEY`, `CHECK_DATASET_CREATION_FEES`, `USE_ONLY_APPROVED_PROVIDERS`, `PDP_SUBGRAPH_ENDPOINT` | +| [Blockchain](#blockchain-configuration) | `NETWORK`, `RPC_URL`, `WALLET_ADDRESS`, `WALLET_PRIVATE_KEY`, `SESSION_KEY_PRIVATE_KEY`, `CHECK_DATASET_CREATION_FEES`, `USE_ONLY_APPROVED_PROVIDERS`, `PDP_SUBGRAPH_ENDPOINT` | | [Dataset Versioning](#dataset-versioning) | `DEALBOT_DATASET_VERSION` | | [Scheduling](#scheduling-configuration) | `PROVIDERS_REFRESH_INTERVAL_SECONDS`, `DATA_RETENTION_POLL_INTERVAL_SECONDS`, `DEALBOT_MAINTENANCE_WINDOWS_UTC`, `DEALBOT_MAINTENANCE_WINDOW_MINUTES` | | [Jobs (pg-boss)](#jobs-pg-boss) | `DEALBOT_PGBOSS_SCHEDULER_ENABLED`, `DEALBOT_PGBOSS_POOL_MAX`, `DEALS_PER_SP_PER_HOUR`, `DATASET_CREATIONS_PER_SP_PER_HOUR`, `RETRIEVALS_PER_SP_PER_HOUR`, `METRICS_PER_HOUR`, `JOB_SCHEDULER_POLL_SECONDS`, `JOB_WORKER_POLL_SECONDS`, `PG_BOSS_LOCAL_CONCURRENCY`, `JOB_CATCHUP_MAX_ENQUEUE`, `JOB_SCHEDULE_PHASE_SECONDS`, `JOB_ENQUEUE_JITTER_SECONDS`, `DEAL_JOB_TIMEOUT_SECONDS`, `RETRIEVAL_JOB_TIMEOUT_SECONDS`, `IPFS_BLOCK_FETCH_CONCURRENCY` | @@ -360,7 +360,7 @@ WALLET_ADDRESS=0x1234567890abcdef1234567890abcdef12345678 - **Required**: Yes - **Security**: **HIGHLY SENSITIVE** - Never commit to version control, use secrets management -**Role**: Private key for the wallet, used to sign blockchain transactions for creating storage deals. +**Role**: Private key for signing blockchain transactions. Required in direct key mode. Not needed when `SESSION_KEY_PRIVATE_KEY` is set (session key mode), since the session key handles all signing. If both are set, `SESSION_KEY_PRIVATE_KEY` takes precedence and `WALLET_PRIVATE_KEY` is ignored. **When to update**: @@ -375,6 +375,24 @@ WALLET_ADDRESS=0x1234567890abcdef1234567890abcdef12345678 --- +### `SESSION_KEY_PRIVATE_KEY` + +- **Type**: `string` (0x-prefixed hex private key) +- **Required**: No +- **Security**: **HIGHLY SENSITIVE** - Treat like `WALLET_PRIVATE_KEY` + +**Role**: When set, DealBot uses session key authentication. The session key must be registered on the SessionKeyRegistry contract from the `WALLET_ADDRESS` (typically a Safe multisig). Storage operations (create dataset, add pieces) are signed with this key instead of `WALLET_PRIVATE_KEY`. + +Session keys are scoped (only storage operations, not deposits or withdrawals) and time-limited (expiry set during registration). See [runbooks/wallet-and-session-keys.md](runbooks/wallet-and-session-keys.md) for the full setup process. + +**When to update**: + +- When rotating session keys +- When switching to session key mode from direct key mode +- When the session key has been compromised + +--- + ### `CHECK_DATASET_CREATION_FEES` - **Type**: `boolean` diff --git a/docs/production-operations.md b/docs/production-operations.md index f2ed7655..a9ccbd94 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -10,6 +10,10 @@ For system architecture, component responsibilities, and data store ownership, s Deployment operations (logs, rollout, rollback, secrets, backup checks), staging coverage and limits, promotion criteria, and deployment validation are documented in the [FilOzone/infra Dealbot runbook](https://github.com/FilOzone/infra/blob/main/docs/runbooks/dealbot.md). +## Wallet and Session Keys + +Wallet management, session key creation/renewal, and multisig operations are documented in [runbooks/wallet-and-session-keys.md](runbooks/wallet-and-session-keys.md). + ## Team-Internal Resources - Operational readiness Notion page (Filoz internal; requires team access): [FOC Operational Excellence: Dealbot](https://www.notion.so/filecoindev/FOC-Operational-Excellence-Dealbot-317dc41950c180fda76eddc205a63453). diff --git a/docs/runbooks/wallet-and-session-keys.md b/docs/runbooks/wallet-and-session-keys.md new file mode 100644 index 00000000..9ad3f3ed --- /dev/null +++ b/docs/runbooks/wallet-and-session-keys.md @@ -0,0 +1,186 @@ +# Wallet and Session Key Management + +DealBot uses a Safe multisig wallet with a session key for delegated signing. This reduces blast radius (session keys are scoped and time-limited) and ensures no single person controls the wallet. + +* [Overview](#overview) +* [Environment Variables](#environment-variables) +* [Creating a Session Key](#creating-a-session-key) + * [Prerequisites](#prerequisites) + * [Step 1: Generate the session key and Safe calldata](#step-1-generate-the-session-key-and-safe-calldata) + * [Step 2: Submit the registration transaction via Safe](#step-2-submit-the-registration-transaction-via-safe) + * [Step 3: Collect signatures](#step-3-collect-signatures) + * [Step 4: Deploy the session key to DealBot](#step-4-deploy-the-session-key-to-dealbot) + * [Step 5: Verify](#step-5-verify) +* [Renewing a Session Key](#renewing-a-session-key) +* [Payment Setup](#payment-setup) + * [Funding the multisig](#funding-the-multisig) + * [Depositing and approving via Safe](#depositing-and-approving-via-safe) + * [Checking account status](#checking-account-status) +* [Cleaning Up the Old Wallet](#cleaning-up-the-old-wallet) + + +## Overview + +- **Multisig wallet**: A Safe (safe.filecoin.io) 2-of-N multisig that owns the funds and datasets. Requires multiple signers for any direct wallet operation (deposits, operator approvals, etc.). +- **Session key**: A regular Ethereum keypair registered on the SessionKeyRegistry contract with scoped permissions. DealBot uses this key for day-to-day operations (creating datasets, adding pieces) without needing multisig approval for each transaction. + +Multisig addresses and signers are documented in the [FOC Operational Excellence](https://www.notion.so/filecoindev/FOC-Operational-Excellence-2b7dc41950c1802aa432fff8ecb801cc#2fddc41950c180749b96e4ddc0fb6aaf) Notion page. The Safe UI is at [safe.filecoin.io](https://safe.filecoin.io/). + +## Environment Variables + +| Variable | Required | Secret | Description | +|----------|----------|--------|-------------| +| `WALLET_ADDRESS` | Yes | No | The multisig wallet address (same on both networks) | +| `SESSION_KEY_PRIVATE_KEY` | Yes (session key mode) | Yes | The session key's private key | +| `WALLET_PRIVATE_KEY` | No (session key mode) | Yes | Not needed when using a session key | +| `RPC_URL` | No | Yes | Authenticated RPC endpoint (contains API key) | + +In session key mode, `SESSION_KEY_PRIVATE_KEY` provides the signing key and `WALLET_PRIVATE_KEY` is not needed. The multisig has no single private key, it's controlled by its signers via the Safe UI. + +See [environment-variables.md](../environment-variables.md) for full documentation of all env vars. + +## Creating a Session Key + +### Prerequisites + +- Node.js 22+ +- This repository checked out (`apps/backend/scripts/create-session-key-safe.mjs` is the generation script) +- Being a signer on the DealBot Safe multisig + +### Step 1: Generate the session key and Safe calldata + +From `apps/backend/` in this repository: + +```bash +node scripts/create-session-key-safe.mjs \ + --network calibration \ + --expiry-days 90 +``` + +For mainnet: + +```bash +node scripts/create-session-key-safe.mjs \ + --network mainnet \ + --expiry-days 90 +``` + +To reuse an existing session key (e.g. for renewal): + +```bash +node scripts/create-session-key-safe.mjs \ + --network mainnet \ + --expiry-days 90 \ + --session-key 0x +``` + +The script outputs: +1. The session key address and private key +2. Permission hashes being registered (CreateDataSet, AddPieces, SchedulePieceRemovals, DeleteDataSet) +3. Safe transaction details: target contract address and ABI-encoded calldata +4. Verification: the decoded calldata for human review before submission +5. The `SESSION_KEY_PRIVATE_KEY` env var value for deployment + +**Save the session key private key securely.** It will be needed for the SOPS secrets in FilOzone/infra. + +### Step 2: Submit the registration transaction via Safe + +1. Go to [safe.filecoin.io](https://safe.filecoin.io/) and open the DealBot multisig +2. **New Transaction** > **Transaction Builder** +3. Enter the **target contract address** (SessionKeyRegistry) from the script output +4. Select **Custom data (hex encoded)** +5. Paste the **calldata** from the script output +6. Set **Value** to `0` +7. **Create Batch** > **Send Batch** +8. Review the transaction details, sign it + +### Step 3: Collect signatures + +The multisig requires 2-of-N signatures. After you sign, another signer must also approve and execute the transaction via the Safe UI. + +### Step 4: Deploy the session key to DealBot + +Update the SOPS-encrypted secrets in [FilOzone/infra](https://github.com/FilOzone/infra) for the target environment. See the [infra dealbot runbook](https://github.com/FilOzone/infra/blob/main/docs/runbooks/dealbot.md#8-secrets-management) for SOPS editing instructions. + +Values to set: +- `WALLET_ADDRESS`: the multisig address +- `SESSION_KEY_PRIVATE_KEY`: the session key private key from Step 1 +- `WALLET_PRIVATE_KEY`: can be removed or left empty +- `RPC_URL`: the authenticated RPC endpoint (if not already set) + +After committing the secrets, restart the DealBot pods to pick up the new values: + +```bash +kubectl --context $CONTEXT rollout restart deployment -n dealbot -l app.kubernetes.io/part-of=dealbot +``` + +### Step 5: Verify + +Check the DealBot logs for successful initialization: + +```bash +kubectl --context $CONTEXT -n dealbot logs deployment/dealbot --tail=50 | grep synapse +``` + +Look for `synapse_initialization` and `bootstrap_listen_complete` events without errors. + +## Renewing a Session Key + +Session keys expire. The expiry is set during registration (default: 90 days). To renew: + +1. Run the generation script with `--session-key 0x` and a new `--expiry-days` +2. Submit the new calldata via Safe (same process as initial creation) +3. No DealBot restart needed, the session key private key hasn't changed, only the on-chain expiry + +Monitor expiry dates. A session key that expires while DealBot is running will cause all storage operations to fail. + +## Payment Setup + +Session keys can only perform scoped storage operations (create datasets, add pieces, schedule removals). They cannot: + +- Deposit USDFC into Filecoin Pay +- Approve FWSS as an operator +- Withdraw funds + +These must be done from the multisig via a Safe batch transaction. The multisig needs: + +1. **USDFC tokens** in its wallet (ERC20 balance, not Filecoin Pay balance) +2. **USDFC deposited** into Filecoin Pay +3. **FWSS approved** as an operator with maxUint256 allowances + +### Funding the multisig + +The multisig must hold USDFC tokens (ERC20 balance) before the deposit batch can execute. Transfer USDFC to the multisig address from any wallet that holds USDFC. + +### Depositing and approving via Safe + +From `apps/backend/` in this repository, generate the Safe batch calldata: + +```bash +node scripts/fund-safe.mjs \ + --network calibration \ + --amount 50 \ + --wallet-address +``` + +For mainnet: + +```bash +node scripts/fund-safe.mjs \ + --network mainnet \ + --amount 50 \ + --wallet-address +``` + +This outputs a 3-transaction batch: +1. **USDFC.approve** -- allow FilecoinPay to pull tokens +2. **FilecoinPay.deposit** -- move tokens into the Filecoin Pay account +3. **FilecoinPay.setOperatorApproval** -- approve FWSS with maxUint256 allowances + +Submit all three as a batch in the Safe Transaction Builder at [safe.filecoin.io](https://safe.filecoin.io/), then collect the required signatures. + +**Note:** If the multisig already has FWSS approved (e.g. from a previous deposit), transaction 3 is a no-op but harmless to include. + +## Cleaning Up the Old Wallet + +After migrating to the multisig, the old wallet's datasets should be terminated to stop streaming payments and recover lockup. This is tracked in [dealbot#111](https://github.com/FilOzone/dealbot/issues/111). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b74be41..4e708a85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: apps/backend: dependencies: + '@filoz/synapse-core': + specifier: 0.3.3 + version: 0.3.3(typescript@5.9.3)(viem@2.47.5(typescript@5.9.3)(zod@4.3.6)) '@filoz/synapse-sdk': specifier: 0.40.2 version: 0.40.2(typescript@5.9.3)(viem@2.47.5(typescript@5.9.3)(zod@4.3.6))