From 35d30168cb4f6ede0e743e3a767c7a9cc16d2367 Mon Sep 17 00:00:00 2001 From: Chris Cassano Date: Wed, 17 Dec 2025 16:23:54 -0800 Subject: [PATCH 1/2] add naga mainnet --- packages/lit-client/src/lib/LitClient/type.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/lit-client/src/lib/LitClient/type.ts b/packages/lit-client/src/lib/LitClient/type.ts index 7fbe05970..6b011ee05 100644 --- a/packages/lit-client/src/lib/LitClient/type.ts +++ b/packages/lit-client/src/lib/LitClient/type.ts @@ -1,7 +1,10 @@ // import type { NagaDevModule20250404 } from '@lit-protocol/networks'; -import { NagaLocalModule } from '@lit-protocol/networks'; -import { NagaDevModule } from '@lit-protocol/networks'; -import { NagaStagingModule } from '@lit-protocol/networks'; +import { + NagaLocalModule, + NagaDevModule, + NagaMainnetModule, + NagaStagingModule, +} from '@lit-protocol/networks'; import type { NagaLitClient, NagaLitClientContext, @@ -19,7 +22,8 @@ export type LitNetworkModule = NagaNetworkModule; export type NagaNetworkModule = | NagaLocalModule | NagaDevModule - | NagaStagingModule; + | NagaStagingModule + | NagaMainnetModule; /** * ========== (v7) All Datil Network Modules ========== From d22b678887e7886fd627dccaef8814b4560928d0 Mon Sep 17 00:00:00 2001 From: anson Date: Fri, 19 Dec 2025 23:37:41 +0000 Subject: [PATCH 2/2] feat(networks): add naga mainnet support and tests --- package.json | 2 + .../constants/src/lib/constants/constants.ts | 8 + packages/contracts/src/utils/abi-extractor.ts | 4 +- packages/e2e/src/helper/createTestAccount.ts | 12 +- packages/e2e/src/helper/createTestEnv.ts | 28 +- packages/e2e/src/helper/generated-accounts.ts | 81 ++ packages/e2e/src/init.ts | 97 ++- packages/e2e/src/lite/mainnet-lite.spec.ts | 796 ++++++++++++++++++ .../tickets/pkp-mint-derived-pubkey.spec.ts | 3 + .../tickets/pkp-mint-derived-pubkey.suite.ts | 246 ++++++ .../src/lib/service-client/constants.ts | 1 + .../src/lib/service-client/types.ts | 2 +- .../src/lib/service-client/utils.ts | 2 +- tools/money-back.ts | 508 +++++++++++ 14 files changed, 1764 insertions(+), 26 deletions(-) create mode 100644 packages/e2e/src/helper/generated-accounts.ts create mode 100644 packages/e2e/src/lite/mainnet-lite.spec.ts create mode 100644 packages/e2e/src/tickets/pkp-mint-derived-pubkey.spec.ts create mode 100644 packages/e2e/src/tickets/pkp-mint-derived-pubkey.suite.ts create mode 100644 tools/money-back.ts diff --git a/package.json b/package.json index 17d706fd4..c85e3a519 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "lint:fix": "npx nx run-many --target=lint --all -- --fix", "format:check": "npx nx format:check --all", "test:e2e": "npx jest --clearCache --config ./jest.e2e.config.ts && LOG_LEVEL=${LOG_LEVEL:-silent} dotenvx run --env-file=.env -- jest --runInBand --detectOpenHandles --forceExit --config ./jest.e2e.config.ts --testTimeout=50000000 --runTestsByPath packages/e2e/src/e2e.spec.ts", + "test:e2e:lite-mainnet": "RUN_LITE_MAINNET_E2E=1 NETWORK=naga LOG_LEVEL=${LOG_LEVEL:-silent} dotenvx run --env-file=.env -- jest --runInBand --detectOpenHandles --forceExit --config ./jest.e2e.config.ts --testTimeout=50000000 --runTestsByPath packages/e2e/src/lite/mainnet-lite.spec.ts", "test:target": "npx jest --clearCache --config ./jest.e2e.config.ts && LOG_LEVEL=${LOG_LEVEL:-silent} dotenvx run --env-file=.env -- jest --runInBand --detectOpenHandles --forceExit --config ./jest.e2e.config.ts --testTimeout=50000000", "test:e2e:ci": "npx jest --clearCache --config ./jest.e2e.config.ts && LOG_LEVEL=${LOG_LEVEL:-silent} npx jest --runInBand --detectOpenHandles --forceExit --config ./jest.e2e.config.ts --testTimeout=50000000 --runTestsByPath", "pretest:e2e": "pnpm run generate:lit-actions", "pretest:e2e:ci": "pnpm run generate:lit-actions", + "e2e:money-back": "dotenvx run --env-file=.env -- tsx tools/money-back.ts", "pretest:custom": "pnpm run generate:lit-actions", "test:health": "LOG_LEVEL=${LOG_LEVEL:-silent} dotenvx run --env-file=.env -- tsx packages/e2e/src/health/index.ts", "ci:health": "LOG_LEVEL=${LOG_LEVEL:-silent} tsx packages/e2e/src/health/index.ts" diff --git a/packages/constants/src/lib/constants/constants.ts b/packages/constants/src/lib/constants/constants.ts index f777b140e..1f3360bd1 100644 --- a/packages/constants/src/lib/constants/constants.ts +++ b/packages/constants/src/lib/constants/constants.ts @@ -1056,6 +1056,11 @@ export const LIT_RPC = { * More info: https://app.conduit.xyz/published/view/chronicle-yellowstone-testnet-9qgmzfcohk */ CHRONICLE_YELLOWSTONE: 'https://yellowstone-rpc.litprotocol.com', + + /** + * Lit Chain mainnet RPC endpoint. + */ + LIT_CHAIN: 'https://lit-chain-rpc.litprotocol.com/', } as const; export type LIT_RPC_TYPE = ConstantKeys; @@ -1067,6 +1072,7 @@ export const LIT_EVM_CHAINS = LIT_CHAINS; * Represents the Lit Network constants. */ export const LIT_NETWORK = { + Naga: 'naga', NagaDev: 'naga-dev', NagaTest: 'naga-test', Custom: 'custom', @@ -1089,6 +1095,7 @@ export type LIT_NETWORK_VALUES = ConstantValues; * A mapping of network names to their corresponding RPC URLs. */ export const RPC_URL_BY_NETWORK: Record = { + [LIT_NETWORK.Naga]: LIT_RPC.LIT_CHAIN, [LIT_NETWORK.NagaDev]: LIT_RPC.CHRONICLE_YELLOWSTONE, [LIT_NETWORK.NagaTest]: LIT_RPC.CHRONICLE_YELLOWSTONE, [LIT_NETWORK.Custom]: LIT_RPC.LOCAL_ANVIL, @@ -1227,6 +1234,7 @@ export const LOCAL_STORAGE_KEYS = { * loaded from the chain during initialization */ export const LIT_NETWORKS: Record = { + [LIT_NETWORK.Naga]: [], [LIT_NETWORK.NagaDev]: [], [LIT_NETWORK.NagaTest]: [], [LIT_NETWORK.Custom]: [], diff --git a/packages/contracts/src/utils/abi-extractor.ts b/packages/contracts/src/utils/abi-extractor.ts index 6a0db7943..fe490c055 100644 --- a/packages/contracts/src/utils/abi-extractor.ts +++ b/packages/contracts/src/utils/abi-extractor.ts @@ -9,7 +9,7 @@ import type { NetworkCache } from '../types/contracts'; import { toFunctionSignature } from 'viem/utils'; -import { Interface } from 'ethers'; +import { utils } from 'ethers'; /** * Represents a single contract method with its metadata @@ -51,7 +51,7 @@ export function extractAbiMethods( ABI.forEach((abiItem) => { if (abiItem.type === 'function' && methodNames.includes(abiItem.name)) { try { - const iface = new Interface(ABI); + const iface = new utils.Interface(ABI); // Special case for safeTransferFrom - use the basic version to avoid ambiguity let functionFragment; diff --git a/packages/e2e/src/helper/createTestAccount.ts b/packages/e2e/src/helper/createTestAccount.ts index 55cc897b4..611f06f3c 100644 --- a/packages/e2e/src/helper/createTestAccount.ts +++ b/packages/e2e/src/helper/createTestAccount.ts @@ -4,6 +4,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { AuthContext } from '../types'; import { TestEnv } from './createTestEnv'; import { fundAccount } from './fundAccount'; +import { persistGeneratedAccount } from './generated-accounts'; import { getOrCreatePkp } from './pkp-utils'; type CreateTestAccountOpts = { @@ -49,8 +50,17 @@ export async function createTestAccount( ): Promise { console.log(`--- ${`[${opts.label}]`} Creating test account ---`); // 1. store result + const privateKey = generatePrivateKey(); + persistGeneratedAccount({ + label: `createTestAccount:${opts.label}`, + privateKey, + network: + typeof testEnv.networkModule.getNetworkName === 'function' + ? testEnv.networkModule.getNetworkName() + : process.env['NETWORK'], + }); let person: CreateTestAccountResult = { - account: privateKeyToAccount(generatePrivateKey()), + account: privateKeyToAccount(privateKey), pkp: undefined, eoaAuthContext: undefined, pkpAuthContext: undefined, diff --git a/packages/e2e/src/helper/createTestEnv.ts b/packages/e2e/src/helper/createTestEnv.ts index 4b409abef..4aa89daf9 100644 --- a/packages/e2e/src/helper/createTestEnv.ts +++ b/packages/e2e/src/helper/createTestEnv.ts @@ -38,12 +38,23 @@ export const CONFIG = { nativeFundingAmount: '0.01', ledgerDepositAmount: '0.01', sponsorshipLimits: { - totalMaxPriceInWei: '10000000000000000', - userMaxPrice: 10000000000000000n, + // The mainnet payment delegation flow uses this as the per-request budget and + // must be large enough to cover the minimum estimated price for a PKP sign. + totalMaxPriceInWei: '60000000000000000000', + userMaxPrice: 60000000000000000000n, }, }, }; +const NAGA_MAINNET_NETWORK_FUNDING_AMOUNT = + process.env['NAGA_MAINNET_NETWORK_FUNDING_AMOUNT'] ?? '20'; +const NAGA_PROTO_NETWORK_FUNDING_AMOUNT = + process.env['NAGA_PROTO_NETWORK_FUNDING_AMOUNT'] ?? '0.01'; +const NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT = + process.env['NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT'] ?? '60'; +const NAGA_PROTO_LEDGER_DEPOSIT_AMOUNT = + process.env['NAGA_PROTO_LEDGER_DEPOSIT_AMOUNT'] ?? '0.01'; + export type TestEnvs = { address: `0x${string}`; networkModule: LitNetworkModule; @@ -125,7 +136,18 @@ export const createTestEnv = async (envVars: EnvVars): Promise => { networkModule = applyRpcOverride( envVars.network === 'naga-proto' ? nagaProto : naga ); - config = CONFIG.MAINNET; + config = + envVars.network === 'naga' + ? { + ...CONFIG.MAINNET, + nativeFundingAmount: NAGA_MAINNET_NETWORK_FUNDING_AMOUNT, + ledgerDepositAmount: NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT, + } + : { + ...CONFIG.MAINNET, + nativeFundingAmount: NAGA_PROTO_NETWORK_FUNDING_AMOUNT, + ledgerDepositAmount: NAGA_PROTO_LEDGER_DEPOSIT_AMOUNT, + }; break; } default: { diff --git a/packages/e2e/src/helper/generated-accounts.ts b/packages/e2e/src/helper/generated-accounts.ts new file mode 100644 index 000000000..2743b4336 --- /dev/null +++ b/packages/e2e/src/helper/generated-accounts.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { privateKeyToAccount } from 'viem/accounts'; + +export type GeneratedAccountRecord = { + runId: string; + createdAt: string; + network?: string; + label: string; + address: `0x${string}`; + privateKey: `0x${string}`; +}; + +const DEFAULT_E2E_DIR = path.resolve(process.cwd(), '.e2e'); +const DEFAULT_ACCOUNTS_FILE = path.join(DEFAULT_E2E_DIR, 'generated-accounts.jsonl'); + +export const E2E_RUN_ID: string = + process.env['E2E_RUN_ID'] ?? + `${Date.now()}-${process.pid}-${randomBytes(4).toString('hex')}`; + +export function getGeneratedAccountsFilePath(): string { + return process.env['E2E_GENERATED_ACCOUNTS_FILE'] ?? DEFAULT_ACCOUNTS_FILE; +} + +export function persistGeneratedAccount(params: { + label: string; + privateKey: `0x${string}`; + network?: string; +}): GeneratedAccountRecord { + const filePath = getGeneratedAccountsFilePath(); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + const account = privateKeyToAccount(params.privateKey); + const record: GeneratedAccountRecord = { + runId: E2E_RUN_ID, + createdAt: new Date().toISOString(), + network: params.network, + label: params.label, + address: account.address, + privateKey: params.privateKey, + }; + + fs.appendFileSync(filePath, `${JSON.stringify(record)}\n`, { + encoding: 'utf8', + }); + + // Best-effort: restrict permissions on *nix. + try { + fs.chmodSync(filePath, 0o600); + } catch { + // Ignore (e.g. Windows or filesystem constraints). + } + + return record; +} + +export function readGeneratedAccounts(params?: { + filePath?: string; + runId?: string; +}): GeneratedAccountRecord[] { + const filePath = params?.filePath ?? getGeneratedAccountsFilePath(); + if (!fs.existsSync(filePath)) return []; + + const runId = params?.runId; + const lines = fs.readFileSync(filePath, 'utf8').split('\n'); + const records: GeneratedAccountRecord[] = []; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as GeneratedAccountRecord; + if (runId && parsed.runId !== runId) continue; + records.push(parsed); + } catch { + // Ignore malformed lines (best-effort persistence). + } + } + + return records; +} diff --git a/packages/e2e/src/init.ts b/packages/e2e/src/init.ts index cfdd3f7dc..737a93bba 100644 --- a/packages/e2e/src/init.ts +++ b/packages/e2e/src/init.ts @@ -6,6 +6,7 @@ import { import { createLitClient, utils as litUtils } from '@lit-protocol/lit-client'; import type { NagaLocalModule } from '@lit-protocol/networks'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { persistGeneratedAccount } from './helper/generated-accounts'; import { NetworkName, NetworkNameSchema, @@ -43,8 +44,17 @@ type LogLevel = z.infer; const LIVE_NETWORK_FUNDING_AMOUNT = '0.01'; const LOCAL_NETWORK_FUNDING_AMOUNT = '1'; const LIVE_NETWORK_LEDGER_DEPOSIT_AMOUNT = '1'; -const MAINNET_NETWORK_FUNDING_AMOUNT = '0.01'; -const MAINNET_LEDGER_DEPOSIT_AMOUNT = '0.01'; +// Mainnet-style networks have separate knobs so `naga-proto` can remain cheap while +// `naga` can be configured independently. +const NAGA_MAINNET_NETWORK_FUNDING_AMOUNT = + process.env['NAGA_MAINNET_NETWORK_FUNDING_AMOUNT'] ?? '0.01'; +const NAGA_PROTO_NETWORK_FUNDING_AMOUNT = + process.env['NAGA_PROTO_NETWORK_FUNDING_AMOUNT'] ?? '0.01'; +const NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT = + // Default stays low to avoid stranding real mainnet funds; override as needed. + process.env['NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT'] ?? '0.01'; +const NAGA_PROTO_LEDGER_DEPOSIT_AMOUNT = + process.env['NAGA_PROTO_LEDGER_DEPOSIT_AMOUNT'] ?? '0.01'; const EVE_VALIDATION_IPFS_CID = 'QmcxWmo3jefFsPUnskJXYBwsJYtiFuMAH1nDQEs99AwzDe'; @@ -92,13 +102,22 @@ async function initInternal( * Prepare accounts for testing * ==================================== */ - const localMasterAccount = privateKeyToAccount( - process.env['LOCAL_MASTER_ACCOUNT'] as `0x${string}` - ); - const liveMasterAccount = privateKeyToAccount( - process.env['LIVE_MASTER_ACCOUNT'] as `0x${string}` - ); - const aliceViemAccount = privateKeyToAccount(generatePrivateKey()); + const networkForPersistence = (network ?? process.env['NETWORK']) as + | string + | undefined; + + const alicePrivateKeyEnv = process.env['E2E_ALICE_PRIVATE_KEY'] as + | `0x${string}` + | undefined; + const alicePrivateKey = alicePrivateKeyEnv ?? generatePrivateKey(); + if (!alicePrivateKeyEnv) { + persistGeneratedAccount({ + label: 'init:alice', + privateKey: alicePrivateKey, + network: networkForPersistence, + }); + } + const aliceViemAccount = privateKeyToAccount(alicePrivateKey); const aliceViemAccountAuthData = await ViemAccountAuthenticator.authenticate( aliceViemAccount ); @@ -187,16 +206,36 @@ async function initInternal( } const isLocal = networkType === 'local'; - const isMainnet = - resolvedNetworkName === 'naga' || resolvedNetworkName === 'naga-proto'; - const masterAccount = isLocal ? localMasterAccount : liveMasterAccount; + const isNagaMainnet = resolvedNetworkName === 'naga'; + const isNagaProto = resolvedNetworkName === 'naga-proto'; + const masterAccountEnvVar = isLocal + ? 'LOCAL_MASTER_ACCOUNT' + : 'LIVE_MASTER_ACCOUNT'; + const masterPrivateKey = process.env[masterAccountEnvVar] as + | `0x${string}` + | undefined; + + if (!masterPrivateKey) { + throw new Error( + `❌ ${masterAccountEnvVar} is not set (expected a 0x-prefixed private key; required for NETWORK=${resolvedNetworkName}).` + ); + } + + const masterAccount = privateKeyToAccount(masterPrivateKey); + // Keep existing API shape: `localMasterAccount` is the sponsor account used by this run + // (LOCAL on local networks, LIVE on live networks). + const localMasterAccount = masterAccount; const fundingAmount = isLocal ? LOCAL_NETWORK_FUNDING_AMOUNT - : isMainnet - ? MAINNET_NETWORK_FUNDING_AMOUNT + : isNagaMainnet + ? NAGA_MAINNET_NETWORK_FUNDING_AMOUNT + : isNagaProto + ? NAGA_PROTO_NETWORK_FUNDING_AMOUNT : LIVE_NETWORK_FUNDING_AMOUNT; - const ledgerDepositAmount = isMainnet - ? MAINNET_LEDGER_DEPOSIT_AMOUNT + const ledgerDepositAmount = isNagaMainnet + ? NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT + : isNagaProto + ? NAGA_PROTO_LEDGER_DEPOSIT_AMOUNT : LIVE_NETWORK_LEDGER_DEPOSIT_AMOUNT; // Fund accounts sequentially to avoid nonce conflicts with same sponsor @@ -210,12 +249,34 @@ async function initInternal( let eveViemAccount: ViemAccount | undefined; if (mode === 'full') { - bobViemAccount = privateKeyToAccount(generatePrivateKey()); + const bobPrivateKeyEnv = process.env['E2E_BOB_PRIVATE_KEY'] as + | `0x${string}` + | undefined; + const bobPrivateKey = bobPrivateKeyEnv ?? generatePrivateKey(); + if (!bobPrivateKeyEnv) { + persistGeneratedAccount({ + label: 'init:bob', + privateKey: bobPrivateKey, + network: networkForPersistence, + }); + } + bobViemAccount = privateKeyToAccount(bobPrivateKey); bobViemAccountAuthData = await ViemAccountAuthenticator.authenticate( bobViemAccount ); - eveViemAccount = privateKeyToAccount(generatePrivateKey()); + const evePrivateKeyEnv = process.env['E2E_EVE_PRIVATE_KEY'] as + | `0x${string}` + | undefined; + const evePrivateKey = evePrivateKeyEnv ?? generatePrivateKey(); + if (!evePrivateKeyEnv) { + persistGeneratedAccount({ + label: 'init:eve', + privateKey: evePrivateKey, + network: networkForPersistence, + }); + } + eveViemAccount = privateKeyToAccount(evePrivateKey); await fundAccount(bobViemAccount, masterAccount, networkModule, { ifLessThan: fundingAmount, diff --git a/packages/e2e/src/lite/mainnet-lite.spec.ts b/packages/e2e/src/lite/mainnet-lite.spec.ts new file mode 100644 index 000000000..725ecff62 --- /dev/null +++ b/packages/e2e/src/lite/mainnet-lite.spec.ts @@ -0,0 +1,796 @@ +// Lite mainnet e2e run profile (approx per run) +// +----------------------+-------+------------+-----------------------------------------+ +// | Item | Count | LITKEY | Notes | +// +----------------------+-------+------------+-----------------------------------------+ +// | EOAs | 3 | - | MASTER + Alice + Bob | +// | PKP mints | 2-3 | 28-42 | +1 if MASTER has no PKP | +// | Paid endpoints | - | - | all rows below | +// | pkpSign | 3 | 8.37 | paid endpoint | +// | signSessionKey | 1 | 13.95 | paid endpoint | +// | litAction | 3 | 1.67 | executeJs + wrapped keys import/export | +// | encrypt/decrypt | 1 | 2.79 | paid endpoint | +// | Paid endpoints total | 1 | ~26.78 | sum of paid endpoint rows above | +// | Rough run total | 1 | ~54.8-68.8 | excludes gas for on-chain txs | +// +----------------------+-------+------------+-----------------------------------------+ +// +--------------------------------------+---------+----------+---------------------------------------+ +// | Env var | Value | Unit | Rationale | +// +--------------------------------------+---------+----------+---------------------------------------+ +// | NAGA_MAINNET_NETWORK_FUNDING_AMOUNT | >=18-20 | LITKEY | 14 mint + gas buffer + delegation txs; | +// | | | per acct | tops up Alice/Bob/PKP (worst-case x3) | +// | NAGA_MAINNET_LEDGER_DEPOSIT_AMOUNT | >=60 | LITKEY | covers mainnet min price checks; tops up | +// | | | per addr | MASTER+PKP+Alice+PKP (worst-case x4) | +// +--------------------------------------+---------+----------+---------------------------------------+ +import { createAccBuilder } from '@lit-protocol/access-control-conditions'; +import { ViemAccountAuthenticator } from '@lit-protocol/auth'; +import { AuthData, PKPData } from '@lit-protocol/schemas'; +import { + api as wrappedKeysApi, + config as wrappedKeysConfig, +} from '@lit-protocol/wrapped-keys'; +import { + litActionRepository, + litActionRepositoryCommon, +} from '@lit-protocol/wrapped-keys-lit-actions'; +import { Wallet } from 'ethers'; +import { createPublicClient, formatEther, http, parseEther } from 'viem'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; + +import { createEnvVars } from '../helper/createEnvVars'; +import type { CreateTestAccountResult } from '../helper/createTestAccount'; +import { createTestEnv } from '../helper/createTestEnv'; +import { fundAccount } from '../helper/fundAccount'; +import { persistGeneratedAccount } from '../helper/generated-accounts'; +import type { AuthContext, ViemAccount } from '../types'; + +const RUN_LITE_MAINNET_E2E = process.env['RUN_LITE_MAINNET_E2E'] === '1'; +const IS_MAINNET = process.env['NETWORK'] === 'naga'; +const LITE_DEBUG_MINT = process.env['LITE_DEBUG_MINT'] === '1'; +const LITE_DEBUG_EXIT_BEFORE_MINT = + process.env['LITE_DEBUG_EXIT_BEFORE_MINT'] === '1'; +const describeIfMainnet = + RUN_LITE_MAINNET_E2E && IS_MAINNET ? describe : describe.skip; + +type LiteContext = { + envVars: ReturnType; + testEnv: Awaited>; + masterAccount: ViemAccount; + masterAuthData: AuthData; + masterEoaAuthContext: AuthContext; + masterPkp: PKPData; +}; + +type LiteCreateTestAccountOpts = { + label: string; + fundAccount: boolean; + fundLedger: boolean; + hasEoaAuthContext?: boolean; + hasPKP: boolean; + fundPKP: boolean; + fundPKPLedger: boolean; + hasPKPAuthContext?: boolean; + sponsor?: { + restrictions: { + totalMaxPriceInWei: string; + requestsPerPeriod: string; + periodSeconds: string; + }; + userAddresses: string[] | `0x${string}`[]; + }; +}; + +const getRpcUrl = ( + testEnv: Awaited>, + envVars: ReturnType +) => + envVars.rpcUrl ?? + testEnv.networkModule.getChainConfig().rpcUrls.default.http[0]; + +const assertNativeBalance = async ( + testEnv: Awaited>, + account: ViemAccount, + envVars: ReturnType +) => { + const rpcUrl = getRpcUrl(testEnv, envVars); + const publicClient = createPublicClient({ + chain: testEnv.networkModule.getChainConfig(), + transport: http(rpcUrl), + }); + const balance = await publicClient.getBalance({ + address: account.address, + }); + const minWei = parseEther(testEnv.config.nativeFundingAmount); + + if (balance < minWei) { + throw new Error( + `MASTER account ${account.address} balance ${formatEther( + balance + )} ETH is below minimum ${testEnv.config.nativeFundingAmount} ETH.` + ); + } +}; + +const ensureLedgerBalance = async ( + testEnv: Awaited>, + userAddress: `0x${string}` +) => { + const desiredWei = parseEther(testEnv.config.ledgerDepositAmount); + const balance = await testEnv.masterPaymentManager.getBalance({ + userAddress, + }); + const currentWei = balance.raw.availableBalance; + const deltaWei = desiredWei > currentWei ? desiredWei - currentWei : 0n; + + if (deltaWei > 0n) { + await testEnv.masterPaymentManager.depositForUser({ + userAddress, + amountInEth: formatEther(deltaWei), + }); + } +}; + +const ensureAuthDataPublicKey = ( + authData: AuthData, + account: ViemAccount +): AuthData => + authData.publicKey ? authData : { ...authData, publicKey: account.publicKey }; + +const formatAuthDataForLog = (authData: AuthData) => { + const authMethodIdBytes = authData.authMethodId + ? (authData.authMethodId.length - 2) / 2 + : 0; + const publicKeyBytes = authData.publicKey + ? (authData.publicKey.length - 2) / 2 + : 0; + + let authSigAddress: string | undefined; + let authSigLength: number | undefined; + try { + const parsed = JSON.parse(authData.accessToken ?? '{}') as { + address?: string; + sig?: string; + }; + authSigAddress = parsed.address; + authSigLength = parsed.sig?.length; + } catch { + authSigAddress = '[parse_error]'; + } + + return { + authMethodType: authData.authMethodType, + authMethodId: authData.authMethodId, + authMethodIdBytes, + publicKey: authData.publicKey ?? '[unset]', + publicKeyBytes, + authSigAddress, + authSigLength, + accessTokenLength: authData.accessToken?.length ?? 0, + }; +}; + +const formatPkpForLog = (pkp?: PKPData) => + pkp + ? { + tokenId: pkp.tokenId, + pubkey: pkp.pubkey, + ethAddress: pkp.ethAddress, + } + : '[none]'; + +const logMintDebug = (label: string, stage: string, data: unknown) => { + if (!LITE_DEBUG_MINT) { + return; + } + console.log(`[lite-mint-debug] ${label} :: ${stage}`, data); +}; + +const getOrCreatePkpLite = async ( + testEnv: Awaited>, + account: ViemAccount, + authData: AuthData, + label: string +): Promise => { + logMintDebug(label, 'viewPKPsByAuthData.request', { + account: account.address, + authData: formatAuthDataForLog(authData), + }); + + const { pkps } = await testEnv.litClient.viewPKPsByAuthData({ + authData, + pagination: { + limit: 5, + }, + }); + + if (pkps && pkps[0]) { + logMintDebug(label, 'viewPKPsByAuthData.response', { + count: pkps.length, + first: formatPkpForLog(pkps[0]), + }); + return pkps[0]; + } + + logMintDebug(label, 'viewPKPsByAuthData.response', { + count: pkps?.length ?? 0, + first: formatPkpForLog(pkps?.[0]), + }); + + const scopes = ['sign-anything']; + logMintDebug(label, 'mintWithAuth.request', { + account: account.address, + authData: formatAuthDataForLog(authData), + scopes, + }); + + if (LITE_DEBUG_EXIT_BEFORE_MINT) { + console.log('❌ Exiting before mint for debug.'); + console.log('label:', label); + console.log('account.address:', account.address); + console.log('account.publicKey:', account.publicKey); + console.log('authData.authMethodType:', authData.authMethodType); + console.log('authData.authMethodId:', authData.authMethodId); + console.log('authData.publicKey:', authData.publicKey ?? '[unset]'); + console.log( + 'authData.accessToken.length:', + authData.accessToken?.length ?? 0 + ); + process.exit(1); + } + + let mintResult: unknown; + try { + mintResult = await testEnv.litClient.mintWithAuth({ + authData, + account, + scopes, + }); + } catch (error) { + logMintDebug(label, 'mintWithAuth.error', error); + throw error; + } + + logMintDebug(label, 'mintWithAuth.response', { + txHash: (mintResult as { txHash?: string })?.txHash, + data: (mintResult as { data?: PKPData })?.data + ? formatPkpForLog((mintResult as { data?: PKPData }).data) + : mintResult, + }); + + const { pkps: mintedPkps } = await testEnv.litClient.viewPKPsByAuthData({ + authData, + pagination: { + limit: 5, + }, + }); + + logMintDebug(label, 'viewPKPsByAuthData.afterMint', { + count: mintedPkps?.length ?? 0, + first: formatPkpForLog(mintedPkps?.[0]), + }); + + if (!mintedPkps?.[0]) { + throw new Error('PKP mint completed but no PKP returned via lookup.'); + } + + return mintedPkps[0]; +}; + +const createTestAccountLite = async ( + testEnv: Awaited>, + opts: LiteCreateTestAccountOpts +): Promise => { + console.log(`--- ${`[${opts.label}]`} Creating test account ---`); + const privateKey = generatePrivateKey(); + persistGeneratedAccount({ + label: `createTestAccount:${opts.label}`, + privateKey, + network: + typeof testEnv.networkModule.getNetworkName === 'function' + ? testEnv.networkModule.getNetworkName() + : process.env['NETWORK'], + }); + + const account = privateKeyToAccount(privateKey); + const baseAuthData = await ViemAccountAuthenticator.authenticate(account); + const authData = ensureAuthDataPublicKey(baseAuthData, account); + + const person: CreateTestAccountResult = { + account, + pkp: undefined, + eoaAuthContext: undefined, + pkpAuthContext: undefined, + pkpViemAccount: undefined, + paymentManager: undefined, + authData, + }; + + console.log(`Address`, person.account.address); + console.log(`opts:`, opts); + + if (opts.fundAccount) { + await fundAccount( + person.account, + testEnv.masterAccount, + testEnv.networkModule, + { + label: 'owner', + ifLessThan: testEnv.config.nativeFundingAmount, + thenFund: testEnv.config.nativeFundingAmount, + } + ); + + if (opts.hasEoaAuthContext) { + person.eoaAuthContext = await testEnv.authManager.createEoaAuthContext({ + config: { + account: person.account, + }, + authConfig: { + statement: 'I authorize the Lit Protocol to execute this Lit Action.', + domain: 'example.com', + resources: [ + ['lit-action-execution', '*'], + ['pkp-signing', '*'], + ['access-control-condition-decryption', '*'], + ], + expiration: new Date(Date.now() + 1000 * 60 * 15).toISOString(), + }, + litClient: testEnv.litClient, + }); + } + } + + if (opts.fundLedger) { + const desiredWei = parseEther(testEnv.config.ledgerDepositAmount); + const balance = await testEnv.masterPaymentManager.getBalance({ + userAddress: person.account.address, + }); + const currentWei = balance.raw.availableBalance; + const deltaWei = desiredWei > currentWei ? desiredWei - currentWei : 0n; + + if (deltaWei > 0n) { + await testEnv.masterPaymentManager.depositForUser({ + userAddress: person.account.address, + amountInEth: formatEther(deltaWei), + }); + } + } + + if (opts.hasPKP) { + person.pkp = await getOrCreatePkpLite( + testEnv, + account, + authData, + opts.label + ); + + if (opts.fundPKP) { + await fundAccount( + person.pkp.ethAddress as `0x${string}`, + testEnv.masterAccount, + testEnv.networkModule, + { + label: 'PKP', + ifLessThan: testEnv.config.nativeFundingAmount, + thenFund: testEnv.config.nativeFundingAmount, + } + ); + } + + if (opts.fundPKPLedger) { + const desiredWei = parseEther(testEnv.config.ledgerDepositAmount); + const balance = await testEnv.masterPaymentManager.getBalance({ + userAddress: person.pkp.ethAddress as `0x${string}`, + }); + const currentWei = balance.raw.availableBalance; + const deltaWei = desiredWei > currentWei ? desiredWei - currentWei : 0n; + + if (deltaWei > 0n) { + await testEnv.masterPaymentManager.depositForUser({ + userAddress: person.pkp.ethAddress as `0x${string}`, + amountInEth: formatEther(deltaWei), + }); + } + } + + if (opts.hasPKPAuthContext) { + person.pkpAuthContext = await testEnv.authManager.createPkpAuthContext({ + authData, + pkpPublicKey: person.pkp.pubkey, + authConfig: { + resources: [ + ['pkp-signing', '*'], + ['lit-action-execution', '*'], + ['access-control-condition-decryption', '*'], + ], + expiration: new Date(Date.now() + 1000 * 60 * 30).toISOString(), + }, + litClient: testEnv.litClient, + }); + } + + const authContext = person.pkpAuthContext ?? person.eoaAuthContext; + if (authContext) { + person.pkpViemAccount = await testEnv.litClient.getPkpViemAccount({ + pkpPublicKey: person.pkp.pubkey, + authContext, + chainConfig: testEnv.networkModule.getChainConfig(), + }); + } + } + + if (opts.sponsor) { + person.paymentManager = await testEnv.litClient.getPaymentManager({ + account: person.account, + }); + + try { + const tx = await person.paymentManager.setRestriction({ + totalMaxPrice: opts.sponsor.restrictions.totalMaxPriceInWei, + requestsPerPeriod: opts.sponsor.restrictions.requestsPerPeriod, + periodSeconds: opts.sponsor.restrictions.periodSeconds, + }); + console.log(`- [setRestriction] TX Hash: ${tx.hash}`); + } catch (e) { + throw new Error(`❌ Failed to set sponsorship restrictions: ${e}`); + } + + const userAddresses = opts.sponsor.userAddresses; + if (!userAddresses || userAddresses.length === 0) { + throw new Error( + '❌ User addresses are required for the sponsor to fund.' + ); + } + + try { + console.log(`- Sponsoring users:`, userAddresses); + const tx = await person.paymentManager.delegatePaymentsBatch({ + userAddresses: userAddresses, + }); + console.log(`[delegatePaymentsBatch] TX Hash: ${tx.hash}`); + } catch (e) { + throw new Error(`❌ Failed to delegate sponsorship to users: ${e}`); + } + } + + return person; +}; + +const initLiteMainnetContext = async (): Promise => { + const envVars = createEnvVars(); + if (envVars.network !== 'naga') { + throw new Error( + `Lite mainnet suite requires NETWORK=naga, received ${envVars.network}` + ); + } + + const testEnv = await createTestEnv(envVars); + const masterAccount = testEnv.masterAccount; + + await assertNativeBalance(testEnv, masterAccount, envVars); + + const baseAuthData = await ViemAccountAuthenticator.authenticate( + masterAccount + ); + const masterAuthData = ensureAuthDataPublicKey(baseAuthData, masterAccount); + const masterEoaAuthContext = await testEnv.authManager.createEoaAuthContext({ + config: { + account: masterAccount, + }, + authConfig: { + statement: 'Lite mainnet e2e authorization.', + domain: 'lite-mainnet.e2e', + resources: [ + ['lit-action-execution', '*'], + ['pkp-signing', '*'], + ['access-control-condition-decryption', '*'], + ], + expiration: new Date(Date.now() + 1000 * 60 * 30).toISOString(), + }, + litClient: testEnv.litClient, + }); + + const masterPkp = await getOrCreatePkpLite( + testEnv, + masterAccount, + masterAuthData, + 'MASTER' + ); + + await ensureLedgerBalance(testEnv, masterAccount.address); + await ensureLedgerBalance(testEnv, masterPkp.ethAddress as `0x${string}`); + + wrappedKeysConfig.setLitActionsCode(litActionRepository); + wrappedKeysConfig.setLitActionsCodeCommon(litActionRepositoryCommon); + + return { + envVars, + testEnv, + masterAccount, + masterAuthData, + masterEoaAuthContext, + masterPkp, + }; +}; + +describeIfMainnet('lite mainnet e2e', () => { + describe('core endpoints', () => { + let ctx: LiteContext; + + beforeAll(async () => { + ctx = await initLiteMainnetContext(); + }); + + beforeEach(async () => { + await ensureLedgerBalance(ctx.testEnv, ctx.masterAccount.address); + await ensureLedgerBalance( + ctx.testEnv, + ctx.masterPkp.ethAddress as `0x${string}` + ); + }); + + it('handshake', async () => { + const clientContext = await ctx.testEnv.litClient.getContext(); + + expect(clientContext?.handshakeResult).toBeDefined(); + + const { serverKeys, connectedNodes, threshold } = + clientContext.handshakeResult!; + const numServers = serverKeys ? Object.keys(serverKeys).length : 0; + const numConnected = connectedNodes ? connectedNodes.size : 0; + + expect(numServers).toBeGreaterThan(0); + + if (typeof threshold === 'number') { + expect(numConnected).toBeGreaterThanOrEqual(threshold); + } + }); + + it('pkpSign', async () => { + const result = await ctx.testEnv.litClient.chain.ethereum.pkpSign({ + authContext: ctx.masterEoaAuthContext, + pubKey: ctx.masterPkp.pubkey, + toSign: 'Hello from lite mainnet e2e', + }); + + expect(result.signature).toBeDefined(); + }); + + it('signSessionKey', async () => { + const pkpAuthContext = await ctx.testEnv.authManager.createPkpAuthContext( + { + authData: ctx.masterAuthData, + pkpPublicKey: ctx.masterPkp.pubkey, + authConfig: { + resources: [ + ['pkp-signing', '*'], + ['lit-action-execution', '*'], + ], + expiration: new Date(Date.now() + 1000 * 60 * 15).toISOString(), + }, + litClient: ctx.testEnv.litClient, + } + ); + + expect(pkpAuthContext).toBeDefined(); + }); + + it('executeJs', async () => { + const litActionCode = ` +(async () => { + const { sigName, toSign, publicKey } = jsParams; + const { keccak256, arrayify } = ethers.utils; + + const toSignBytes = new TextEncoder().encode(toSign); + const toSignBytes32 = keccak256(toSignBytes); + const toSignBytes32Array = arrayify(toSignBytes32); + + const sigShare = await Lit.Actions.signEcdsa({ + toSign: toSignBytes32Array, + publicKey, + sigName, + }); +})();`; + + const result = await ctx.testEnv.litClient.executeJs({ + code: litActionCode, + authContext: ctx.masterEoaAuthContext, + jsParams: { + sigName: 'lite-mainnet-sig', + toSign: 'Lite mainnet executeJs', + publicKey: ctx.masterPkp.pubkey, + }, + }); + + expect(result).toBeDefined(); + expect(result.signatures).toBeDefined(); + }); + + it('encryptDecrypt', async () => { + const builder = createAccBuilder(); + const accs = builder + .requireWalletOwnership(ctx.masterAccount.address) + .on('ethereum') + .build(); + + const testData = 'Lite mainnet decrypt test'; + const encryptedData = await ctx.testEnv.litClient.encrypt({ + dataToEncrypt: testData, + unifiedAccessControlConditions: accs, + chain: 'ethereum', + }); + + expect(encryptedData.ciphertext).toBeDefined(); + expect(encryptedData.dataToEncryptHash).toBeDefined(); + + const decryptedData = await ctx.testEnv.litClient.decrypt({ + data: encryptedData, + unifiedAccessControlConditions: accs, + chain: 'ethereum', + authContext: ctx.masterEoaAuthContext, + }); + + expect(decryptedData.convertedData).toBe(testData); + }); + + it('wrappedKeys', async () => { + const sessionKeyPair = ctx.testEnv.sessionKeyPair; + const delegationAuthSig = + await ctx.testEnv.authManager.generatePkpDelegationAuthSig({ + pkpPublicKey: ctx.masterPkp.pubkey, + authData: ctx.masterAuthData, + sessionKeyPair, + authConfig: { + resources: [ + ['pkp-signing', '*'], + ['lit-action-execution', '*'], + ['access-control-condition-decryption', '*'], + ], + expiration: new Date(Date.now() + 1000 * 60 * 15).toISOString(), + }, + litClient: ctx.testEnv.litClient, + }); + + const pkpSessionSigs = await ctx.testEnv.authManager.createPkpSessionSigs( + { + pkpPublicKey: ctx.masterPkp.pubkey, + sessionKeyPair, + delegationAuthSig, + litClient: ctx.testEnv.litClient, + } + ); + + const wallet = Wallet.createRandom(); + const memo = `lite-mainnet-import-${Date.now()}`; + + const importResult = await wrappedKeysApi.importPrivateKey({ + pkpSessionSigs, + litClient: ctx.testEnv.litClient, + privateKey: wallet.privateKey, + publicKey: wallet.publicKey, + keyType: 'K256', + memo, + }); + + expect(importResult.id).toBeDefined(); + + const exportResult = await wrappedKeysApi.exportPrivateKey({ + pkpSessionSigs, + litClient: ctx.testEnv.litClient, + id: importResult.id, + network: 'evm', + }); + + expect(exportResult.decryptedPrivateKey?.toLowerCase()).toBe( + wallet.privateKey.toLowerCase() + ); + }); + }); + + describe('payment delegation test', () => { + let envVars: ReturnType; + let testEnv: Awaited>; + let alice: CreateTestAccountResult; + let bobAccount: CreateTestAccountResult; + + beforeAll(async () => { + envVars = createEnvVars(); + testEnv = await createTestEnv(envVars); + }); + + it("should allow Bob to use Alice's sponsorship to pay for PKP execution", async () => { + // 1. First, create Bob + bobAccount = await createTestAccountLite(testEnv, { + label: 'Bob', + fundAccount: true, + hasEoaAuthContext: true, + fundLedger: false, + hasPKP: true, + fundPKP: false, + hasPKPAuthContext: false, + fundPKPLedger: false, + }); + + console.log('bobAccount:', bobAccount); + + if (!bobAccount.pkp?.ethAddress) { + throw new Error("Bob's PKP does not have an ethAddress"); + } + + // 2. Next, create Alice, who will sponsor Bob + alice = await createTestAccountLite(testEnv, { + label: 'Alice', + fundAccount: true, + fundLedger: true, + hasPKP: true, + fundPKP: true, + fundPKPLedger: true, + sponsor: { + restrictions: { + totalMaxPriceInWei: + testEnv.config.sponsorshipLimits.totalMaxPriceInWei, + requestsPerPeriod: '100', + periodSeconds: '600', + }, + userAddresses: [bobAccount.account.address], + }, + }); + + // 3. Take a snapshot of Alice's Ledger balance before Bob's request + const aliceBeforeBalance = await testEnv.masterPaymentManager.getBalance({ + userAddress: alice.account.address, + }); + + console.log( + "[BEFORE] Alice's Ledger balance before Bob's request:", + aliceBeforeBalance + ); + + // 4. Now, Bob tries to sign with his PKP using Alice's sponsorship + await testEnv.litClient.chain.ethereum.pkpSign({ + authContext: bobAccount.eoaAuthContext!, + pubKey: bobAccount.pkp?.pubkey!, + toSign: 'Hello, world!', + userMaxPrice: testEnv.config.sponsorshipLimits.userMaxPrice, + }); + + // 5. Now, Alice removes Bob from her sponsorship + await alice.paymentManager!.undelegatePaymentsBatch({ + userAddresses: [bobAccount.account.address], + }); + + // 6. Bob should now fail to sign with his PKP due to lack of sponsorship + let didFail = false; + try { + await testEnv.litClient.chain.ethereum.pkpSign({ + authContext: bobAccount.eoaAuthContext!, + pubKey: bobAccount.pkp?.pubkey!, + toSign: 'Hello again, world!', + userMaxPrice: testEnv.config.sponsorshipLimits.userMaxPrice, + }); + } catch (e) { + didFail = true; + console.log( + "As expected, Bob's PKP sign failed after Alice removed sponsorship:", + e + ); + } + + expect(didFail).toBe(true); + + // 7. Finally, check that Alice's Ledger balance has decreased + // let's wait a big longer for the payment to be processed + await new Promise((resolve) => setTimeout(resolve, 5000)); + const aliceBalanceAfter = await testEnv.masterPaymentManager.getBalance({ + userAddress: alice.account.address, + }); + + console.log( + "[AFTER] Alice's Ledger balance after Bob's request:", + aliceBalanceAfter + ); + + expect(BigInt(aliceBalanceAfter.raw.availableBalance)).toBeLessThan( + BigInt(aliceBeforeBalance.raw.availableBalance) + ); + }); + }); +}); diff --git a/packages/e2e/src/tickets/pkp-mint-derived-pubkey.spec.ts b/packages/e2e/src/tickets/pkp-mint-derived-pubkey.spec.ts new file mode 100644 index 000000000..ee6dc1fe6 --- /dev/null +++ b/packages/e2e/src/tickets/pkp-mint-derived-pubkey.spec.ts @@ -0,0 +1,3 @@ +import { registerPkpMintDerivedPubkeyTicketSuite } from './pkp-mint-derived-pubkey.suite'; + +registerPkpMintDerivedPubkeyTicketSuite(); diff --git a/packages/e2e/src/tickets/pkp-mint-derived-pubkey.suite.ts b/packages/e2e/src/tickets/pkp-mint-derived-pubkey.suite.ts new file mode 100644 index 000000000..adb0d6a5e --- /dev/null +++ b/packages/e2e/src/tickets/pkp-mint-derived-pubkey.suite.ts @@ -0,0 +1,246 @@ +import { + nagaDevSignatures, + nagaProtoSignatures, + nagaSignatures, + nagaStagingSignatures, + nagaTestSignatures, +} from '@lit-protocol/contracts'; +import { buildSignaturesFromContext } from '@lit-protocol/contracts/custom-network-signatures'; +import { createPublicClient, getContract, http, type Hex } from 'viem'; + +import { createEnvVars, type EnvVars } from '../helper/createEnvVars'; +import { createTestEnv } from '../helper/createTestEnv'; + +const KEY_SET_ID = 'naga-keyset1'; +type RequiredSignatures = { + PKPNFT?: { address?: string }; + PubkeyRouter?: { address?: string }; + Staking?: { address?: string }; +}; + +const PKP_NFT_ABI = [ + { + name: 'getNextDerivedKeyId', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'bytes32' }], + }, +] as const; + +const PUBKEY_ROUTER_ABI = [ + { + name: 'getRootKeys', + type: 'function', + stateMutability: 'view', + inputs: [ + { name: 'stakingContract', type: 'address' }, + { name: 'keySetId', type: 'string' }, + ], + outputs: [ + { + type: 'tuple[]', + components: [ + { name: 'pubkey', type: 'bytes' }, + { name: 'keyType', type: 'uint256' }, + ], + }, + ], + }, + { + name: 'getDerivedPubkey', + type: 'function', + stateMutability: 'view', + inputs: [ + { name: 'stakingContract', type: 'address' }, + { name: 'keySetId', type: 'string' }, + { name: 'derivedKeyId', type: 'bytes32' }, + ], + outputs: [{ type: 'bytes' }], + }, + { + name: 'deriveEthAddressFromPubkey', + type: 'function', + stateMutability: 'pure', + inputs: [{ name: 'pubkey', type: 'bytes' }], + outputs: [{ type: 'address' }], + }, +] as const; + +const resolveSignatures = (envVars: EnvVars): RequiredSignatures => { + switch (envVars.network) { + case 'naga': + return nagaSignatures; + case 'naga-proto': + return nagaProtoSignatures; + case 'naga-staging': + return nagaStagingSignatures; + case 'naga-test': + return nagaTestSignatures; + case 'naga-dev': + return nagaDevSignatures; + case 'naga-local': { + if (!envVars.localContextPath) { + throw new Error( + 'naga-local requires NAGA_LOCAL_CONTEXT_PATH to be set' + ); + } + const { signatures } = buildSignaturesFromContext({ + jsonFilePath: envVars.localContextPath, + networkName: envVars.network, + }); + return signatures as RequiredSignatures; + } + default: { + const exhaustiveCheck: never = envVars.network; + throw new Error(`Unsupported network: ${exhaustiveCheck}`); + } + } +}; + +const requireAddress = ( + signatures: RequiredSignatures, + key: keyof RequiredSignatures, + network: EnvVars['network'] +) => { + const address = signatures[key]?.address; + if (!address) { + throw new Error( + `[pkp-mint-derived-pubkey] missing ${String(key)} address for ${network}` + ); + } + return address; +}; + +const summarizeRootKeys = ( + rootKeys: ReadonlyArray<{ keyType: bigint; pubkey: Hex }> +) => { + const summary: Record = {}; + + for (const rootKey of rootKeys) { + const keyType = rootKey.keyType.toString(); + const byteLength = (rootKey.pubkey.length - 2) / 2; + + if (!summary[keyType]) { + summary[keyType] = { count: 0, byteLengths: [] }; + } + + summary[keyType].count += 1; + if (!summary[keyType].byteLengths.includes(byteLength)) { + summary[keyType].byteLengths.push(byteLength); + } + } + + for (const entry of Object.values(summary)) { + entry.byteLengths.sort((a, b) => a - b); + } + + return summary; +}; + +export function registerPkpMintDerivedPubkeyTicketSuite() { + describe('pkp mint derived pubkey', () => { + let envVars: ReturnType; + let testEnv: Awaited>; + let signatures: RequiredSignatures; + + beforeAll(async () => { + envVars = createEnvVars(); + testEnv = await createTestEnv(envVars); + signatures = resolveSignatures(envVars); + }); + + it('derives a pubkey that can produce an ETH address', async () => { + const rpcUrl = envVars.rpcUrl ?? testEnv.networkModule.getRpcUrl(); + const publicClient = createPublicClient({ + chain: testEnv.networkModule.getChainConfig(), + transport: http(rpcUrl), + }); + + const pkpNft = getContract({ + address: requireAddress(signatures, 'PKPNFT', envVars.network), + abi: PKP_NFT_ABI, + client: { public: publicClient }, + }); + + const pubkeyRouter = getContract({ + address: requireAddress(signatures, 'PubkeyRouter', envVars.network), + abi: PUBKEY_ROUTER_ABI, + client: { public: publicClient }, + }); + + const stakingAddress = requireAddress( + signatures, + 'Staking', + envVars.network + ); + const rootKeys = await pubkeyRouter.read.getRootKeys([ + stakingAddress, + KEY_SET_ID, + ]); + + console.log( + '[pkp-mint-derived-pubkey] root keys total:', + rootKeys.length + ); + console.log( + '[pkp-mint-derived-pubkey] root key summary:', + summarizeRootKeys(rootKeys) + ); + + if (rootKeys.length === 0) { + throw new Error( + `[pkp-mint-derived-pubkey] no root keys returned for ${envVars.network} (${KEY_SET_ID})` + ); + } + + const derivedKeyId = await pkpNft.read.getNextDerivedKeyId(); + console.log('[pkp-mint-derived-pubkey] derived key id:', derivedKeyId); + const derivedPubkey = await pubkeyRouter.read.getDerivedPubkey([ + stakingAddress, + KEY_SET_ID, + derivedKeyId, + ]); + const derivedPubkeyBytes = (derivedPubkey.length - 2) / 2; + const hasK256Roots = rootKeys.some((rootKey) => rootKey.keyType === 2n); + + console.log( + '[pkp-mint-derived-pubkey] derived pubkey bytes:', + derivedPubkeyBytes + ); + console.log('[pkp-mint-derived-pubkey] derived pubkey:', derivedPubkey); + + if (derivedPubkeyBytes === 0) { + const hint = hasK256Roots + ? 'root keys exist; HD KDF precompile may be missing or misconfigured' + : 'no keyType=2 root keys found'; + throw new Error( + `[pkp-mint-derived-pubkey] derived pubkey is empty for ${envVars.network} (${hint}).` + ); + } + + if (derivedPubkeyBytes < 65) { + throw new Error( + `[pkp-mint-derived-pubkey] derived pubkey too short (${derivedPubkeyBytes} bytes) for ${envVars.network}` + ); + } + + try { + const ethAddress = await pubkeyRouter.read.deriveEthAddressFromPubkey([ + derivedPubkey, + ]); + console.log( + '[pkp-mint-derived-pubkey] derived eth address:', + ethAddress + ); + expect(ethAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + } catch (error) { + console.log( + '[pkp-mint-derived-pubkey] deriveEthAddressFromPubkey failed:', + error + ); + throw error; + } + }); + }); +} diff --git a/packages/wrapped-keys/src/lib/service-client/constants.ts b/packages/wrapped-keys/src/lib/service-client/constants.ts index b05f6e7bb..b62a117c8 100644 --- a/packages/wrapped-keys/src/lib/service-client/constants.ts +++ b/packages/wrapped-keys/src/lib/service-client/constants.ts @@ -9,6 +9,7 @@ const SERVICE_URL_BY_NETWORKTYPE: Record = { }; export const SERVICE_URL_BY_LIT_NETWORK: Record = { + [LIT_NETWORK.Naga]: SERVICE_URL_BY_NETWORKTYPE.Production, [LIT_NETWORK.NagaDev]: SERVICE_URL_BY_NETWORKTYPE.TestNetworks, [LIT_NETWORK.NagaTest]: SERVICE_URL_BY_NETWORKTYPE.TestNetworks, }; diff --git a/packages/wrapped-keys/src/lib/service-client/types.ts b/packages/wrapped-keys/src/lib/service-client/types.ts index 1e7291ef4..3aeaab164 100644 --- a/packages/wrapped-keys/src/lib/service-client/types.ts +++ b/packages/wrapped-keys/src/lib/service-client/types.ts @@ -17,7 +17,7 @@ export type ListKeysParams = BaseApiParams & { pkpAddress: string }; export type SupportedNetworks = Extract< LIT_NETWORK_VALUES, - 'naga-dev' | 'naga-test' + 'naga' | 'naga-dev' | 'naga-test' >; export interface StoreKeyParams extends BaseApiParams { diff --git a/packages/wrapped-keys/src/lib/service-client/utils.ts b/packages/wrapped-keys/src/lib/service-client/utils.ts index f4eb1fc4c..e11c39f31 100644 --- a/packages/wrapped-keys/src/lib/service-client/utils.ts +++ b/packages/wrapped-keys/src/lib/service-client/utils.ts @@ -16,7 +16,7 @@ function composeAuthHeader(sessionSig: AuthSig) { ).toString('base64')}`; } -const supportedNetworks: SupportedNetworks[] = ['naga-dev', 'naga-test']; +const supportedNetworks: SupportedNetworks[] = ['naga', 'naga-dev', 'naga-test']; function isSupportedLitNetwork( litNetwork: LIT_NETWORK_VALUES diff --git a/tools/money-back.ts b/tools/money-back.ts new file mode 100644 index 000000000..a719cdff5 --- /dev/null +++ b/tools/money-back.ts @@ -0,0 +1,508 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { createPublicClient, createWalletClient, formatEther, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { createLitClient } from '@lit-protocol/lit-client'; +import { + readGeneratedAccounts, + type GeneratedAccountRecord, +} from '../packages/e2e/src/helper/generated-accounts'; +import { resolveNetwork } from '../packages/e2e/src/helper/network'; + +type PendingWithdrawal = { + address: `0x${string}`; + label?: string; + amountEth: string; + requestedAt: string; + timeRemainingSeconds?: number; + lastCheckedAt: string; +}; + +type WithdrawalState = { + version: 1; + updatedAt: string; + network: string; + destination: `0x${string}`; + pending: PendingWithdrawal[]; +}; + +type CliFlags = { + withdraw: boolean; + yes: boolean; + help: boolean; +}; + +const ACCOUNTS_FILE = path.resolve( + process.cwd(), + '.e2e', + 'generated-accounts.jsonl' +); +const STATE_FILE = path.resolve( + process.cwd(), + '.e2e', + 'withdrawal-state.json' +); + +function parseFlags(argv: string[]): CliFlags { + return { + withdraw: argv.includes('--withdraw'), + yes: argv.includes('--yes'), + help: argv.includes('--help') || argv.includes('-h'), + }; +} + +function formatWei(wei: bigint): string { + return `${wei.toString()} wei (${formatEther(wei)} ETH)`; +} + +function toIsoFromSeconds(seconds: string): string { + const parsed = Number(seconds); + if (Number.isFinite(parsed) && parsed > 0) { + return new Date(parsed * 1000).toISOString(); + } + return new Date().toISOString(); +} + +function dedupeAccounts( + records: GeneratedAccountRecord[] +): GeneratedAccountRecord[] { + const seen = new Set(); + const unique: GeneratedAccountRecord[] = []; + + for (const record of records) { + const key = record.address.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + unique.push(record); + } + + return unique; +} + +function readWithdrawalState(filePath: string): WithdrawalState | null { + if (!fs.existsSync(filePath)) return null; + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return JSON.parse(raw) as WithdrawalState; + } catch (error) { + console.warn(`Failed to parse withdrawal state at ${filePath}:`, error); + return null; + } +} + +function writeWithdrawalState( + filePath: string, + state: WithdrawalState +): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +} + +async function confirmDestination( + destination: `0x${string}`, + autoYes: boolean +): Promise { + if (autoYes) { + console.log('Bypassing confirmation via --yes.'); + return; + } + + if (!process.stdin.isTTY) { + throw new Error('Refusing to proceed without --yes in non-interactive mode.'); + } + + const rl = readline.createInterface({ input, output }); + const answer = await rl.question( + `Confirm destination address (${destination}) by typing "yes": ` + ); + rl.close(); + + if (answer.trim().toLowerCase() !== 'yes') { + throw new Error('Aborted by user.'); + } +} + +async function sweepNativeBalance(params: { + publicClient: ReturnType; + chainConfig: ReturnType< + Awaited>['networkModule']['getChainConfig'] + >; + account: ReturnType; + destination: `0x${string}`; + rpcUrl: string; +}): Promise { + const { publicClient, chainConfig, account, destination, rpcUrl } = params; + + const balance = await publicClient.getBalance({ address: account.address }); + if (balance === 0n) { + console.log('Native balance is zero; skipping sweep.'); + return; + } + + const gas = 21_000n; + let gasPrice: bigint | undefined; + let maxFeePerGas: bigint | undefined; + let maxPriorityFeePerGas: bigint | undefined; + let feePerGas: bigint | undefined; + + try { + const feeEstimate = await publicClient.estimateFeesPerGas(); + if ('gasPrice' in feeEstimate && feeEstimate.gasPrice) { + gasPrice = feeEstimate.gasPrice; + feePerGas = gasPrice; + } else if ('maxFeePerGas' in feeEstimate && feeEstimate.maxFeePerGas) { + maxFeePerGas = feeEstimate.maxFeePerGas; + maxPriorityFeePerGas = (feeEstimate as { maxPriorityFeePerGas?: bigint }) + .maxPriorityFeePerGas; + feePerGas = maxFeePerGas; + } + } catch { + gasPrice = await publicClient.getGasPrice(); + feePerGas = gasPrice; + } + + if (!feePerGas) { + console.warn('Unable to estimate gas fee; skipping native sweep.'); + return; + } + + const maxTxFee = gas * feePerGas; + if (balance <= maxTxFee) { + console.log( + `Balance ${formatWei(balance)} is not enough to cover gas ${formatWei( + maxTxFee + )}; skipping sweep.` + ); + return; + } + + const value = balance - maxTxFee; + + const walletClient = createWalletClient({ + account, + chain: chainConfig, + transport: http(rpcUrl), + }); + + const hash = await walletClient.sendTransaction({ + to: destination, + value, + gas, + ...(gasPrice ? { gasPrice } : {}), + ...(maxFeePerGas ? { maxFeePerGas } : {}), + ...(maxPriorityFeePerGas ? { maxPriorityFeePerGas } : {}), + }); + + await publicClient.waitForTransactionReceipt({ hash }); + + console.log( + `Swept ${formatWei(value)} from ${account.address} -> ${destination} (${hash}).` + ); +} + +async function main(): Promise { + if (!process.env['LOG_LEVEL']) { + process.env['LOG_LEVEL'] = 'silent'; + } + + const flags = parseFlags(process.argv.slice(2)); + + if (flags.help) { + console.log(`Usage: tsx tools/money-back.ts [--withdraw] [--yes] + +Environment: + NETWORK (required) + LIVE_MASTER_ACCOUNT (required) + LIT_MAINNET_RPC_URL (required) +`); + return; + } + + const networkInput = process.env['NETWORK']; + if (!networkInput) { + throw new Error('NETWORK is required.'); + } + + const masterPrivateKey = process.env['LIVE_MASTER_ACCOUNT'] as + | `0x${string}` + | undefined; + if (!masterPrivateKey) { + throw new Error('LIVE_MASTER_ACCOUNT is required.'); + } + + const mainnetRpcUrl = process.env['LIT_MAINNET_RPC_URL']; + if (!mainnetRpcUrl) { + throw new Error('LIT_MAINNET_RPC_URL is required.'); + } + + const destination = privateKeyToAccount(masterPrivateKey).address; + + const accounts = readGeneratedAccounts({ filePath: ACCOUNTS_FILE }); + if (accounts.length === 0) { + console.log(`No generated accounts found at ${ACCOUNTS_FILE}.`); + return; + } + + const uniqueAccounts = dedupeAccounts(accounts); + + console.log(`Loaded ${uniqueAccounts.length} accounts from ${ACCOUNTS_FILE}.`); + console.log(`Network: ${networkInput}`); + console.log(`Destination: ${destination}`); + + if (flags.withdraw) { + const previousState = readWithdrawalState(STATE_FILE); + if (previousState?.pending?.length) { + console.log( + `Loaded ${previousState.pending.length} pending withdrawals from ${STATE_FILE}.` + ); + } + await confirmDestination(destination, flags.yes); + } + + const rpcOverrideEnvVar = + networkInput === 'naga' || networkInput === 'naga-proto' + ? 'LIT_MAINNET_RPC_URL' + : 'LIT_YELLOWSTONE_PRIVATE_RPC_URL'; + const rpcOverride = process.env[rpcOverrideEnvVar]; + + const resolvedNetwork = await resolveNetwork({ + network: networkInput, + rpcUrlOverride: rpcOverride, + }); + + if (rpcOverride) { + console.log(`Using RPC override (${rpcOverrideEnvVar}).`); + } + + const chainConfig = resolvedNetwork.networkModule.getChainConfig(); + const publicClient = createPublicClient({ + chain: chainConfig, + transport: http(mainnetRpcUrl), + }); + + const litClient = await createLitClient({ + network: resolvedNetwork.networkModule, + }); + + const pendingEntries: PendingWithdrawal[] = []; + + for (const record of uniqueAccounts) { + const account = privateKeyToAccount(record.privateKey); + const accountId = `${account.address}${record.label ? ` (${record.label})` : ''}`; + + console.log('\n---'); + console.log(`Account: ${accountId}`); + if (record.network) { + console.log(`Recorded network: ${record.network}`); + } + + const nativeBalance = await publicClient.getBalance({ + address: account.address, + }); + console.log(`Native balance: ${formatWei(nativeBalance)}`); + + const isDestination = + account.address.toLowerCase() === destination.toLowerCase(); + if (isDestination) { + console.log('Account is the destination; skipping withdrawals and sweep.'); + continue; + } + + let paymentManager: + | Awaited> + | null = null; + try { + paymentManager = await litClient.getPaymentManager({ account }); + } catch (error) { + console.warn('Failed to create PaymentManager:', error); + continue; + } + if (!paymentManager) { + console.warn('PaymentManager unavailable; skipping.'); + continue; + } + + let ledgerBalance: + | Awaited> + | null = null; + try { + ledgerBalance = await paymentManager.getBalance({ + userAddress: account.address, + }); + console.log( + `Ledger balance: total=${ledgerBalance.totalBalance} ETH available=${ledgerBalance.availableBalance} ETH` + ); + } catch (error) { + console.warn('Failed to fetch ledger balance:', error); + } + + let withdrawStatus: + | Awaited> + | null = null; + try { + withdrawStatus = await paymentManager.canExecuteWithdraw({ + userAddress: account.address, + }); + if (withdrawStatus.withdrawRequest.isPending) { + const remaining = + withdrawStatus.timeRemaining !== undefined + ? `${withdrawStatus.timeRemaining}s remaining` + : 'eligible'; + console.log( + `Pending withdrawal: ${withdrawStatus.withdrawRequest.amount} ETH (${remaining})` + ); + } else { + console.log('No pending withdrawal request.'); + } + } catch (error) { + console.warn('Failed to fetch withdraw request:', error); + } + + if (!flags.withdraw) { + continue; + } + + if (!ledgerBalance || !withdrawStatus) { + console.log('Skipping withdrawal actions due to missing ledger data.'); + continue; + } + + const hadPendingBefore = withdrawStatus.withdrawRequest.isPending; + let updatedWithdrawStatus = withdrawStatus; + let statusReliable = true; + let requestedWithdrawal = false; + let executedWithdrawal = false; + + const refreshWithdrawStatus = async () => { + try { + updatedWithdrawStatus = await paymentManager.canExecuteWithdraw({ + userAddress: account.address, + }); + } catch (error) { + statusReliable = false; + console.warn('Failed to refresh withdrawal status:', error); + } + }; + + if (updatedWithdrawStatus.withdrawRequest.isPending) { + if (updatedWithdrawStatus.canExecute) { + try { + console.log( + `Executing withdrawal for ${updatedWithdrawStatus.withdrawRequest.amount} ETH...` + ); + await paymentManager.withdraw({ + amountInEth: updatedWithdrawStatus.withdrawRequest.amount, + }); + executedWithdrawal = true; + } catch (error) { + console.warn('Withdrawal execution failed:', error); + } + await refreshWithdrawStatus(); + } else { + console.log('Withdrawal pending; waiting for delay to elapse.'); + } + } else if (ledgerBalance.raw.availableBalance > 0n) { + try { + console.log( + `Requesting withdrawal for ${ledgerBalance.availableBalance} ETH...` + ); + await paymentManager.requestWithdraw({ + amountInEth: ledgerBalance.availableBalance, + }); + requestedWithdrawal = true; + } catch (error) { + console.warn('Withdrawal request failed:', error); + } + + await refreshWithdrawStatus(); + + if ( + updatedWithdrawStatus.withdrawRequest.isPending && + updatedWithdrawStatus.canExecute + ) { + try { + console.log( + `Executing withdrawal for ${updatedWithdrawStatus.withdrawRequest.amount} ETH...` + ); + await paymentManager.withdraw({ + amountInEth: updatedWithdrawStatus.withdrawRequest.amount, + }); + executedWithdrawal = true; + } catch (error) { + console.warn('Withdrawal execution failed:', error); + } + + await refreshWithdrawStatus(); + } + } else { + console.log('Ledger available balance is zero; no withdrawal requested.'); + } + + const pending = statusReliable + ? updatedWithdrawStatus.withdrawRequest.isPending + : !executedWithdrawal && (hadPendingBefore || requestedWithdrawal); + + if (pending) { + const pendingRequest = statusReliable + ? updatedWithdrawStatus.withdrawRequest + : requestedWithdrawal + ? { + amount: ledgerBalance.availableBalance, + timestamp: `${Math.floor(Date.now() / 1000)}`, + } + : withdrawStatus.withdrawRequest; + const timeRemainingSeconds = + statusReliable && updatedWithdrawStatus.timeRemaining !== undefined + ? updatedWithdrawStatus.timeRemaining + : undefined; + const pendingEntry: PendingWithdrawal = { + address: account.address, + label: record.label, + amountEth: pendingRequest.amount, + requestedAt: toIsoFromSeconds(pendingRequest.timestamp), + timeRemainingSeconds, + lastCheckedAt: new Date().toISOString(), + }; + pendingEntries.push(pendingEntry); + console.log( + 'Skipping native sweep while a withdrawal is pending to preserve gas for a later run.' + ); + continue; + } + + try { + console.log('Sweeping native balance to destination...'); + await sweepNativeBalance({ + publicClient, + chainConfig, + account, + destination, + rpcUrl: mainnetRpcUrl, + }); + } catch (error) { + console.warn('Native sweep failed:', error); + } + } + + if (flags.withdraw) { + const state: WithdrawalState = { + version: 1, + updatedAt: new Date().toISOString(), + network: networkInput, + destination, + pending: pendingEntries, + }; + writeWithdrawalState(STATE_FILE, state); + console.log( + `Saved withdrawal state (${pendingEntries.length} pending) to ${STATE_FILE}.` + ); + } +} + +main().catch((error) => { + console.error('money-back failed:', error); + process.exit(1); +});