UNPKG

stint-signer

Version:

Short-lived, non-custodial session signer using passkeys for Cosmos SDK

1,040 lines (1,033 loc) 36.1 kB
'use strict'; var stargate = require('@cosmjs/stargate'); var protoSigning = require('@cosmjs/proto-signing'); var coin = require('cosmjs-types/cosmos/base/v1beta1/coin'); var timestamp = require('cosmjs-types/google/protobuf/timestamp'); var authz = require('cosmjs-types/cosmos/bank/v1beta1/authz'); var feegrant = require('cosmjs-types/cosmos/feegrant/v1beta1/feegrant'); var any = require('cosmjs-types/google/protobuf/any'); var encoding = require('@cosmjs/encoding'); var tx = require('cosmjs-types/cosmos/bank/v1beta1/tx'); // src/stint.ts // src/errors.ts var StintError = class extends Error { constructor(message, code, details) { super(message); this.code = code; this.details = details; this.name = "StintError"; } }; var ErrorCodes = { // WebAuthn and Passkey errors WEBAUTHN_NOT_SUPPORTED: "WEBAUTHN_NOT_SUPPORTED", PASSKEY_CREATION_FAILED: "PASSKEY_CREATION_FAILED", PASSKEY_AUTHENTICATION_FAILED: "PASSKEY_AUTHENTICATION_FAILED", PRF_NOT_SUPPORTED: "PRF_NOT_SUPPORTED", USER_CANCELLED: "USER_CANCELLED", // Client initialization errors CLIENT_INITIALIZATION_FAILED: "CLIENT_INITIALIZATION_FAILED", SIGNER_EXTRACTION_FAILED: "SIGNER_EXTRACTION_FAILED", RPC_URL_EXTRACTION_FAILED: "RPC_URL_EXTRACTION_FAILED", // Grant checking errors GRANT_CHECK_FAILED: "GRANT_CHECK_FAILED", INVALID_RESPONSE: "INVALID_RESPONSE", // Validation errors INVALID_ADDRESS: "INVALID_ADDRESS", INVALID_AMOUNT: "INVALID_AMOUNT", INVALID_DENOMINATION: "INVALID_DENOMINATION", INVALID_RPC_URL: "INVALID_RPC_URL", // Key generation errors KEY_GENERATION_FAILED: "KEY_GENERATION_FAILED" }; // src/logger.ts var consoleLogger = { debug: (message, context) => { if (context) { console.log(`[Stint] ${message}`, context); } else { console.log(`[Stint] ${message}`); } }, info: (message, context) => { if (context) { console.info(`[Stint] ${message}`, context); } else { console.info(`[Stint] ${message}`); } }, warn: (message, context) => { if (context) { console.warn(`[Stint] ${message}`, context); } else { console.warn(`[Stint] ${message}`); } }, error: (message, error, context) => { if (error && context) { console.error(`[Stint] ${message}`, error, context); } else if (error) { console.error(`[Stint] ${message}`, error); } else if (context) { console.error(`[Stint] ${message}`, context); } else { console.error(`[Stint] ${message}`); } } }; var noopLogger = { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } }; // src/passkey.ts function generateStintSalt(userAddress, purpose, windowHours = 24, windowNumber) { const now = Date.now(); const windowMs = windowHours * 60 * 60 * 1e3; const calculatedWindowNumber = windowNumber ?? Math.floor(now / windowMs); const domain = window.location.hostname; return `${domain}:${userAddress}:${purpose}:${calculatedWindowNumber}`; } function getWindowBoundaries(windowHours = 24) { const now = Date.now(); const windowMs = windowHours * 60 * 60 * 1e3; const windowNumber = Math.floor(now / windowMs); return { start: new Date(windowNumber * windowMs), end: new Date((windowNumber + 1) * windowMs), windowNumber }; } function getSecureRpId() { const hostname = window.location.hostname; if (!hostname || hostname === "localhost" || /^[\d.]+$/.test(hostname) || /^[a-zA-Z0-9.-]+$/.test(hostname)) { return hostname; } throw new StintError("Invalid hostname for WebAuthn", ErrorCodes.WEBAUTHN_NOT_SUPPORTED, { hostname }); } async function hkdf(ikm, salt, info, length) { const key = await crypto.subtle.importKey("raw", ikm.buffer, "HKDF", false, [ "deriveBits" ]); const derivedBits = await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", salt: salt.buffer, info: info.buffer }, key, length * 8 // Convert bytes to bits ); return new Uint8Array(derivedBits); } async function getOrCreateDerivedKey(options) { const logger = options.logger || noopLogger; const purpose = options.saltName || "stint-session"; const windowHours = options.stintWindowHours || 24; const stintSalt = generateStintSalt(options.address, purpose, windowHours, options.windowNumber); logger.debug("Starting passkey derivation", { address: options.address.slice(0, 10) + "...", purpose, windowHours, stintSalt }); if (!window.PublicKeyCredential) { logger.error("WebAuthn not supported in this browser"); throw new StintError("WebAuthn not supported", ErrorCodes.WEBAUTHN_NOT_SUPPORTED, { userAgent: navigator.userAgent }); } let existingCredential = null; try { existingCredential = await getExistingPasskey(options.address, stintSalt, logger); } catch (error) { if (error instanceof Error && (error.name === "NotAllowedError" || error.name === "AbortError")) { logger.warn("User cancelled passkey operation"); throw new StintError("Passkey operation cancelled", ErrorCodes.USER_CANCELLED, { operation: "getExisting", error: error.message }); } logger.debug("No existing passkey found, will create new one"); } if (existingCredential) { logger.debug("Found existing passkey"); if (existingCredential.prfSupported && existingCredential.prfOutput) { const saltBytes = new TextEncoder().encode(stintSalt); const infoBytes = new TextEncoder().encode("stint-key-derivation"); const privateKey2 = await hkdf(existingCredential.prfOutput, saltBytes, infoBytes, 32); logger.debug("Session key ready"); return { credentialId: existingCredential.credentialId, privateKey: privateKey2 }; } else if (existingCredential.prfSupported) { try { const privateKey2 = await derivePrivateKey( existingCredential.credentialId, stintSalt, logger ); logger.debug("Session key ready"); return { credentialId: existingCredential.credentialId, privateKey: privateKey2 }; } catch (error) { if (error instanceof Error && (error.name === "NotAllowedError" || error.name === "AbortError")) { logger.warn("User cancelled existing passkey authentication"); throw new StintError( "Authentication with existing passkey was cancelled", ErrorCodes.PASSKEY_AUTHENTICATION_FAILED, { operation: "derivePrivateKey", error: error.message } ); } logger.warn("Failed to derive key from existing passkey, will create new one", { error: error instanceof Error ? error.message : "Unknown error" }); } } else { logger.error("Existing passkey does not support PRF extension"); throw new StintError( "Existing passkey does not support PRF extension", ErrorCodes.PRF_NOT_SUPPORTED, { suggestion: "Please delete the existing passkey for this site and create a new one" } ); } } logger.debug("Creating new passkey"); const credential = await createPasskey( { userName: options.address, userDisplayName: options.displayName || `Stint: ${options.address.slice(0, 10)}...` }, logger ); const privateKey = await derivePrivateKey(credential.id, stintSalt, logger); logger.debug("Session key ready"); return { credentialId: credential.id, privateKey }; } async function getExistingPasskey(address, stintSalt, logger = noopLogger) { const challenge = crypto.getRandomValues(new Uint8Array(32)).buffer; try { const publicKeyCredentialRequestOptions = { challenge, rpId: getSecureRpId(), userVerification: "required", allowCredentials: [], // Let user select any credential for this domain timeout: 6e4, // 60 seconds for authentication extensions: { prf: { eval: { first: new TextEncoder().encode(stintSalt + "\0").buffer, second: new TextEncoder().encode(stintSalt + "").buffer } } } }; const assertion = await navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions }); if (!assertion) { return null; } const userHandle = assertion.response.userHandle; if (userHandle) { const userIdFromPasskey = new TextDecoder().decode(userHandle); if (userIdFromPasskey !== address) { logger.debug("Passkey user ID does not match current address", { expected: address, actual: userIdFromPasskey }); return null; } } else { logger.debug("Passkey has no userHandle, cannot validate address"); return null; } const clientExtensionResults = assertion.getClientExtensionResults(); const prfResult1 = clientExtensionResults.prf?.results?.first; const prfResult2 = clientExtensionResults.prf?.results?.second; const prfSupported = !!prfResult1; let prfOutput; if (prfResult1) { let output1; if (prfResult1 instanceof ArrayBuffer) { output1 = new Uint8Array(prfResult1); } else { output1 = new Uint8Array(prfResult1); } let output2; if (prfResult2) { if (prfResult2 instanceof ArrayBuffer) { output2 = new Uint8Array(prfResult2); } else { output2 = new Uint8Array(prfResult2); } } else { output2 = output1; } const combinedOutput = new Uint8Array(output1.length + output2.length); combinedOutput.set(output1); combinedOutput.set(output2, output1.length); prfOutput = combinedOutput; } return { credentialId: assertion.id, prfSupported, prfOutput }; } catch (error) { if (error instanceof Error) { if (error.name === "NotAllowedError") { logger.debug("User cancelled passkey authentication or invalid credential for domain", { error: error.message, suggestion: "This might be due to selecting a passkey from a different domain" }); } else if (error.name === "SecurityError") { logger.debug("Security error during passkey authentication", { error: error.message, suggestion: "This might be due to domain mismatch or invalid RP ID" }); } else { logger.debug("Passkey authentication failed", { error: error.message, errorType: error.name }); } } else { logger.debug("Unknown error during passkey authentication", { error: "Unknown error" }); } return null; } } async function createPasskey(options, logger = noopLogger) { const challenge = crypto.getRandomValues(new Uint8Array(32)).buffer; const publicKeyCredentialCreationOptions = { challenge, rp: { id: getSecureRpId(), name: "Stint Session Signer" }, user: { id: new TextEncoder().encode(options.userName), name: options.userName, displayName: options.userDisplayName }, pubKeyCredParams: [ { alg: -7, type: "public-key" }, // ES256 { alg: -257, type: "public-key" } // RS256 ], authenticatorSelection: { // Remove platform restriction to allow 1Password and other passkey managers userVerification: "required", requireResidentKey: false, residentKey: "preferred" }, timeout: 12e4, attestation: "none", extensions: { prf: {} } }; const credential = await navigator.credentials.create({ publicKey: publicKeyCredentialCreationOptions }); if (!credential) { logger.error("Failed to create passkey - credential is null"); throw new StintError("Failed to create passkey", ErrorCodes.PASSKEY_CREATION_FAILED, { reason: "Credential creation returned null" }); } await new Promise((resolve) => setTimeout(resolve, 100)); const clientExtensionResults = credential.getClientExtensionResults(); const prfExtension = clientExtensionResults.prf; if (!prfExtension) { logger.error("Passkey created but PRF extension not enabled"); throw new StintError( "Passkey created but PRF extension not enabled", ErrorCodes.PRF_NOT_SUPPORTED, { suggestion: "Your browser or authenticator may not support the PRF extension" } ); } return credential; } async function getPasskeyPRF(credentialId, stintSalt, logger = noopLogger) { const challenge = crypto.getRandomValues(new Uint8Array(32)).buffer; const base64 = credentialId.replace(/-/g, "+").replace(/_/g, "/"); const padded = base64 + "===".slice(0, (4 - base64.length % 4) % 4); const credentialIdBytes = encoding.fromBase64(padded); const publicKeyCredentialRequestOptions = { challenge, rpId: getSecureRpId(), userVerification: "required", // Require user verification for security allowCredentials: [ { id: credentialIdBytes.buffer, type: "public-key" } ], extensions: { prf: { eval: { first: new TextEncoder().encode(stintSalt + "\0").buffer, second: new TextEncoder().encode(stintSalt + "").buffer } } }, timeout: 6e4 // 60 seconds for authentication }; const assertion = await navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions }); if (!assertion) { logger.error("Failed to get passkey assertion"); throw new StintError( "Failed to get passkey assertion", ErrorCodes.PASSKEY_AUTHENTICATION_FAILED, { operation: "getPRF" } ); } const clientExtensionResults = assertion.getClientExtensionResults(); if (!clientExtensionResults.prf?.results?.first) { logger.error("PRF extension not supported or no output"); throw new StintError("PRF extension not supported or no output", ErrorCodes.PRF_NOT_SUPPORTED, { suggestion: "Your browser or authenticator may not support the PRF extension" }); } const prfResult1 = clientExtensionResults.prf.results.first; const prfResult2 = clientExtensionResults.prf.results.second; let output1; if (prfResult1 instanceof ArrayBuffer) { output1 = new Uint8Array(prfResult1); } else { output1 = new Uint8Array(prfResult1); } let output2; if (prfResult2) { if (prfResult2 instanceof ArrayBuffer) { output2 = new Uint8Array(prfResult2); } else { output2 = new Uint8Array(prfResult2); } } else { output2 = output1; } const combinedOutput = new Uint8Array(output1.length + output2.length); combinedOutput.set(output1); combinedOutput.set(output2, output1.length); return combinedOutput; } async function derivePrivateKey(credentialId, stintSalt, logger = noopLogger) { const prfOutput = await getPasskeyPRF(credentialId, stintSalt, logger); const saltBytes = new TextEncoder().encode(stintSalt); const infoBytes = new TextEncoder().encode("stint-key-derivation"); return hkdf(prfOutput, saltBytes, infoBytes, 32); } function wrapInMsgExec(granteeAddress, messages) { const execMsg = { typeUrl: "/cosmos.authz.v1beta1.MsgExec", value: { grantee: granteeAddress, msgs: messages } }; return execMsg; } function createFeeWithGranter(granterAddress, fee) { if (fee === "auto") { return "auto"; } const defaultFee = { amount: [{ denom: "uphoton", amount: "5000" }], gas: "200000" }; const feeWithGranter = { ...defaultFee, ...fee, granter: granterAddress }; return feeWithGranter; } async function send(sessionSigner, params, logger) { const { toAddress, amount, memo = "", fee } = params; logger.info("Executing send with session signer", { toAddress, amount, memo: memo.slice(0, 50) + (memo.length > 50 ? "..." : "") }); if (!toAddress) { throw new StintError("Invalid recipient address", ErrorCodes.INVALID_ADDRESS, { toAddress }); } if (!amount || amount.length === 0) { throw new StintError("Invalid amount", ErrorCodes.INVALID_AMOUNT, { amount }); } try { const msgSend = tx.MsgSend.fromPartial({ fromAddress: sessionSigner.primaryAddress(), toAddress, amount }); logger.debug("Created MsgSend", { fromAddress: sessionSigner.primaryAddress(), toAddress, amount }); const msgSendBytes = tx.MsgSend.encode(msgSend).finish(); const msgSendAny = any.Any.fromPartial({ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: msgSendBytes }); const execMsg = wrapInMsgExec(sessionSigner.sessionAddress(), [msgSendAny]); const feeWithGranter = createFeeWithGranter(sessionSigner.primaryAddress(), fee); logger.debug("Broadcasting transaction...", { signer: sessionSigner.sessionAddress(), feeGranter: sessionSigner.primaryAddress(), fee: feeWithGranter }); const result = await sessionSigner.client.signAndBroadcast( sessionSigner.sessionAddress(), [execMsg], feeWithGranter, memo ); if (!stargate.isDeliverTxSuccess(result)) { const errorLog = result.rawLog || "Transaction failed"; logger.error("Transaction failed on chain", void 0, { code: result.code, rawLog: errorLog }); throw new StintError(`Transaction failed: ${errorLog}`, ErrorCodes.INVALID_RESPONSE, { code: result.code, rawLog: errorLog }); } logger.info("Transaction successful", { transactionHash: result.transactionHash, gasUsed: result.gasUsed, gasWanted: result.gasWanted, height: result.height }); return result; } catch (error) { if (error instanceof StintError) { throw error; } logger.error("Failed to execute send", error); throw new StintError( "Failed to execute send transaction", ErrorCodes.CLIENT_INITIALIZATION_FAILED, { error: error instanceof Error ? error.message : String(error) } ); } } async function custom(sessionSigner, params, logger) { const { messages, memo = "", fee } = params; logger.info("Executing custom messages with session signer", { messageCount: messages.length, memo: memo.slice(0, 50) + (memo.length > 50 ? "..." : "") }); try { const execMsg = wrapInMsgExec(sessionSigner.sessionAddress(), messages); const feeWithGranter = createFeeWithGranter(sessionSigner.primaryAddress(), fee); logger.debug("Broadcasting custom transaction...", { signer: sessionSigner.sessionAddress(), feeGranter: sessionSigner.primaryAddress(), messageCount: messages.length }); const result = await sessionSigner.client.signAndBroadcast( sessionSigner.sessionAddress(), [execMsg], feeWithGranter, memo ); if (!stargate.isDeliverTxSuccess(result)) { const errorLog = result.rawLog || "Transaction failed"; logger.error("Transaction failed on chain", void 0, { code: result.code, rawLog: errorLog }); throw new StintError(`Transaction failed: ${errorLog}`, ErrorCodes.INVALID_RESPONSE, { code: result.code, rawLog: errorLog }); } logger.info("Custom transaction successful", { transactionHash: result.transactionHash, gasUsed: result.gasUsed, gasWanted: result.gasWanted, height: result.height }); return result; } catch (error) { if (error instanceof StintError) { throw error; } logger.error("Failed to execute custom messages", error); throw new StintError( "Failed to execute custom transaction", ErrorCodes.CLIENT_INITIALIZATION_FAILED, { error: error instanceof Error ? error.message : String(error) } ); } } function createExecuteHelpers(sessionSigner, logger) { return { send: (params) => send(sessionSigner, params, logger), custom: (params) => custom(sessionSigner, params, logger) }; } // src/stint.ts async function newSessionSigner(config) { const logger = config.logger || noopLogger; logger.debug("Initializing session signer", { saltName: config.saltName }); const extendedClient = config.primaryClient; const primarySigner = extendedClient.signer; if (!primarySigner) { logger.error("Failed to extract signer from primary client"); throw new StintError( "Failed to initialize session signer", ErrorCodes.SIGNER_EXTRACTION_FAILED, { reason: "Signer not available in primary client" } ); } const primaryAccounts = await primarySigner.getAccounts(); const primaryAddress = primaryAccounts[0].address; const prefix = primaryAddress.match(/^([a-z]+)1/)?.[1] || "atom"; const windowHours = config.stintWindowHours || 24; const now = Date.now(); const windowMs = windowHours * 60 * 60 * 1e3; const currentWindow = Math.floor(now / windowMs); const windowNumber = config.usePreviousWindow ? currentWindow - 1 : currentWindow; logger.debug("Creating session signer", { windowNumber, windowHours, usePreviousWindow: config.usePreviousWindow || false, keyMode: config.keyMode || "passkey" }); let privateKey; if (config.keyMode === "random") { logger.debug("Generating new random session key"); logger.warn("Random session key generated - will not persist across page refresh"); privateKey = crypto.getRandomValues(new Uint8Array(32)); } else { const derivedKey = await getOrCreateDerivedKey({ address: primaryAddress, displayName: `Stint: ${primaryAddress.slice(0, 10)}...`, saltName: config.saltName || "stint-session", stintWindowHours: windowHours, windowNumber, logger }); privateKey = derivedKey.privateKey; } const sessionSigner = await protoSigning.DirectSecp256k1Wallet.fromKey(privateKey, prefix); const sessionAccounts = await sessionSigner.getAccounts(); const sessionAddress = sessionAccounts[0].address; const rpcUrl = extendedClient.cometClient?.client?.url; if (!rpcUrl) { logger.error("Failed to extract RPC URL from primary client"); throw new StintError( "Failed to initialize session signer", ErrorCodes.RPC_URL_EXTRACTION_FAILED, { reason: "RPC URL not available in primary client" } ); } const originalGasPrice = extendedClient.gasPrice; let gasPrice; if (originalGasPrice instanceof stargate.GasPrice) { gasPrice = originalGasPrice; } else if (originalGasPrice && typeof originalGasPrice === "object" && "denom" in originalGasPrice && "amount" in originalGasPrice) { gasPrice = stargate.GasPrice.fromString(`${originalGasPrice.amount}${originalGasPrice.denom}`); } else { gasPrice = stargate.GasPrice.fromString("0.025uphoton"); } const client = await stargate.SigningStargateClient.connectWithSigner(rpcUrl, sessionSigner, { gasPrice }); const signer = { primarySigner, sessionSigner, client, // Methods - now synchronous with cached addresses primaryAddress: () => { return primaryAddress; }, sessionAddress: () => { return sessionAddress; }, // Methods - created by factory functions hasAuthzGrant: createHasAuthzGrant( config.primaryClient, primaryAddress, sessionAddress, logger ), hasFeegrant: createHasFeegrant(config.primaryClient, primaryAddress, sessionAddress, logger), // Methods - message generation (implemented inline) generateDelegationMessages: (config2) => generateDelegationMessagesFn(primaryAddress, sessionAddress, config2), generateConditionalDelegationMessages: async (config2) => generateConditionalDelegationMessagesFn(signer, config2), revokeDelegationMessages: (msgTypeUrl) => revokeDelegationMessagesFn(primaryAddress, sessionAddress, msgTypeUrl), // Execute helpers - will be added after signer creation execute: null }; signer.execute = createExecuteHelpers(signer, logger); return signer; } function convertRpcToRestUrl(rpcUrl) { try { const url = new globalThis.URL(rpcUrl); if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("Invalid protocol: only http/https allowed"); } if (url.port === "26657") { url.port = "1317"; } if (url.hostname.startsWith("rpc.")) { url.hostname = url.hostname.replace("rpc.", "api."); } if (url.hostname.includes("-rpc.")) { url.hostname = url.hostname.replace("-rpc.", "-api."); } const urlString = url.toString(); return urlString.endsWith("/") ? urlString.slice(0, -1) : urlString; } catch (error) { throw new StintError("Invalid RPC URL provided", ErrorCodes.INVALID_RPC_URL, { rpcUrl, error: error instanceof Error ? error.message : "Unknown error" }); } } function createHasAuthzGrant(primaryClient, primaryAddress, sessionAddress, logger = noopLogger) { return async (messageType = "/cosmos.bank.v1beta1.MsgSend") => { try { const extendedClient = primaryClient; const rpcUrl = extendedClient.cometClient?.client?.url; if (!rpcUrl) { logger.warn("Could not extract RPC URL for authz grant check"); return null; } const restUrl = convertRpcToRestUrl(rpcUrl); const requestUrl = `${restUrl}/cosmos/authz/v1beta1/grants?granter=${primaryAddress}&grantee=${sessionAddress}&msg_type_url=${messageType}`; logger.debug("Checking authz grant", { rpcUrl, restUrl, requestUrl, granter: primaryAddress, grantee: sessionAddress, messageType }); const response = await fetch(requestUrl, { method: "GET", headers: { Accept: "application/json", "User-Agent": "stint-library/1.0.0" }, signal: globalThis.AbortSignal.timeout(1e4), // 10 second timeout // Security: Only allow specific response types redirect: "error" // Don't follow redirects for security }); if (!response.ok) { logger.debug("Authz grant check failed", { status: response.status, statusText: response.statusText, messageType }); return null; } if (response.headers) { const contentLength = response.headers.get("content-length"); if (contentLength && parseInt(contentLength) > 1024 * 1024) { logger.warn("Authz grant response too large", { contentLength, messageType }); return null; } const contentType = response.headers.get("content-type"); if (!contentType || !contentType.includes("application/json")) { logger.warn("Invalid content type for authz grant response", { contentType, messageType }); return null; } } const data = await response.json(); logger.debug("Authz grant response", { data, messageType }); if (!data.grants || data.grants.length === 0) { logger.debug("No authz grants found", { messageType }); return null; } const grant = data.grants[0]; logger.debug("Found authz grant", { messageType, hasExpiration: !!grant.expiration }); return { authorization: grant.authorization, expiration: grant.expiration ? new Date(grant.expiration) : void 0 }; } catch (error) { if (error instanceof globalThis.DOMException && error.name === "TimeoutError") { logger.warn("Authz grant check timed out", { operation: "hasAuthzGrant", messageType, timeout: "10000ms" }); } else if (error instanceof TypeError && error.message.includes("fetch")) { logger.warn("Network error during authz grant check", { operation: "hasAuthzGrant", messageType, error: error.message }); } else { logger.warn("Authz grant check failed", { operation: "hasAuthzGrant", messageType, error: error instanceof Error ? error.message : "Unknown error" }); } return null; } }; } function createHasFeegrant(primaryClient, primaryAddress, sessionAddress, logger = noopLogger) { return async () => { try { const extendedClient = primaryClient; const rpcUrl = extendedClient.cometClient?.client?.url; if (!rpcUrl) { logger.warn("Could not extract RPC URL for feegrant check"); return null; } const restUrl = convertRpcToRestUrl(rpcUrl); const requestUrl = `${restUrl}/cosmos/feegrant/v1beta1/allowance/${primaryAddress}/${sessionAddress}`; logger.debug("Checking feegrant", { rpcUrl, restUrl, requestUrl, granter: primaryAddress, grantee: sessionAddress }); const response = await fetch(requestUrl, { method: "GET", headers: { Accept: "application/json", "User-Agent": "stint-library/1.0.0" }, signal: globalThis.AbortSignal.timeout(1e4), // 10 second timeout // Security: Only allow specific response types redirect: "error" // Don't follow redirects for security }); if (!response.ok) { logger.debug("Feegrant check failed", { status: response.status, statusText: response.statusText }); return null; } if (response.headers) { const contentLength = response.headers.get("content-length"); if (contentLength && parseInt(contentLength) > 1024 * 1024) { logger.warn("Feegrant response too large", { contentLength }); return null; } const contentType = response.headers.get("content-type"); if (!contentType || !contentType.includes("application/json")) { logger.warn("Invalid content type for feegrant response", { contentType }); return null; } } const data = await response.json(); if (!data.allowance) { logger.debug("No feegrant found"); return null; } logger.debug("Found feegrant", { hasExpiration: !!data.allowance.expiration }); return { allowance: data.allowance, expiration: data.allowance.expiration ? new Date(data.allowance.expiration) : void 0 }; } catch (error) { if (error instanceof globalThis.DOMException && error.name === "TimeoutError") { logger.warn("Feegrant check timed out", { operation: "hasFeegrant", timeout: "10000ms" }); } else if (error instanceof TypeError && error.message.includes("fetch")) { logger.warn("Network error during feegrant check", { operation: "hasFeegrant", error: error.message }); } else { logger.warn("Feegrant check failed", { operation: "hasFeegrant", error: error instanceof Error ? error.message : "Unknown error" }); } return null; } }; } function dateToTimestamp(date) { return timestamp.Timestamp.fromPartial({ seconds: BigInt(Math.floor(date.getTime() / 1e3)), nanos: date.getTime() % 1e3 * 1e6 }); } function generateDelegationMessagesFn(primaryAddress, sessionAddress, config) { const spendLimitCoins = config.spendLimit ? [coin.Coin.fromPartial({ denom: config.spendLimit.denom, amount: config.spendLimit.amount })] : [coin.Coin.fromPartial({ denom: "uphoton", amount: "10000000" })]; const sendAuth = authz.SendAuthorization.fromPartial({ spendLimit: spendLimitCoins, allowList: config.allowedRecipients || [] }); const authorization = any.Any.fromPartial({ typeUrl: "/cosmos.bank.v1beta1.SendAuthorization", value: authz.SendAuthorization.encode(sendAuth).finish() }); const expirationDate = config.sessionExpiration || new Date(Date.now() + 24 * 60 * 60 * 1e3); const authzGrant = { granter: primaryAddress, grantee: sessionAddress, grant: { authorization, expiration: dateToTimestamp(expirationDate) } }; const gasLimitCoins = config.gasLimit ? [coin.Coin.fromPartial({ denom: config.gasLimit.denom, amount: config.gasLimit.amount })] : [coin.Coin.fromPartial({ denom: "uphoton", amount: "10000000" })]; const allowance = feegrant.BasicAllowance.fromPartial({ spendLimit: gasLimitCoins, expiration: config.sessionExpiration ? dateToTimestamp(config.sessionExpiration) : void 0 }); const feeAllowance = any.Any.fromPartial({ typeUrl: "/cosmos.feegrant.v1beta1.BasicAllowance", value: feegrant.BasicAllowance.encode(allowance).finish() }); const feegrant$1 = { granter: primaryAddress, grantee: sessionAddress, allowance: feeAllowance }; return [ { typeUrl: "/cosmos.authz.v1beta1.MsgGrant", value: authzGrant }, { typeUrl: "/cosmos.feegrant.v1beta1.MsgGrantAllowance", value: feegrant$1 } ]; } function revokeDelegationMessagesFn(primaryAddress, sessionAddress, msgTypeUrl = "/cosmos.bank.v1beta1.MsgSend") { return [ { typeUrl: "/cosmos.authz.v1beta1.MsgRevoke", value: { granter: primaryAddress, grantee: sessionAddress, msgTypeUrl } }, { typeUrl: "/cosmos.feegrant.v1beta1.MsgRevokeAllowance", value: { granter: primaryAddress, grantee: sessionAddress } } ]; } async function generateConditionalDelegationMessagesFn(sessionSigner, config) { const primaryAddress = sessionSigner.primaryAddress(); const sessionAddress = sessionSigner.sessionAddress(); const [existingAuthz, existingFeegrant] = await Promise.all([ sessionSigner.hasAuthzGrant(), sessionSigner.hasFeegrant() ]); const messages = []; if (!existingAuthz) { const spendLimitCoins = config.spendLimit ? [coin.Coin.fromPartial({ denom: config.spendLimit.denom, amount: config.spendLimit.amount })] : [coin.Coin.fromPartial({ denom: "uphoton", amount: "10000000" })]; const sendAuth = authz.SendAuthorization.fromPartial({ spendLimit: spendLimitCoins, allowList: config.allowedRecipients || [] }); const authorization = any.Any.fromPartial({ typeUrl: "/cosmos.bank.v1beta1.SendAuthorization", value: authz.SendAuthorization.encode(sendAuth).finish() }); const expirationDate = config.sessionExpiration || new Date(Date.now() + 24 * 60 * 60 * 1e3); const authzGrant = { granter: primaryAddress, grantee: sessionAddress, grant: { authorization, expiration: dateToTimestamp(expirationDate) } }; messages.push({ typeUrl: "/cosmos.authz.v1beta1.MsgGrant", value: authzGrant }); } if (!existingFeegrant) { const gasLimitCoins = config.gasLimit ? [coin.Coin.fromPartial({ denom: config.gasLimit.denom, amount: config.gasLimit.amount })] : [coin.Coin.fromPartial({ denom: "uphoton", amount: "10000000" })]; const allowance = feegrant.BasicAllowance.fromPartial({ spendLimit: gasLimitCoins, expiration: config.sessionExpiration ? dateToTimestamp(config.sessionExpiration) : void 0 }); const feeAllowance = any.Any.fromPartial({ typeUrl: "/cosmos.feegrant.v1beta1.BasicAllowance", value: feegrant.BasicAllowance.encode(allowance).finish() }); const feegrant$1 = { granter: primaryAddress, grantee: sessionAddress, allowance: feeAllowance }; messages.push({ typeUrl: "/cosmos.feegrant.v1beta1.MsgGrantAllowance", value: feegrant$1 }); } return messages; } exports.ErrorCodes = ErrorCodes; exports.StintError = StintError; exports.consoleLogger = consoleLogger; exports.createFeeWithGranter = createFeeWithGranter; exports.custom = custom; exports.getWindowBoundaries = getWindowBoundaries; exports.newSessionSigner = newSessionSigner; exports.send = send; exports.wrapInMsgExec = wrapInMsgExec; //# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map