stint-signer
Version:
Short-lived, non-custodial session signer using passkeys for Cosmos SDK
1,040 lines (1,033 loc) • 36.1 kB
JavaScript
'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