From 5d01857a4ba9ba3481a697c7c280e5331e1d465d Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 2 Mar 2026 15:11:56 +0300 Subject: [PATCH 01/33] feat: integrate api-keys spending limits into payment flows and dashboard --- apps/dashboard/app/api-keys/server-actions.ts | 263 +- apps/dashboard/components/api-keys/form.tsx | 84 + apps/dashboard/components/api-keys/limit.tsx | 289 +++ apps/dashboard/components/api-keys/list.tsx | 112 +- apps/dashboard/services/graphql/generated.ts | 440 +++- .../services/graphql/mutations/api-keys.ts | 314 +++ bats/core/api-keys/api-keys-limits.bats | 707 +++++ core/api/src/app/errors.ts | 2 + core/api/src/app/payments/send-intraledger.ts | 32 + core/api/src/app/payments/send-lightning.ts | 75 + core/api/src/app/payments/send-lnurl.ts | 4 + core/api/src/app/payments/send-on-chain.ts | 63 + .../app/payments/update-pending-payments.ts | 28 +- core/api/src/app/wallets/index.types.d.ts | 5 + core/api/src/config/env.ts | 6 + core/api/src/config/index.ts | 3 + core/api/src/domain/api-keys/errors.ts | 21 + core/api/src/domain/api-keys/index.ts | 2 + core/api/src/domain/api-keys/index.types.d.ts | 23 + .../src/domain/api-keys/spending-limits.ts | 116 + core/api/src/graphql/error-map.ts | 18 + .../root/mutation/intraledger-payment-send.ts | 3 +- .../mutation/intraledger-usd-payment-send.ts | 3 +- .../root/mutation/ln-address-payment-send.ts | 3 +- .../root/mutation/ln-invoice-payment-send.ts | 3 +- .../ln-noamount-invoice-payment-send.ts | 3 +- .../ln-noamount-usd-invoice-payment-send.ts | 3 +- .../root/mutation/lnurl-payment-send.ts | 3 +- .../root/mutation/onchain-payment-send-all.ts | 3 +- .../root/mutation/onchain-payment-send.ts | 3 +- .../onchain-usd-payment-send-as-sats.ts | 3 +- .../root/mutation/onchain-usd-payment-send.ts | 3 +- core/api/src/servers/index.files.d.ts | 1 + core/api/src/servers/middlewares/session.ts | 2 + core/api/src/services/api-keys/convert.ts | 24 + core/api/src/services/api-keys/errors.ts | 34 + core/api/src/services/api-keys/grpc-client.ts | 51 + core/api/src/services/api-keys/index.ts | 96 + .../services/api-keys/proto/api_keys.proto | 66 + .../api-keys/proto/api_keys_grpc_pb.d.ts | 92 + .../api-keys/proto/api_keys_grpc_pb.js | 143 ++ .../services/api-keys/proto/api_keys_pb.d.ts | 290 +++ .../services/api-keys/proto/api_keys_pb.js | 2268 +++++++++++++++++ .../src/services/api-keys/proto/buf.gen.yaml | 16 + 44 files changed, 5696 insertions(+), 27 deletions(-) create mode 100644 apps/dashboard/components/api-keys/limit.tsx create mode 100644 bats/core/api-keys/api-keys-limits.bats create mode 100644 core/api/src/domain/api-keys/errors.ts create mode 100644 core/api/src/domain/api-keys/index.ts create mode 100644 core/api/src/domain/api-keys/index.types.d.ts create mode 100644 core/api/src/domain/api-keys/spending-limits.ts create mode 100644 core/api/src/services/api-keys/convert.ts create mode 100644 core/api/src/services/api-keys/errors.ts create mode 100644 core/api/src/services/api-keys/grpc-client.ts create mode 100644 core/api/src/services/api-keys/index.ts create mode 100644 core/api/src/services/api-keys/proto/api_keys.proto create mode 100644 core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts create mode 100644 core/api/src/services/api-keys/proto/api_keys_grpc_pb.js create mode 100644 core/api/src/services/api-keys/proto/api_keys_pb.d.ts create mode 100644 core/api/src/services/api-keys/proto/api_keys_pb.js create mode 100644 core/api/src/services/api-keys/proto/buf.gen.yaml diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index f27072ceea..1c50aef755 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -5,7 +5,18 @@ import { revalidatePath } from "next/cache" import { ApiKeyResponse } from "./api-key.types" import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { createApiKey, revokeApiKey } from "@/services/graphql/mutations/api-keys" +import { + createApiKey, + revokeApiKey, + setApiKeyDailyLimit, + setApiKeyWeeklyLimit, + setApiKeyMonthlyLimit, + setApiKeyAnnualLimit, + removeApiKeyLimit, + removeApiKeyWeeklyLimit, + removeApiKeyMonthlyLimit, + removeApiKeyAnnualLimit, +} from "@/services/graphql/mutations/api-keys" import { Scope } from "@/services/graphql/generated" export const revokeApiKeyServerAction = async (id: string) => { @@ -113,9 +124,259 @@ export const createApiKeyServerAction = async ( } } + // Set budget limits if provided + if (data?.apiKeyCreate.apiKey.id) { + const apiKeyId = data.apiKeyCreate.apiKey.id + try { + const dailyLimitSats = form.get("dailyLimitSats") + if (dailyLimitSats && dailyLimitSats !== "") { + const limit = parseInt(dailyLimitSats as string, 10) + if (limit > 0) { + await setApiKeyDailyLimit({ id: apiKeyId, dailyLimitSats: limit }) + } + } + + const weeklyLimitSats = form.get("weeklyLimitSats") + if (weeklyLimitSats && weeklyLimitSats !== "") { + const limit = parseInt(weeklyLimitSats as string, 10) + if (limit > 0) { + await setApiKeyWeeklyLimit({ id: apiKeyId, weeklyLimitSats: limit }) + } + } + + const monthlyLimitSats = form.get("monthlyLimitSats") + if (monthlyLimitSats && monthlyLimitSats !== "") { + const limit = parseInt(monthlyLimitSats as string, 10) + if (limit > 0) { + await setApiKeyMonthlyLimit({ id: apiKeyId, monthlyLimitSats: limit }) + } + } + + const annualLimitSats = form.get("annualLimitSats") + if (annualLimitSats && annualLimitSats !== "") { + const limit = parseInt(annualLimitSats as string, 10) + if (limit > 0) { + await setApiKeyAnnualLimit({ id: apiKeyId, annualLimitSats: limit }) + } + } + } catch (err) { + console.log("error in setting API key limits ", err) + // Don't fail the entire operation if limits fail to set + // The API key was created successfully + } + } + return { error: false, message: "API Key created successfully", responsePayload: { apiKeySecret: data?.apiKeyCreate.apiKeySecret }, } } + +export const setDailyLimit = async ({ + id, + dailyLimitSats, +}: { + id: string + dailyLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!dailyLimitSats || dailyLimitSats <= 0) { + throw new Error("Daily limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyDailyLimit({ id, dailyLimitSats }) + } catch (err) { + console.log("error in setApiKeyDailyLimit ", err) + throw new Error("Failed to set API key daily limit") + } + + revalidatePath("/api-keys") +} + +export const setWeeklyLimit = async ({ + id, + weeklyLimitSats, +}: { + id: string + weeklyLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!weeklyLimitSats || weeklyLimitSats <= 0) { + throw new Error("Weekly limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyWeeklyLimit({ id, weeklyLimitSats }) + } catch (err) { + console.log("error in setApiKeyWeeklyLimit ", err) + throw new Error("Failed to set API key weekly limit") + } + + revalidatePath("/api-keys") +} + +export const setMonthlyLimit = async ({ + id, + monthlyLimitSats, +}: { + id: string + monthlyLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!monthlyLimitSats || monthlyLimitSats <= 0) { + throw new Error("Monthly limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyMonthlyLimit({ id, monthlyLimitSats }) + } catch (err) { + console.log("error in setApiKeyMonthlyLimit ", err) + throw new Error("Failed to set API key monthly limit") + } + + revalidatePath("/api-keys") +} + +export const setAnnualLimit = async ({ + id, + annualLimitSats, +}: { + id: string + annualLimitSats: number +}) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + if (!annualLimitSats || annualLimitSats <= 0) { + throw new Error("Annual limit must be greater than 0") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await setApiKeyAnnualLimit({ id, annualLimitSats }) + } catch (err) { + console.log("error in setApiKeyAnnualLimit ", err) + throw new Error("Failed to set API key annual limit") + } + + revalidatePath("/api-keys") +} + +export const removeLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyLimit ", err) + throw new Error("Failed to remove API key limit") + } + + revalidatePath("/api-keys") +} + +export const removeWeeklyLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyWeeklyLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyWeeklyLimit ", err) + throw new Error("Failed to remove API key weekly limit") + } + + revalidatePath("/api-keys") +} + +export const removeMonthlyLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyMonthlyLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyMonthlyLimit ", err) + throw new Error("Failed to remove API key monthly limit") + } + + revalidatePath("/api-keys") +} + +export const removeAnnualLimit = async ({ id }: { id: string }) => { + if (!id || typeof id !== "string") { + throw new Error("API Key ID is not present") + } + + const session = await getServerSession(authOptions) + const token = session?.accessToken + if (!token || typeof token !== "string") { + throw new Error("Token is not present") + } + + try { + await removeApiKeyAnnualLimit({ id }) + } catch (err) { + console.log("error in removeApiKeyAnnualLimit ", err) + throw new Error("Failed to remove API key annual limit") + } + + revalidatePath("/api-keys") +} diff --git a/apps/dashboard/components/api-keys/form.tsx b/apps/dashboard/components/api-keys/form.tsx index 9d71c925f4..ecdec3f881 100644 --- a/apps/dashboard/components/api-keys/form.tsx +++ b/apps/dashboard/components/api-keys/form.tsx @@ -23,6 +23,7 @@ type ApiKeyFormProps = { const ApiKeyForm = ({ state, formAction }: ApiKeyFormProps) => { const [enableCustomExpiresInDays, setEnableCustomExpiresInDays] = useState(false) const [expiresInDays, setExpiresInDays] = useState(null) + const [showSpendingLimits, setShowSpendingLimits] = useState(false) const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() @@ -53,6 +54,11 @@ const ApiKeyForm = ({ state, formAction }: ApiKeyFormProps) => { )} {state.error && } + + {showSpendingLimits && } @@ -179,6 +185,84 @@ const ScopeCheckboxes = () => ( ) +const SpendingLimitsToggle = ({ + showSpendingLimits, + setShowSpendingLimits, +}: { + showSpendingLimits: boolean + setShowSpendingLimits: (value: boolean) => void +}) => ( + + setShowSpendingLimits(e.target.checked)} + label="Set budget limits" + /> + +) + +const SpendingLimitsInputs = () => ( + + + Limits (in satoshis) + + + Daily Limit + + Rolling 24-hour window + + + Weekly Limit + + Rolling 7-day window + + + Monthly Limit + + Rolling 30-day window + + + Annual Limit + + Rolling 365-day window + + +) + const SubmitButton = () => ( = ({ id, limits, spent }) => { + const [open, setOpen] = useState(false) + const [selectedPeriod, setSelectedPeriod] = useState("daily") + const [limitValues, setLimitValues] = useState({ + daily: limits.daily?.toString() || "", + weekly: limits.weekly?.toString() || "", + monthly: limits.monthly?.toString() || "", + annual: limits.annual?.toString() || "", + }) + const [loading, setLoading] = useState(false) + + const periodConfig = { + daily: { + label: "Daily (24h)", + description: "Set a rolling 24-hour spending limit", + currentLimit: limits.daily, + spent: spent.last24h, + setValue: (val: string) => setLimitValues({ ...limitValues, daily: val }), + getValue: () => limitValues.daily, + }, + weekly: { + label: "Weekly (7 days)", + description: "Set a rolling 7-day spending limit", + currentLimit: limits.weekly, + spent: spent.last7d, + setValue: (val: string) => setLimitValues({ ...limitValues, weekly: val }), + getValue: () => limitValues.weekly, + }, + monthly: { + label: "Monthly (30 days)", + description: "Set a rolling 30-day spending limit", + currentLimit: limits.monthly, + spent: spent.last30d, + setValue: (val: string) => setLimitValues({ ...limitValues, monthly: val }), + getValue: () => limitValues.monthly, + }, + annual: { + label: "Annual (365 days)", + description: "Set a rolling 365-day spending limit", + currentLimit: limits.annual, + spent: spent.last365d, + setValue: (val: string) => setLimitValues({ ...limitValues, annual: val }), + getValue: () => limitValues.annual, + }, + } + + const handleSetLimit = async (period: LimitPeriod) => { + const config = periodConfig[period] + const limitValue = config.getValue() + + if (!limitValue || parseInt(limitValue) <= 0) { + alert("Please enter a valid limit in satoshis") + return + } + + setLoading(true) + try { + const satsValue = parseInt(limitValue) + switch (period) { + case "daily": + await setDailyLimit({ id, dailyLimitSats: satsValue }) + break + case "weekly": + await setWeeklyLimit({ id, weeklyLimitSats: satsValue }) + break + case "monthly": + await setMonthlyLimit({ id, monthlyLimitSats: satsValue }) + break + case "annual": + await setAnnualLimit({ id, annualLimitSats: satsValue }) + break + } + setOpen(false) + window.location.reload() // Refresh to show updated data + } catch (error) { + console.error("Error setting limit:", error) + alert("Failed to set limit. Please try again.") + } finally { + setLoading(false) + } + } + + const handleRemoveLimit = async (period: LimitPeriod) => { + const periodLabels = { + daily: "daily", + weekly: "weekly", + monthly: "monthly", + annual: "annual", + } + + if ( + !confirm( + `Are you sure you want to remove the ${periodLabels[period]} spending limit?`, + ) + ) { + return + } + + setLoading(true) + try { + switch (period) { + case "daily": + await removeLimit({ id }) + break + case "weekly": + await removeWeeklyLimit({ id }) + break + case "monthly": + await removeMonthlyLimit({ id }) + break + case "annual": + await removeAnnualLimit({ id }) + break + } + setOpen(false) + window.location.reload() // Refresh to show updated data + } catch (error) { + console.error("Error removing limit:", error) + alert("Failed to remove limit. Please try again.") + } finally { + setLoading(false) + } + } + + const formatSats = (sats: number | null) => { + if (sats === null) return "Unlimited" + return `${sats.toLocaleString()} sats` + } + + const hasAnyLimit = limits.daily || limits.weekly || limits.monthly || limits.annual + + return ( + <> + + + setOpen(false)}> + + Budget Limits + + Configure rolling budget limits for different time periods + + + setSelectedPeriod(value as LimitPeriod)} + > + + Daily + Weekly + Monthly + Annual + + + {(Object.keys(periodConfig) as LimitPeriod[]).map((period) => { + const config = periodConfig[period] + const remaining = config.currentLimit + ? config.currentLimit - config.spent + : null + + return ( + + + {config.description} + + {config.currentLimit && ( + + + + Current Limit:{" "} + {formatSats(config.currentLimit)} + + + Spent: {formatSats(config.spent)} + + + Remaining: {formatSats(remaining)} + + + + )} + + + {config.label} Limit (satoshis) + config.setValue(e.target.value)} + placeholder="Enter limit in sats (e.g., 100000)" + disabled={loading} + /> + + + + + {config.currentLimit && ( + + )} + + + + ) + })} + + + + + + + + + ) +} + +export default Limit diff --git a/apps/dashboard/components/api-keys/list.tsx b/apps/dashboard/components/api-keys/list.tsx index 13d8a9d217..e742cd6cd4 100644 --- a/apps/dashboard/components/api-keys/list.tsx +++ b/apps/dashboard/components/api-keys/list.tsx @@ -1,9 +1,13 @@ +"use client" + import React from "react" import Table from "@mui/joy/Table" import Typography from "@mui/joy/Typography" import Divider from "@mui/joy/Divider" +import { Stack } from "@mui/joy" import RevokeKey from "./revoke" +import Limit from "./limit" import { formatDate, getScopeText } from "./utils" import { ApiKey } from "@/services/graphql/generated" @@ -25,25 +29,117 @@ const ApiKeysList: React.FC = ({ - - - - - - + + + + + + + - {activeKeys.map(({ id, name, expiresAt, lastUsedAt, scopes }) => { + {activeKeys.map(({ id, name, expiresAt, lastUsedAt, scopes, limits }) => { + const dailyLimitSats = limits?.dailyLimitSats + const weeklyLimitSats = limits?.weeklyLimitSats + const monthlyLimitSats = limits?.monthlyLimitSats + const annualLimitSats = limits?.annualLimitSats + const spentLast24HSats = limits?.spentLast24HSats ?? 0 + const spentLast7DSats = limits?.spentLast7DSats ?? 0 + const spentLast30DSats = limits?.spentLast30DSats ?? 0 + const spentLast365DSats = limits?.spentLast365DSats ?? 0 + + const remainingDailyLimitSats = + dailyLimitSats !== null && dailyLimitSats !== undefined + ? dailyLimitSats - spentLast24HSats + : null + + const hasAnyLimit = + dailyLimitSats || weeklyLimitSats || monthlyLimitSats || annualLimitSats + return ( + ) diff --git a/apps/dashboard/services/graphql/generated.ts b/apps/dashboard/services/graphql/generated.ts index b4535f35c9..c93b739c97 100644 --- a/apps/dashboard/services/graphql/generated.ts +++ b/apps/dashboard/services/graphql/generated.ts @@ -1607,12 +1607,10 @@ export type OneDayAccountLimit = AccountLimit & { export type OpenDeepLinkAction = { readonly __typename: 'OpenDeepLinkAction'; readonly deepLink: Scalars['String']['output']; - readonly label?: Maybe; }; export type OpenExternalLinkAction = { readonly __typename: 'OpenExternalLinkAction'; - readonly label?: Maybe; readonly url: Scalars['String']['output']; }; @@ -2536,7 +2534,7 @@ export type ApiKeyCreateMutationVariables = Exact<{ }>; -export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; +export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; export type ApiKeyRevokeMutationVariables = Exact<{ input: ApiKeyRevokeInput; @@ -2545,6 +2543,62 @@ export type ApiKeyRevokeMutationVariables = Exact<{ export type ApiKeyRevokeMutation = { readonly __typename: 'Mutation', readonly apiKeyRevoke: { readonly __typename: 'ApiKeyRevokePayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; +export type ApiKeySetDailyLimitMutationVariables = Exact<{ + input: ApiKeySetDailyLimitInput; +}>; + + +export type ApiKeySetDailyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetDailyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeyRemoveDailyLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveDailyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveDailyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeySetWeeklyLimitMutationVariables = Exact<{ + input: ApiKeySetWeeklyLimitInput; +}>; + + +export type ApiKeySetWeeklyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetWeeklyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeyRemoveWeeklyLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveWeeklyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveWeeklyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeySetMonthlyLimitMutationVariables = Exact<{ + input: ApiKeySetMonthlyLimitInput; +}>; + + +export type ApiKeySetMonthlyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetMonthlyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeyRemoveMonthlyLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveMonthlyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveMonthlyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeySetAnnualLimitMutationVariables = Exact<{ + input: ApiKeySetAnnualLimitInput; +}>; + + +export type ApiKeySetAnnualLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetAnnualLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + +export type ApiKeyRemoveAnnualLimitMutationVariables = Exact<{ + input: ApiKeyRemoveLimitInput; +}>; + + +export type ApiKeyRemoveAnnualLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveAnnualLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; + export type CallbackEndpointAddMutationVariables = Exact<{ input: CallbackEndpointAddInput; }>; @@ -2667,6 +2721,16 @@ export const ApiKeyCreateDocument = gql` lastUsedAt expiresAt scopes + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } } apiKeySecret } @@ -2740,6 +2804,374 @@ export function useApiKeyRevokeMutation(baseOptions?: Apollo.MutationHookOptions export type ApiKeyRevokeMutationHookResult = ReturnType; export type ApiKeyRevokeMutationResult = Apollo.MutationResult; export type ApiKeyRevokeMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetDailyLimitDocument = gql` + mutation ApiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { + apiKeySetDailyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeySetDailyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetDailyLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetDailyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetDailyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetDailyLimitMutation, { data, loading, error }] = useApiKeySetDailyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetDailyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetDailyLimitDocument, options); + } +export type ApiKeySetDailyLimitMutationHookResult = ReturnType; +export type ApiKeySetDailyLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetDailyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveDailyLimitDocument = gql` + mutation ApiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveDailyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeyRemoveDailyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveDailyLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveDailyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveDailyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveDailyLimitMutation, { data, loading, error }] = useApiKeyRemoveDailyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveDailyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveDailyLimitDocument, options); + } +export type ApiKeyRemoveDailyLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveDailyLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveDailyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetWeeklyLimitDocument = gql` + mutation ApiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { + apiKeySetWeeklyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeySetWeeklyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetWeeklyLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetWeeklyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetWeeklyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetWeeklyLimitMutation, { data, loading, error }] = useApiKeySetWeeklyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetWeeklyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetWeeklyLimitDocument, options); + } +export type ApiKeySetWeeklyLimitMutationHookResult = ReturnType; +export type ApiKeySetWeeklyLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetWeeklyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveWeeklyLimitDocument = gql` + mutation ApiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveWeeklyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeyRemoveWeeklyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveWeeklyLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveWeeklyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveWeeklyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveWeeklyLimitMutation, { data, loading, error }] = useApiKeyRemoveWeeklyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveWeeklyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveWeeklyLimitDocument, options); + } +export type ApiKeyRemoveWeeklyLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveWeeklyLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveWeeklyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetMonthlyLimitDocument = gql` + mutation ApiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { + apiKeySetMonthlyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeySetMonthlyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetMonthlyLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetMonthlyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetMonthlyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetMonthlyLimitMutation, { data, loading, error }] = useApiKeySetMonthlyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetMonthlyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetMonthlyLimitDocument, options); + } +export type ApiKeySetMonthlyLimitMutationHookResult = ReturnType; +export type ApiKeySetMonthlyLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetMonthlyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveMonthlyLimitDocument = gql` + mutation ApiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveMonthlyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeyRemoveMonthlyLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveMonthlyLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveMonthlyLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveMonthlyLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveMonthlyLimitMutation, { data, loading, error }] = useApiKeyRemoveMonthlyLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveMonthlyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveMonthlyLimitDocument, options); + } +export type ApiKeyRemoveMonthlyLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveMonthlyLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveMonthlyLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeySetAnnualLimitDocument = gql` + mutation ApiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { + apiKeySetAnnualLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeySetAnnualLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeySetAnnualLimitMutation__ + * + * To run a mutation, you first call `useApiKeySetAnnualLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetAnnualLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeySetAnnualLimitMutation, { data, loading, error }] = useApiKeySetAnnualLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeySetAnnualLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeySetAnnualLimitDocument, options); + } +export type ApiKeySetAnnualLimitMutationHookResult = ReturnType; +export type ApiKeySetAnnualLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetAnnualLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveAnnualLimitDocument = gql` + mutation ApiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveAnnualLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } +} + `; +export type ApiKeyRemoveAnnualLimitMutationFn = Apollo.MutationFunction; + +/** + * __useApiKeyRemoveAnnualLimitMutation__ + * + * To run a mutation, you first call `useApiKeyRemoveAnnualLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveAnnualLimitMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [apiKeyRemoveAnnualLimitMutation, { data, loading, error }] = useApiKeyRemoveAnnualLimitMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useApiKeyRemoveAnnualLimitMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ApiKeyRemoveAnnualLimitDocument, options); + } +export type ApiKeyRemoveAnnualLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveAnnualLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveAnnualLimitMutationOptions = Apollo.BaseMutationOptions; export const CallbackEndpointAddDocument = gql` mutation CallbackEndpointAdd($input: CallbackEndpointAddInput!) { callbackEndpointAdd(input: $input) { @@ -4836,12 +5268,10 @@ export interface OneTimeAuthCodeScalarConfig extends GraphQLScalarTypeConfig = { deepLink?: Resolver; - label?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; export type OpenExternalLinkActionResolvers = { - label?: Resolver, ParentType, ContextType>; url?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/apps/dashboard/services/graphql/mutations/api-keys.ts b/apps/dashboard/services/graphql/mutations/api-keys.ts index a0a0a414d8..b5714ba196 100644 --- a/apps/dashboard/services/graphql/mutations/api-keys.ts +++ b/apps/dashboard/services/graphql/mutations/api-keys.ts @@ -6,6 +6,22 @@ import { ApiKeyCreateMutation, ApiKeyRevokeDocument, ApiKeyRevokeMutation, + ApiKeySetDailyLimitDocument, + ApiKeySetDailyLimitMutation, + ApiKeySetWeeklyLimitDocument, + ApiKeySetWeeklyLimitMutation, + ApiKeySetMonthlyLimitDocument, + ApiKeySetMonthlyLimitMutation, + ApiKeySetAnnualLimitDocument, + ApiKeySetAnnualLimitMutation, + ApiKeyRemoveDailyLimitDocument, + ApiKeyRemoveDailyLimitMutation, + ApiKeyRemoveWeeklyLimitDocument, + ApiKeyRemoveWeeklyLimitMutation, + ApiKeyRemoveMonthlyLimitDocument, + ApiKeyRemoveMonthlyLimitMutation, + ApiKeyRemoveAnnualLimitDocument, + ApiKeyRemoveAnnualLimitMutation, Scope, } from "../generated" @@ -21,6 +37,16 @@ gql` lastUsedAt expiresAt scopes + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } } apiKeySecret } @@ -40,6 +66,158 @@ gql` } } } + + mutation ApiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { + apiKeySetDailyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveDailyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { + apiKeySetWeeklyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveWeeklyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { + apiKeySetMonthlyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveMonthlyLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { + apiKeySetAnnualLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } + + mutation ApiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveAnnualLimit(input: $input) { + apiKey { + id + name + limits { + dailyLimitSats + weeklyLimitSats + monthlyLimitSats + annualLimitSats + spentLast24HSats + spentLast7DSats + spentLast30DSats + spentLast365DSats + } + } + } + } ` export async function createApiKey({ @@ -77,3 +255,139 @@ export async function revokeApiKey({ id }: { id: string }) { throw new Error("Error in apiKeyRevoke") } } + +export async function setApiKeyDailyLimit({ + id, + dailyLimitSats, +}: { + id: string + dailyLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetDailyLimitDocument, + variables: { input: { id, dailyLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetDailyLimit ==> ", error) + throw new Error("Error in apiKeySetDailyLimit") + } +} + +export async function setApiKeyWeeklyLimit({ + id, + weeklyLimitSats, +}: { + id: string + weeklyLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetWeeklyLimitDocument, + variables: { input: { id, weeklyLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetWeeklyLimit ==> ", error) + throw new Error("Error in apiKeySetWeeklyLimit") + } +} + +export async function setApiKeyMonthlyLimit({ + id, + monthlyLimitSats, +}: { + id: string + monthlyLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetMonthlyLimitDocument, + variables: { input: { id, monthlyLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetMonthlyLimit ==> ", error) + throw new Error("Error in apiKeySetMonthlyLimit") + } +} + +export async function setApiKeyAnnualLimit({ + id, + annualLimitSats, +}: { + id: string + annualLimitSats: number +}) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeySetAnnualLimitDocument, + variables: { input: { id, annualLimitSats } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeySetAnnualLimit ==> ", error) + throw new Error("Error in apiKeySetAnnualLimit") + } +} + +export async function removeApiKeyLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveDailyLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveDailyLimit ==> ", error) + throw new Error("Error in apiKeyRemoveDailyLimit") + } +} + +export async function removeApiKeyWeeklyLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveWeeklyLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveWeeklyLimit ==> ", error) + throw new Error("Error in apiKeyRemoveWeeklyLimit") + } +} + +export async function removeApiKeyMonthlyLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveMonthlyLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveMonthlyLimit ==> ", error) + throw new Error("Error in apiKeyRemoveMonthlyLimit") + } +} + +export async function removeApiKeyAnnualLimit({ id }: { id: string }) { + const client = await apolloClient.authenticated() + try { + const { data } = await client.mutate({ + mutation: ApiKeyRemoveAnnualLimitDocument, + variables: { input: { id } }, + }) + return data + } catch (error) { + console.error("Error executing mutation: apiKeyRemoveAnnualLimit ==> ", error) + throw new Error("Error in apiKeyRemoveAnnualLimit") + } +} diff --git a/bats/core/api-keys/api-keys-limits.bats b/bats/core/api-keys/api-keys-limits.bats new file mode 100644 index 0000000000..87c32c873e --- /dev/null +++ b/bats/core/api-keys/api-keys-limits.bats @@ -0,0 +1,707 @@ +#!/usr/bin/env bats + +load "../../helpers/_common.bash" +load "../../helpers/cli.bash" +load "../../helpers/user.bash" +load "../../helpers/onchain.bash" +load "../../helpers/ln.bash" + +random_uuid() { + if [[ -e /proc/sys/kernel/random/uuid ]]; then + cat /proc/sys/kernel/random/uuid + else + uuidgen + fi +} + +new_key_name() { + random_uuid +} + +ALICE='alice' +BOB='bob' + +setup_file() { + clear_cache + + # Ensure LND has sufficient balance for lightning tests + lnd1_balance=$(lnd_cli channelbalance | jq -r '.balance // 0') + if [[ $lnd1_balance -lt "1000000" ]]; then + create_user 'lnd_funding' + fund_user_lightning 'lnd_funding' 'lnd_funding.btc_wallet_id' '5000000' + fi + + create_user "$ALICE" + fund_user_onchain "$ALICE" 'btc_wallet' + fund_user_onchain "$ALICE" 'usd_wallet' + + create_user "$BOB" + user_update_username "$BOB" + ensure_username_is_present "xyz_zap_receiver" +} + +@test "api-keys-limits: create key and set daily limit" { + key_name="$(new_key_name)" + cache_value 'limit_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + + exec_graphql 'alice' 'api-key-create' "$variables" + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + + cache_value "api-key-limit-secret" "$secret" + + name=$(echo "$key" | jq -r '.name') + [[ "${name}" = "${key_name}" ]] || exit 1 + + key_id=$(echo "$key" | jq -r '.id') + cache_value "limit-api-key-id" "$key_id" + + # Set daily limit to 10000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + daily_limit="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.dailyLimitSats')" + [[ "${daily_limit}" = "10000" ]] || exit 1 + + spent_24h="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.spentLast24HSats')" + [[ "${spent_24h}" = "0" ]] || exit 1 +} + +@test "api-keys-limits: can send payment within limit" { + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=5000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Check spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -ge "$amount" ]] || exit 1 +} + +@test "api-keys-limits: cannot exceed daily limit" { + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=6000 # Would exceed 10000 daily limit + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + + # Should fail due to limit + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + errors="$(graphql_output '.data.intraLedgerPaymentSend.errors | length')" + [[ "${errors}" -ge "1" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: set weekly limit" { + key_id=$(read_value "limit-api-key-id") + + # Set weekly limit to 50000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"weeklyLimitSats\":50000}}" + exec_graphql 'alice' 'api-key-set-weekly-limit' "$variables" + + weekly_limit="$(graphql_output '.data.apiKeySetWeeklyLimit.apiKey.limits.weeklyLimitSats')" + [[ "${weekly_limit}" = "50000" ]] || exit 1 +} + +@test "api-keys-limits: remove daily limit" { + key_id=$(read_value "limit-api-key-id") + + variables="{\"input\":{\"id\":\"${key_id}\"}}" + exec_graphql 'alice' 'api-key-remove-daily-limit' "$variables" + + daily_limit="$(graphql_output '.data.apiKeyRemoveDailyLimit.apiKey.limits.dailyLimitSats')" + [[ "${daily_limit}" = "null" ]] || exit 1 +} + +@test "api-keys-limits: can send after removing daily limit (but weekly still applies)" { + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=3000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Check total spending across all time periods + exec_graphql 'alice' 'api-keys' + spent_7d="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast7DSats')" + + # Should have accumulated spending from previous tests + [[ "${spent_7d}" -ge "8000" ]] || exit 1 +} + +@test "api-keys-limits: set monthly and annual limits" { + key_id=$(read_value "limit-api-key-id") + + # Set monthly limit to 100000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"monthlyLimitSats\":100000}}" + exec_graphql 'alice' 'api-key-set-monthly-limit' "$variables" + monthly_limit="$(graphql_output '.data.apiKeySetMonthlyLimit.apiKey.limits.monthlyLimitSats')" + [[ "${monthly_limit}" = "100000" ]] || exit 1 + + spent_30d="$(graphql_output '.data.apiKeySetMonthlyLimit.apiKey.limits.spentLast30DSats')" + [[ "${spent_30d}" -ge "8000" ]] || exit 1 + + # Set annual limit to 500000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"annualLimitSats\":500000}}" + exec_graphql 'alice' 'api-key-set-annual-limit' "$variables" + annual_limit="$(graphql_output '.data.apiKeySetAnnualLimit.apiKey.limits.annualLimitSats')" + [[ "${annual_limit}" = "500000" ]] || exit 1 + + spent_365d="$(graphql_output '.data.apiKeySetAnnualLimit.apiKey.limits.spentLast365DSats')" + [[ "${spent_365d}" -ge "8000" ]] || exit 1 +} + +@test "api-keys-limits: multiple limits active - respects most restrictive" { + # At this point we have: + # - No daily limit (removed) + # - Weekly: 50000 sats (spent: ~8000) + # - Monthly: 100000 sats (spent: ~8000) + # - Annual: 500000 sats (spent: ~8000) + + # Try to send 45000 sats - this would exceed weekly limit + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=45000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + + # Should fail due to weekly limit + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"weekly"* ]] || exit 1 +} + +@test "api-keys-limits: can send within all active limits" { + # Send 30000 sats - within weekly (50000 - 8000 = 42000 remaining) + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=30000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending updated across all time windows + exec_graphql 'alice' 'api-keys' + key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" + + spent_24h=$(echo "$key_data" | jq -r '.limits.spentLast24HSats') + spent_7d=$(echo "$key_data" | jq -r '.limits.spentLast7DSats') + spent_30d=$(echo "$key_data" | jq -r '.limits.spentLast30DSats') + spent_365d=$(echo "$key_data" | jq -r '.limits.spentLast365DSats') + + [[ "${spent_24h}" -ge "30000" ]] || exit 1 + [[ "${spent_7d}" -ge "38000" ]] || exit 1 + [[ "${spent_30d}" -ge "38000" ]] || exit 1 + [[ "${spent_365d}" -ge "38000" ]] || exit 1 +} + +@test "api-keys-limits: spending tracked consistently across time windows" { + exec_graphql 'alice' 'api-keys' + key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" + + # Verify all limits are still set + daily_limit=$(echo "$key_data" | jq -r '.limits.dailyLimitSats') + weekly_limit=$(echo "$key_data" | jq -r '.limits.weeklyLimitSats') + monthly_limit=$(echo "$key_data" | jq -r '.limits.monthlyLimitSats') + annual_limit=$(echo "$key_data" | jq -r '.limits.annualLimitSats') + + [[ "${daily_limit}" = "null" ]] || exit 1 + [[ "${weekly_limit}" = "50000" ]] || exit 1 + [[ "${monthly_limit}" = "100000" ]] || exit 1 + [[ "${annual_limit}" = "500000" ]] || exit 1 + + # Verify spending is consistent across all time windows (since all payments are within last 24h) + spent_24h=$(echo "$key_data" | jq -r '.limits.spentLast24HSats') + spent_7d=$(echo "$key_data" | jq -r '.limits.spentLast7DSats') + spent_30d=$(echo "$key_data" | jq -r '.limits.spentLast30DSats') + spent_365d=$(echo "$key_data" | jq -r '.limits.spentLast365DSats') + + [[ "${spent_24h}" = "${spent_7d}" ]] || exit 1 + [[ "${spent_7d}" = "${spent_30d}" ]] || exit 1 + [[ "${spent_30d}" = "${spent_365d}" ]] || exit 1 +} + +@test "api-keys-limits: update existing limit to lower value" { + key_id=$(read_value "limit-api-key-id") + + # Update weekly limit to 40000 (already spent ~38000) + variables="{\"input\":{\"id\":\"${key_id}\",\"weeklyLimitSats\":40000}}" + exec_graphql 'alice' 'api-key-set-weekly-limit' "$variables" + weekly_limit="$(graphql_output '.data.apiKeySetWeeklyLimit.apiKey.limits.weeklyLimitSats')" + [[ "${weekly_limit}" = "40000" ]] || exit 1 + + # Try to send 3000 - should fail as it would exceed updated limit + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=3000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"weekly"* ]] || exit 1 +} + +@test "api-keys-limits: remove all limits" { + key_id=$(read_value "limit-api-key-id") + + # Remove weekly limit + variables="{\"input\":{\"id\":\"${key_id}\"}}" + exec_graphql 'alice' 'api-key-remove-weekly-limit' "$variables" + weekly_limit="$(graphql_output '.data.apiKeyRemoveWeeklyLimit.apiKey.limits.weeklyLimitSats')" + [[ "${weekly_limit}" = "null" ]] || exit 1 + + # Remove monthly limit + exec_graphql 'alice' 'api-key-remove-monthly-limit' "$variables" + monthly_limit="$(graphql_output '.data.apiKeyRemoveMonthlyLimit.apiKey.limits.monthlyLimitSats')" + [[ "${monthly_limit}" = "null" ]] || exit 1 + + # Remove annual limit + exec_graphql 'alice' 'api-key-remove-annual-limit' "$variables" + annual_limit="$(graphql_output '.data.apiKeyRemoveAnnualLimit.apiKey.limits.annualLimitSats')" + [[ "${annual_limit}" = "null" ]] || exit 1 +} + +@test "api-keys-limits: can send large amount with no limits" { + # With all limits removed, should be able to send larger amounts + local from_wallet_name="$ALICE.btc_wallet_id" + local to_wallet_name="$BOB.btc_wallet_id" + local amount=100000 + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $from_wallet_name)" \ + --arg recipient_wallet_id "$(read_value $to_wallet_name)" \ + --arg amount "$amount" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Spending should still be tracked even without limits + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -ge "130000" ]] || exit 1 +} + +# ============================================================================ +# Tests for different payment flows (lightning, on-chain, lnurl, no-amount invoices) +# ============================================================================ + +@test "api-keys-limits: lightning payment respects limits" { + # Create new API key with daily limit for lightning tests + key_name="$(new_key_name)" + cache_value 'ln_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-ln-secret" "$secret" + cache_value "ln-api-key-id" "$key_id" + + # Set daily limit to 5000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":5000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Create invoice for 3000 sats + invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_hash=$(echo $invoice_response | jq -r '.r_hash') + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request}}' + ) + + # Send lightning payment with API key + exec_graphql 'api-key-ln-secret' 'ln-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -ge "3000" ]] || exit 1 +} + +@test "api-keys-limits: lightning payment exceeding limit fails" { + # Try to send 3000 more sats (would exceed 5000 limit) + invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request}}' + ) + + exec_graphql 'api-key-ln-secret' 'ln-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.lnInvoicePaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: onchain payment respects limits" { + # Create new API key with daily limit for onchain tests + key_name="$(new_key_name)" + cache_value 'onchain_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-onchain-secret" "$secret" + cache_value "onchain-api-key-id" "$key_id" + + # Set daily limit to 10000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Create onchain address + onchain_address=$(bitcoin_cli getnewaddress) + + # Send onchain payment for 5000 sats with API key + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg address "$onchain_address" \ + --argjson amount "5000" \ + '{input: {walletId: $wallet_id, address: $address, amount: $amount}}' + ) + + exec_graphql 'api-key-onchain-secret' 'on-chain-payment-send' "$variables" + send_status="$(graphql_output '.data.onChainPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -ge "5000" ]] || exit 1 +} + +@test "api-keys-limits: onchain payment exceeding limit fails" { + # Try to send 6000 more sats (would exceed 10000 limit) + onchain_address=$(bitcoin_cli getnewaddress) + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg address "$onchain_address" \ + --argjson amount "6000" \ + '{input: {walletId: $wallet_id, address: $address, amount: $amount}}' + ) + + exec_graphql 'api-key-onchain-secret' 'on-chain-payment-send' "$variables" + send_status="$(graphql_output '.data.onChainPaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.onChainPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: mixed payment flows tracked separately per key" { + # Verify that each API key tracks its own spending independently + + # Check intraledger key spending (original key from earlier tests) + exec_graphql 'alice' 'api-keys' + intraledger_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast24HSats')" + [[ "${intraledger_spent}" -ge "130000" ]] || exit 1 + + # Check lightning key spending (separate key) + ln_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.spentLast24HSats')" + [[ "${ln_spent}" -ge "3000" ]] || exit 1 + [[ "${ln_spent}" -lt "10000" ]] || exit 1 + + # Check onchain key spending (separate key) + onchain_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.spentLast24HSats')" + [[ "${onchain_spent}" -ge "5000" ]] || exit 1 + [[ "${onchain_spent}" -lt "10000" ]] || exit 1 + + # Each key should have independent spending totals + [[ "${intraledger_spent}" != "${ln_spent}" ]] || exit 1 + [[ "${intraledger_spent}" != "${onchain_spent}" ]] || exit 1 +} + +@test "api-keys-limits: USD wallet payments also respect limits" { + # Create API key with daily limit for USD wallet tests + key_name="$(new_key_name)" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-usd-secret" "$secret" + + # Set daily limit to 50000 sats (in satoshi equivalent) + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":50000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Send USD intraledger payment (amount in cents) + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.usd_wallet_id)" \ + --arg recipient_wallet_id "$(read_value $BOB.usd_wallet_id)" \ + --argjson amount "25" \ + '{input: {walletId: $wallet_id, recipientWalletId: $recipient_wallet_id, amount: $amount}}' + ) + + exec_graphql 'api-key-usd-secret' 'intraledger-usd-payment-send' "$variables" + send_status="$(graphql_output '.data.intraLedgerUsdPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded (converted to sats) + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$key_name'") | .limits.spentLast24HSats')" + # USD amount converted to sats should be tracked + [[ "${spent_24h}" -gt "0" ]] || exit 1 +} + +@test "api-keys-limits: lnNoAmountInvoicePaymentSend respects limits" { + # Create new API key with daily limit + key_name="$(new_key_name)" + cache_value 'ln_noamount_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-ln-noamount-secret" "$secret" + cache_value "ln-noamount-api-key-id" "$key_id" + + # Set daily limit to 8000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":8000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Create no-amount invoice + invoice_response="$(lnd_outside_cli addinvoice)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + + # Pay 4000 sats to the no-amount invoice + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + --argjson amount "4000" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request, amount: $amount}}' + ) + + exec_graphql 'api-key-ln-noamount-secret' 'ln-no-amount-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -ge "4000" ]] || exit 1 +} + +@test "api-keys-limits: lnNoAmountInvoicePaymentSend exceeding limit fails" { + # Try to pay 5000 more sats (would exceed 8000 limit) + invoice_response="$(lnd_outside_cli addinvoice)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + --argjson amount "5000" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request, amount: $amount}}' + ) + + exec_graphql 'api-key-ln-noamount-secret' 'ln-no-amount-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} + +@test "api-keys-limits: lnNoAmountUsdInvoicePaymentSend respects limits" { + # Create new API key with daily limit + key_name="$(new_key_name)" + cache_value 'ln_noamount_usd_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-ln-noamount-usd-secret" "$secret" + + # Set daily limit to 8000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":8000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Create no-amount invoice + invoice_response="$(lnd_outside_cli addinvoice)" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + + # Pay 30 cents (USD) to the no-amount invoice from USD wallet + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.usd_wallet_id)" \ + --arg payment_request "$payment_request" \ + --argjson amount "30" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request, amount: $amount}}' + ) + + exec_graphql 'api-key-ln-noamount-usd-secret' 'ln-no-amount-usd-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnNoAmountUsdInvoicePaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded (converted to sats) + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_usd_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -gt "0" ]] || exit 1 +} + +@test "api-keys-limits: lnurlPaymentSend respects limits" { + # Create new API key with daily limit + key_name="$(new_key_name)" + cache_value 'lnurl_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + exec_graphql 'alice' 'api-key-create' "$variables" + + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + key_id=$(echo "$key" | jq -r '.id') + + cache_value "api-key-lnurl-secret" "$secret" + + # Set daily limit to 5000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":5000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + # Send payment via lnurl (to xyz_zap_receiver) + lnurl="lnurl1dp68gup69uhkcmmrv9kxsmmnwsarxvpsxghjuam9d3kz66mwdamkutmvde6hymrs9au8j7jl0fshqhmjv43k26tkv4eq5ndl2y" + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --argjson amount "2000" \ + --arg lnurl "$lnurl" \ + '{input: {walletId: $wallet_id, amount: $amount, lnurl: $lnurl}}' + ) + + exec_graphql 'api-key-lnurl-secret' 'lnurl-payment-send' "$variables" + send_status="$(graphql_output '.data.lnurlPaymentSend.status')" + [[ "${send_status}" = "SUCCESS" ]] || exit 1 + + # Verify spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'lnurl_key_name')'") | .limits.spentLast24HSats')" + [[ "${spent_24h}" -ge "2000" ]] || exit 1 +} + +@test "api-keys-limits: lnurlPaymentSend exceeding limit fails" { + # Try to send 4000 more sats (would exceed 5000 limit) + lnurl="lnurl1dp68gup69uhkcmmrv9kxsmmnwsarxvpsxghjuam9d3kz66mwdamkutmvde6hymrs9au8j7jl0fshqhmjv43k26tkv4eq5ndl2y" + + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --argjson amount "4000" \ + --arg lnurl "$lnurl" \ + '{input: {walletId: $wallet_id, amount: $amount, lnurl: $lnurl}}' + ) + + exec_graphql 'api-key-lnurl-secret' 'lnurl-payment-send' "$variables" + send_status="$(graphql_output '.data.lnurlPaymentSend.status')" + [[ "${send_status}" = "FAILURE" ]] || exit 1 + + # Verify error message contains limit information + error_msg="$(graphql_output '.data.lnurlPaymentSend.errors[0].message')" + [[ "${error_msg}" == *"daily"* ]] || exit 1 +} \ No newline at end of file diff --git a/core/api/src/app/errors.ts b/core/api/src/app/errors.ts index 9eac8c0bf2..c8b7a4a51b 100644 --- a/core/api/src/app/errors.ts +++ b/core/api/src/app/errors.ts @@ -25,6 +25,7 @@ import * as WalletInvoiceErrors from "@/domain/wallet-invoices/errors" import * as SupportError from "@/domain/support/errors" import * as OathkeeperError from "@/domain/oathkeeper/errors" import * as KratosErrors from "@/domain/kratos/errors" +import * as ApiKeysErrors from "@/domain/api-keys/errors" import * as LedgerFacadeErrors from "@/services/ledger/domain/errors" import * as BriaEventErrors from "@/services/bria/errors" @@ -58,6 +59,7 @@ export const ApplicationErrors = { ...SupportError, ...OathkeeperError, ...KratosErrors, + ...ApiKeysErrors, ...LedgerFacadeErrors, ...BriaEventErrors, diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index af7a768dfa..9b471c1105 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -44,8 +44,11 @@ import { WalletsRepository, } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" +import { ApiKeysService } from "@/services/api-keys" +import { validateSpendingLimit } from "@/domain/api-keys" const dealer = DealerPriceService() +const apiKeys = ApiKeysService() const intraledgerPaymentSendWalletId = async ({ recipientWalletId: uncheckedRecipientWalletId, @@ -53,6 +56,7 @@ const intraledgerPaymentSendWalletId = async ({ amount: uncheckedAmount, memo, senderWalletId: uncheckedSenderWalletId, + apiKeyId, }: IntraLedgerPaymentSendWalletIdArgs): Promise => { const validatedPaymentInputs = await validateIntraledgerPaymentInputs({ uncheckedSenderWalletId, @@ -128,6 +132,7 @@ const intraledgerPaymentSendWalletId = async ({ recipientUser, senderUser, memo, + apiKeyId, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -231,6 +236,7 @@ const executePaymentViaIntraledger = async < recipientUser, senderUser, memo, + apiKeyId, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -239,6 +245,7 @@ const executePaymentViaIntraledger = async < recipientUser: User senderUser: User memo: string | null + apiKeyId?: string }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -247,6 +254,20 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const limits = await apiKeys.getSpendingLimits({ + apiKeyId, + amountSats, + }) + if (limits instanceof Error) return limits + + const validation = validateSpendingLimit({ amountSats, limits }) + if (!validation.allowed) { + return validation.error + } + } + const checkLimits = senderAccount.id === recipientAccount.id ? checkTradeIntraAccountLimits @@ -318,6 +339,17 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 57dea4ad31..1f9da2e453 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -54,6 +54,7 @@ import { DealerPriceService } from "@/services/dealer-price" import { LedgerService } from "@/services/ledger" import { LockService } from "@/services/lock" import { NotificationsService } from "@/services/notifications" +import { ApiKeysService } from "@/services/api-keys" import * as LedgerFacade from "@/services/ledger/facade" import { @@ -76,8 +77,10 @@ import { } from "@/app/wallets" import { ResourceExpiredLockServiceError } from "@/domain/lock" +import { validateSpendingLimit } from "@/domain/api-keys" const dealer = DealerPriceService() +const apiKeys = ApiKeysService() const paymentFlowRepo = PaymentFlowStateRepository(defaultTimeToExpiryInSeconds) export const payInvoiceByWalletId = async ({ @@ -85,6 +88,7 @@ export const payInvoiceByWalletId = async ({ memo, senderWalletId: uncheckedSenderWalletId, senderAccount, + apiKeyId, }: PayInvoiceByWalletIdArgs): Promise => { addAttributesToCurrentSpan({ "payment.initiation_method": PaymentInitiationMethod.Lightning, @@ -121,6 +125,7 @@ export const payInvoiceByWalletId = async ({ paymentFlow, senderAccount, memo, + apiKeyId, }) } @@ -144,6 +149,7 @@ export const payInvoiceByWalletId = async ({ senderAccount, recipientAccount, memo, + apiKeyId, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -166,6 +172,7 @@ const payNoAmountInvoiceByWalletId = async ({ memo, senderWalletId: uncheckedSenderWalletId, senderAccount, + apiKeyId, }: PayNoAmountInvoiceByWalletIdArgs): Promise => { addAttributesToCurrentSpan({ "payment.initiation_method": PaymentInitiationMethod.Lightning, @@ -204,6 +211,7 @@ const payNoAmountInvoiceByWalletId = async ({ paymentFlow, senderAccount, memo, + apiKeyId, }) } @@ -228,6 +236,7 @@ const payNoAmountInvoiceByWalletId = async ({ senderAccount, recipientAccount, memo, + apiKeyId, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -432,12 +441,14 @@ const executePaymentViaIntraledger = async < senderWalletId, recipientAccount, memo, + apiKeyId, }: { paymentFlow: PaymentFlow senderAccount: Account senderWalletId: WalletId recipientAccount: Account memo: string | null + apiKeyId?: string }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -446,6 +457,22 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const limits = await apiKeys.getSpendingLimits({ + apiKeyId, + amountSats, + }) + + if (limits instanceof Error) return limits + + const validation = validateSpendingLimit({ amountSats, limits }) + + if (!validation.allowed) { + return validation.error + } + } + const paymentHash = paymentFlow.paymentHashForFlow() if (paymentHash instanceof Error) return paymentHash @@ -546,6 +573,17 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, @@ -727,11 +765,13 @@ const executePaymentViaLn = async ({ paymentFlow, senderAccount, memo, + apiKeyId, }: { decodedInvoice: LnInvoice paymentFlow: PaymentFlow senderAccount: Account memo: string | null + apiKeyId?: string }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.Lightning, @@ -742,6 +782,22 @@ const executePaymentViaLn = async ({ const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const limits = await apiKeys.getSpendingLimits({ + apiKeyId, + amountSats, + }) + + if (limits instanceof Error) return limits + + const validation = validateSpendingLimit({ amountSats, limits }) + + if (!validation.allowed) { + return validation.error + } + } + const limitCheck = await checkWithdrawalLimits({ amount: paymentFlow.usdPaymentAmount, accountId: senderAccount.id, @@ -801,6 +857,15 @@ const executePaymentViaLn = async ({ return paymentSendAttemptResult.error case PaymentSendAttemptResultType.Pending: + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amountSats, + transactionId: paymentSendAttemptResult.journalId, + }) + if (recordResult instanceof Error) return recordResult + } return getPendingPaymentResponse({ walletId: senderWalletId, paymentHash, @@ -813,6 +878,16 @@ const executePaymentViaLn = async ({ }) default: + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amountSats, + transactionId: paymentSendAttemptResult.journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: walletTransaction, diff --git a/core/api/src/app/payments/send-lnurl.ts b/core/api/src/app/payments/send-lnurl.ts index 1c9168483e..3372395db4 100644 --- a/core/api/src/app/payments/send-lnurl.ts +++ b/core/api/src/app/payments/send-lnurl.ts @@ -8,6 +8,7 @@ export const lnAddressPaymentSend = async ({ senderAccount, amount: uncheckedAmount, lnAddress, + apiKeyId, }: LnAddressPaymentSendArgs): Promise => { const amount = checkedToBtcPaymentAmount(uncheckedAmount) @@ -29,6 +30,7 @@ export const lnAddressPaymentSend = async ({ memo: null, senderWalletId, senderAccount, + apiKeyId, }) } @@ -37,6 +39,7 @@ export const lnurlPaymentSend = async ({ senderAccount, amount: uncheckedAmount, lnurl, + apiKeyId, }: LnurlPaymentSendArgs): Promise => { const amount = checkedToBtcPaymentAmount(uncheckedAmount) @@ -58,5 +61,6 @@ export const lnurlPaymentSend = async ({ memo: null, senderWalletId, senderAccount, + apiKeyId, }) } diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index a648dba63a..1c34a3e646 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -52,9 +52,12 @@ import { } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" import { addAttributesToCurrentSpan } from "@/services/tracing" +import { ApiKeysService } from "@/services/api-keys" +import { validateSpendingLimit } from "@/domain/api-keys" const { dustThreshold } = getOnChainWalletConfig() const dealer = DealerPriceService() +const apiKeys = ApiKeysService() const payOnChainByWalletId = async ({ senderAccount, @@ -65,6 +68,7 @@ const payOnChainByWalletId = async ({ speed, memo, sendAll, + apiKeyId, }: PayOnChainByWalletIdArgs): Promise => { const latestAccountState = await AccountsRepository().findById(senderAccount.id) if (latestAccountState instanceof Error) return latestAccountState @@ -178,6 +182,7 @@ const payOnChainByWalletId = async ({ senderAccount, memo, sendAll, + apiKeyId, }) } @@ -193,6 +198,7 @@ const payOnChainByWalletId = async ({ memo, sendAll, logger: onchainLogger, + apiKeyId, }) } @@ -248,11 +254,13 @@ const executePaymentViaIntraledger = async < senderAccount, memo, sendAll, + apiKeyId, }: { builder: OPFBWithConversion | OPFBWithError senderAccount: Account memo: string | null sendAll: boolean + apiKeyId?: string }): Promise => { const paymentFlow = await builder.withoutMinerFee() if (paymentFlow instanceof Error) return paymentFlow @@ -289,6 +297,20 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const limits = await apiKeys.getSpendingLimits({ + apiKeyId, + amountSats, + }) + if (limits instanceof Error) return limits + + const validation = validateSpendingLimit({ amountSats, limits }) + if (!validation.allowed) { + return validation.error + } + } + const checkLimits = senderAccount.id === recipientAccount.id ? checkTradeIntraAccountLimits @@ -357,6 +379,17 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) + // Record API key spending after successful payment + if (apiKeyId) { + const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + return { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, @@ -523,6 +556,7 @@ const executePaymentViaOnChain = async < memo, sendAll, logger, + apiKeyId, }: { builder: OPFBWithConversion | OPFBWithError senderDisplayCurrency: DisplayCurrency @@ -530,6 +564,7 @@ const executePaymentViaOnChain = async < memo: string | null sendAll: boolean logger: Logger + apiKeyId?: string }): Promise => { const senderWalletDescriptor = await builder.senderWalletDescriptor() if (senderWalletDescriptor instanceof Error) return senderWalletDescriptor @@ -541,6 +576,20 @@ const executePaymentViaOnChain = async < const priceRatioForLimits = await getPriceRatioForLimits(proposedAmounts) if (priceRatioForLimits instanceof Error) return priceRatioForLimits + if (apiKeyId) { + const amountSats = Number(proposedAmounts.btc.amount) + const limits = await apiKeys.getSpendingLimits({ + apiKeyId, + amountSats, + }) + if (limits instanceof Error) return limits + + const validation = validateSpendingLimit({ amountSats, limits }) + if (!validation.allowed) { + return validation.error + } + } + const limitCheck = await checkWithdrawalLimits({ amount: proposedAmounts.usd, accountId: senderWalletDescriptor.accountId, @@ -572,6 +621,20 @@ const executePaymentViaOnChain = async < }) if (walletTransaction instanceof Error) return walletTransaction + // Record API key spending after successful payment + if (apiKeyId) { + const paymentFlow = await builder.proposedAmounts() + if (!(paymentFlow instanceof Error)) { + const amountSats = Number(paymentFlow.btc.amount) + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amountSats, + transactionId: journalId, + }) + if (recordResult instanceof Error) return recordResult + } + } + return { status: PaymentSendStatus.Success, transaction: walletTransaction } } diff --git a/core/api/src/app/payments/update-pending-payments.ts b/core/api/src/app/payments/update-pending-payments.ts index e7c0e9b74c..b7658f5047 100644 --- a/core/api/src/app/payments/update-pending-payments.ts +++ b/core/api/src/app/payments/update-pending-payments.ts @@ -24,6 +24,7 @@ import { import { MissingPropsInTransactionForPaymentFlowError } from "@/domain/payments" import { setErrorCritical, WalletCurrency } from "@/domain/shared" +import { ApiKeysService } from "@/services/api-keys" import { LedgerService, getNonEndUserWalletIds } from "@/services/ledger" import * as LedgerFacade from "@/services/ledger/facade" import { LndService } from "@/services/lnd" @@ -344,6 +345,7 @@ const lockedPendingPaymentSteps = async ({ // pendingPayment is a different version to latest payment from lnd satsAmount !== toSats(paymentFlow.btcPaymentAmount.amount) ) { + const apiKeys = ApiKeysService() paymentLogger.warn( { success: false, id: paymentHash, payment: pendingPayment }, "payment has failed. reverting transaction", @@ -358,7 +360,18 @@ const lockedPendingPaymentSteps = async ({ paymentLogger.fatal({ success: false, result: lnPaymentLookup }, error) return setErrorCritical(voided) } - + const reverseResult = await apiKeys.reverseSpending({ + transactionId: journalId, + }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ + error: reverseResult, + attributes: { + "apiKeys.reverseSpending.failed": true, + "journalId": journalId, + }, + }) + } return finalizePaymentUpdate({ result: voided, walletIds, @@ -378,7 +391,18 @@ const lockedPendingPaymentSteps = async ({ paymentLogger.fatal({ success: false, result: lnPaymentLookup }, error) return setErrorCritical(reimbursed) } - + const reverseResult = await apiKeys.reverseSpending({ + transactionId: journalId, + }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ + error: reverseResult, + attributes: { + "apiKeys.reverseSpending.failed": true, + "journalId": journalId, + }, + }) + } return finalizePaymentUpdate({ result: reimbursed, walletIds, diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 147498f45f..87f6a6883b 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -98,6 +98,7 @@ type PaymentSendArgs = { senderWalletId: WalletId senderAccount: Account memo: string | null + apiKeyId?: string } type PayInvoiceByWalletIdArgs = PaymentSendArgs & { @@ -127,6 +128,7 @@ type PayAllOnChainByWalletIdArgs = { address: string speed: PayoutSpeed memo: string | null + apiKeyId?: string } type PayOnChainByWalletIdWithoutCurrencyArgs = { @@ -136,6 +138,7 @@ type PayOnChainByWalletIdWithoutCurrencyArgs = { address: string speed: PayoutSpeed memo: string | null + apiKeyId?: string } type PayOnChainByWalletIdArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { @@ -148,6 +151,7 @@ type LnAddressPaymentSendArgs = { senderAccount: Account lnAddress: string amount: number + apiKeyId?: string } type LnurlPaymentSendArgs = { @@ -155,6 +159,7 @@ type LnurlPaymentSendArgs = { senderAccount: Account lnurl: string amount: number + apiKeyId?: string } type GetDepositFeeConfigurationResult = { diff --git a/core/api/src/config/env.ts b/core/api/src/config/env.ts index bd6c2c4c39..881915c9d8 100644 --- a/core/api/src/config/env.ts +++ b/core/api/src/config/env.ts @@ -64,6 +64,9 @@ export const env = createEnv({ .pipe(z.coerce.number()) .default(6685), + API_KEYS_HOST: z.string().min(1).default("localhost"), + API_KEYS_PORT: z.number().min(1).or(z.string()).pipe(z.coerce.number()).default(6686), + GEETEST_ID: z.string().min(1).optional(), GEETEST_KEY: z.string().min(1).optional(), @@ -197,6 +200,9 @@ export const env = createEnv({ NOTIFICATIONS_HOST: process.env.NOTIFICATIONS_HOST, NOTIFICATIONS_PORT: process.env.NOTIFICATIONS_PORT, + API_KEYS_HOST: process.env.API_KEYS_HOST, + API_KEYS_PORT: process.env.API_KEYS_PORT, + GEETEST_ID: process.env.GEETEST_ID, GEETEST_KEY: process.env.GEETEST_KEY, diff --git a/core/api/src/config/index.ts b/core/api/src/config/index.ts index 3548c2cf94..f81dab244c 100644 --- a/core/api/src/config/index.ts +++ b/core/api/src/config/index.ts @@ -84,6 +84,9 @@ export const getCallbackServiceConfig = (): SvixConfig => { export const getBriaConfig = getBriaPartialConfigFromYaml +export const API_KEYS_HOST = env.API_KEYS_HOST +export const API_KEYS_PORT = env.API_KEYS_PORT + export const isTelegramPassportEnabled = () => !!env.TELEGRAM_BOT_API_TOKEN && !!env.TELEGRAM_PASSPORT_PRIVATE_KEY diff --git a/core/api/src/domain/api-keys/errors.ts b/core/api/src/domain/api-keys/errors.ts new file mode 100644 index 0000000000..f8754662fc --- /dev/null +++ b/core/api/src/domain/api-keys/errors.ts @@ -0,0 +1,21 @@ +import { DomainError, ErrorLevel } from "@/domain/shared" + +export class ApiKeyLimitExceededError extends DomainError { + level = ErrorLevel.Critical +} + +export class ApiKeyInvalidLimitError extends DomainError { + level = ErrorLevel.Critical +} + +export class ApiKeySpendingRecordError extends DomainError { + level = ErrorLevel.Critical +} + +export class InvalidApiKeyIdError extends DomainError { + level = ErrorLevel.Warn +} + +export class UnknownApiKeysServiceError extends DomainError { + level = ErrorLevel.Critical +} diff --git a/core/api/src/domain/api-keys/index.ts b/core/api/src/domain/api-keys/index.ts new file mode 100644 index 0000000000..63365aab92 --- /dev/null +++ b/core/api/src/domain/api-keys/index.ts @@ -0,0 +1,2 @@ +export * from "./errors" +export * from "./spending-limits" diff --git a/core/api/src/domain/api-keys/index.types.d.ts b/core/api/src/domain/api-keys/index.types.d.ts new file mode 100644 index 0000000000..17954aeb40 --- /dev/null +++ b/core/api/src/domain/api-keys/index.types.d.ts @@ -0,0 +1,23 @@ +type ApiKeysServiceError = + | import("./errors").InvalidApiKeyIdError + | import("./errors").ApiKeyLimitExceededError + | import("./errors").ApiKeySpendingRecordError + | import("./errors").ApiKeyInvalidLimitError + | import("./errors").UnknownApiKeysServiceError + +type SpendingLimits = import("./spending-limits").SpendingLimits + +interface IApiKeysService { + getSpendingLimits(args: { + apiKeyId: string + amountSats: number + }): Promise + + recordSpending(args: { + apiKeyId: string + amountSats: number + transactionId: string + }): Promise + + reverseSpending(args: { transactionId: string }): Promise +} diff --git a/core/api/src/domain/api-keys/spending-limits.ts b/core/api/src/domain/api-keys/spending-limits.ts new file mode 100644 index 0000000000..75112fc680 --- /dev/null +++ b/core/api/src/domain/api-keys/spending-limits.ts @@ -0,0 +1,116 @@ +import { ApiKeyLimitExceededError } from "./errors" + +export type SpendingLimits = { + dailyLimitSats: number | null + weeklyLimitSats: number | null + monthlyLimitSats: number | null + annualLimitSats: number | null + spentLast24hSats: number + spentLast7dSats: number + spentLast30dSats: number + spentLast365dSats: number +} + +type ValidationResult = + | { + allowed: true + } + | { + allowed: false + error: ApiKeyLimitExceededError + } + +export const validateSpendingLimit = ({ + amountSats, + limits, +}: { + amountSats: number + limits: SpendingLimits +}): ValidationResult => { + const { + dailyLimitSats, + weeklyLimitSats, + monthlyLimitSats, + annualLimitSats, + spentLast24hSats, + spentLast7dSats, + spentLast30dSats, + spentLast365dSats, + } = limits + + // Calculate remaining amounts + const remainingDailySats = + dailyLimitSats !== null ? dailyLimitSats - spentLast24hSats : null + const remainingWeeklySats = + weeklyLimitSats !== null ? weeklyLimitSats - spentLast7dSats : null + const remainingMonthlySats = + monthlyLimitSats !== null ? monthlyLimitSats - spentLast30dSats : null + const remainingAnnualSats = + annualLimitSats !== null ? annualLimitSats - spentLast365dSats : null + + if ( + dailyLimitSats !== null && + remainingDailySats !== null && + remainingDailySats < amountSats + ) { + return { + allowed: false, + error: new ApiKeyLimitExceededError({ + daily: remainingDailySats, + weekly: remainingWeeklySats, + monthly: remainingMonthlySats, + annual: remainingAnnualSats, + }), + } + } + + if ( + weeklyLimitSats !== null && + remainingWeeklySats !== null && + remainingWeeklySats < amountSats + ) { + return { + allowed: false, + error: new ApiKeyLimitExceededError({ + daily: remainingDailySats, + weekly: remainingWeeklySats, + monthly: remainingMonthlySats, + annual: remainingAnnualSats, + }), + } + } + + if ( + monthlyLimitSats !== null && + remainingMonthlySats !== null && + remainingMonthlySats < amountSats + ) { + return { + allowed: false, + error: new ApiKeyLimitExceededError({ + daily: remainingDailySats, + weekly: remainingWeeklySats, + monthly: remainingMonthlySats, + annual: remainingAnnualSats, + }), + } + } + + if ( + annualLimitSats !== null && + remainingAnnualSats !== null && + remainingAnnualSats < amountSats + ) { + return { + allowed: false, + error: new ApiKeyLimitExceededError({ + daily: remainingDailySats, + weekly: remainingWeeklySats, + monthly: remainingMonthlySats, + annual: remainingAnnualSats, + }), + } + } + + return { allowed: true } +} diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 025a294941..604d4c3aaa 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -63,6 +63,22 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = error.message return new TransactionRestrictedError({ message, logger: baseLogger }) + case "ApiKeyLimitExceededError": + message = error.message + return new TransactionRestrictedError({ message, logger: baseLogger }) + + case "ApiKeyInvalidLimitError": + message = error.message || "Failed to check API key spending limit" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "ApiKeySpendingRecordError": + message = error.message || "Failed to record API key spending" + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidApiKeyIdError": + message = error.message || "Invalid API key ID" + return new ValidationInternalError({ message, logger: baseLogger }) + case "AlreadyPaidError": message = "Invoice is already paid" return new LightningPaymentError({ message, logger: baseLogger }) @@ -615,6 +631,7 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "PayoutQueueNotFoundError": message = "Invalid or inactive speed" return new ValidationInternalError({ message, logger: baseLogger }) + // ---------- // Unhandled below here // ---------- @@ -886,6 +903,7 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "UnknownBigIntConversionError": case "UnknownDomainError": case "UnknownBriaEventError": + case "UnknownApiKeysServiceError": case "CouldNotFindAccountError": case "OathkeeperError": case "OathkeeperUnauthorizedServiceError": diff --git a/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts b/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts index 0744d46af0..191d418a99 100644 --- a/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/intraledger-payment-send.ts @@ -30,7 +30,7 @@ const IntraLedgerPaymentSendMutation = GT.Field( args: { input: { type: GT.NonNull(IntraLedgerPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, recipientWalletId, amount, memo } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { @@ -54,6 +54,7 @@ const IntraLedgerPaymentSendMutation = GT.Field( amount, senderWalletId: walletId, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { return { status: "failed", errors: [mapAndParseErrorForGqlResponse(result)] } diff --git a/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts b/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts index 0aa7dd3a3d..14457d251d 100644 --- a/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts @@ -30,7 +30,7 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field { + resolve: async (_, args, { domainAccount, apiKeyId }: GraphQLPublicContextAuth) => { const { walletId, recipientWalletId, amount, memo } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { @@ -54,6 +54,7 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, amount, lnAddress } = args.input if (lnAddress instanceof Error) { return { errors: [{ message: lnAddress.message }] } @@ -62,6 +62,7 @@ const LnAddressPaymentSendMutation = GT.Field< amount, senderWalletId: walletId, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts b/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts index 033c539a32..bc798d0782 100644 --- a/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/ln-invoice-payment-send.ts @@ -49,7 +49,7 @@ const LnInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, paymentRequest, memo } = args.input if (walletId instanceof InputValidationError) { return { errors: [{ message: walletId.message }] } @@ -66,6 +66,7 @@ const LnInvoicePaymentSendMutation = GT.Field< uncheckedPaymentRequest: paymentRequest, memo: memo ?? null, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts b/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts index c3f6db9aca..7f8d0698b7 100644 --- a/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts @@ -55,7 +55,7 @@ const LnNoAmountInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnNoAmountInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, paymentRequest, amount, memo } = args.input if (walletId instanceof InputValidationError) { @@ -77,6 +77,7 @@ const LnNoAmountInvoicePaymentSendMutation = GT.Field< memo: memo ?? null, amount, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts b/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts index c025c6d206..ac9811fa6e 100644 --- a/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts @@ -52,7 +52,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnNoAmountUsdInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, paymentRequest, amount, memo } = args.input if (walletId instanceof InputValidationError) { @@ -74,6 +74,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< memo: memo ?? null, amount, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/lnurl-payment-send.ts b/core/api/src/graphql/public/root/mutation/lnurl-payment-send.ts index 18dcfc239f..73aea1d835 100644 --- a/core/api/src/graphql/public/root/mutation/lnurl-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/lnurl-payment-send.ts @@ -43,7 +43,7 @@ const LnurlPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnurlPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, amount, lnurl } = args.input if (lnurl instanceof Error) { return { errors: [{ message: lnurl.message }] } @@ -62,6 +62,7 @@ const LnurlPaymentSendMutation = GT.Field< amount, senderWalletId: walletId, senderAccount: domainAccount, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts b/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts index 7382c84dc7..d392b0b4ff 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-payment-send-all.ts @@ -42,7 +42,7 @@ const OnChainPaymentSendAllMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainPaymentSendAllInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, memo, speed } = args.input if (walletId instanceof Error) { @@ -67,6 +67,7 @@ const OnChainPaymentSendAllMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts b/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts index 79c0476940..3efd51ed7e 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-payment-send.ts @@ -45,7 +45,7 @@ const OnChainPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -75,6 +75,7 @@ const OnChainPaymentSendMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts index 3e16c5ae50..7b4f0e69ba 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send-as-sats.ts @@ -45,7 +45,7 @@ const OnChainUsdPaymentSendAsBtcDenominatedMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainUsdPaymentSendAsBtcDenominatedInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -71,6 +71,7 @@ const OnChainUsdPaymentSendAsBtcDenominatedMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts index 4f9b1a63e0..44e555d6b5 100644 --- a/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts +++ b/core/api/src/graphql/public/root/mutation/onchain-usd-payment-send.ts @@ -45,7 +45,7 @@ const OnChainUsdPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainUsdPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, apiKeyId }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -71,6 +71,7 @@ const OnChainUsdPaymentSendMutation = GT.Field< address, speed, memo, + apiKeyId, }) if (result instanceof Error) { diff --git a/core/api/src/servers/index.files.d.ts b/core/api/src/servers/index.files.d.ts index add09bc539..a640143199 100644 --- a/core/api/src/servers/index.files.d.ts +++ b/core/api/src/servers/index.files.d.ts @@ -18,6 +18,7 @@ type GraphQLPublicContextAuth = GraphQLPublicContext & { domainAccount: Account scope: ScopesOauth2[] | undefined appId: string | undefined + apiKeyId?: string } type GraphQLAdminContext = { diff --git a/core/api/src/servers/middlewares/session.ts b/core/api/src/servers/middlewares/session.ts index 8cda27eb1f..f1b200fd77 100644 --- a/core/api/src/servers/middlewares/session.ts +++ b/core/api/src/servers/middlewares/session.ts @@ -30,6 +30,7 @@ export const sessionPublicContext = async ({ ) const sub = tokenPayload?.sub const appId = tokenPayload?.client_id + const apiKeyId = tokenPayload?.api_key_id // note: value should match (ie: "anon") if not an accountId // settings from dev/ory/oathkeeper.yml/authenticator/anonymous/config/subjet @@ -87,6 +88,7 @@ export const sessionPublicContext = async ({ sessionId, scope, appId, + apiKeyId, } } diff --git a/core/api/src/services/api-keys/convert.ts b/core/api/src/services/api-keys/convert.ts new file mode 100644 index 0000000000..562c6fde8d --- /dev/null +++ b/core/api/src/services/api-keys/convert.ts @@ -0,0 +1,24 @@ +import { CheckSpendingLimitResponse } from "./proto/api_keys_pb" + +import { SpendingLimits } from "@/domain/api-keys" + +export const grpcSpendingLimitsToSpendingLimits = ( + response: CheckSpendingLimitResponse, +): SpendingLimits => ({ + dailyLimitSats: response.hasDailyLimitSats() + ? (response.getDailyLimitSats() ?? null) + : null, + weeklyLimitSats: response.hasWeeklyLimitSats() + ? (response.getWeeklyLimitSats() ?? null) + : null, + monthlyLimitSats: response.hasMonthlyLimitSats() + ? (response.getMonthlyLimitSats() ?? null) + : null, + annualLimitSats: response.hasAnnualLimitSats() + ? (response.getAnnualLimitSats() ?? null) + : null, + spentLast24hSats: response.getSpentLast24hSats(), + spentLast7dSats: response.getSpentLast7dSats(), + spentLast30dSats: response.getSpentLast30dSats(), + spentLast365dSats: response.getSpentLast365dSats(), +}) diff --git a/core/api/src/services/api-keys/errors.ts b/core/api/src/services/api-keys/errors.ts new file mode 100644 index 0000000000..ac5c2a3d59 --- /dev/null +++ b/core/api/src/services/api-keys/errors.ts @@ -0,0 +1,34 @@ +import { + ApiKeyInvalidLimitError, + ApiKeySpendingRecordError, + InvalidApiKeyIdError, + UnknownApiKeysServiceError, +} from "@/domain/api-keys" +import { parseErrorMessageFromUnknown } from "@/domain/shared" + +export const handleCommonApiKeysErrors = (err: Error | string | unknown) => { + const errMsg = parseErrorMessageFromUnknown(err) + + const match = (knownErrDetail: RegExp): boolean => knownErrDetail.test(errMsg) + + switch (true) { + case match(KnownApiKeysErrorMessages.InvalidApiKeyId): + return new InvalidApiKeyIdError(errMsg) + + case match(KnownApiKeysErrorMessages.InvalidAmountError): + return new ApiKeySpendingRecordError(errMsg) + + case match(KnownApiKeysErrorMessages.InvalidLimitError): + return new ApiKeyInvalidLimitError(errMsg) + + default: + return new UnknownApiKeysServiceError(errMsg) + } +} + +export const KnownApiKeysErrorMessages = { + InvalidApiKeyId: /Invalid API key ID/, + InvalidAmountError: /Negative amount not allowed|Amount must be positive/, + InvalidLimitError: /Invalid limit value/, + DatabaseError: /Database/, +} as const diff --git a/core/api/src/services/api-keys/grpc-client.ts b/core/api/src/services/api-keys/grpc-client.ts new file mode 100644 index 0000000000..c5d5ab9fec --- /dev/null +++ b/core/api/src/services/api-keys/grpc-client.ts @@ -0,0 +1,51 @@ +import { promisify } from "util" + +import { credentials, Metadata } from "@grpc/grpc-js" + +import { ApiKeysServiceClient } from "./proto/api_keys_grpc_pb" + +import { + CheckSpendingLimitRequest, + CheckSpendingLimitResponse, + GetSpendingSummaryRequest, + GetSpendingSummaryResponse, + RecordSpendingRequest, + RecordSpendingResponse, + ReverseSpendingRequest, + ReverseSpendingResponse, +} from "./proto/api_keys_pb" + +import { API_KEYS_HOST, API_KEYS_PORT } from "@/config" + +const apiKeysEndpoint = `${API_KEYS_HOST}:${API_KEYS_PORT}` + +const apiKeysClient = new ApiKeysServiceClient( + apiKeysEndpoint, + credentials.createInsecure(), +) + +export const apiKeysMetadata = new Metadata() + +export const checkSpendingLimit = promisify< + CheckSpendingLimitRequest, + Metadata, + CheckSpendingLimitResponse +>(apiKeysClient.checkSpendingLimit.bind(apiKeysClient)) + +export const getSpendingSummary = promisify< + GetSpendingSummaryRequest, + Metadata, + GetSpendingSummaryResponse +>(apiKeysClient.getSpendingSummary.bind(apiKeysClient)) + +export const recordSpending = promisify< + RecordSpendingRequest, + Metadata, + RecordSpendingResponse +>(apiKeysClient.recordSpending.bind(apiKeysClient)) + +export const reverseSpending = promisify< + ReverseSpendingRequest, + Metadata, + ReverseSpendingResponse +>(apiKeysClient.reverseSpending.bind(apiKeysClient)) diff --git a/core/api/src/services/api-keys/index.ts b/core/api/src/services/api-keys/index.ts new file mode 100644 index 0000000000..f9d5f0fbe5 --- /dev/null +++ b/core/api/src/services/api-keys/index.ts @@ -0,0 +1,96 @@ +import { grpcSpendingLimitsToSpendingLimits } from "./convert" + +import { + CheckSpendingLimitRequest, + RecordSpendingRequest, + ReverseSpendingRequest, +} from "./proto/api_keys_pb" + +import * as apiKeysGrpc from "./grpc-client" + +import { handleCommonApiKeysErrors } from "./errors" + +import { baseLogger } from "@/services/logger" +import { SpendingLimits } from "@/domain/api-keys" + +export const ApiKeysService = (): IApiKeysService => { + const getSpendingLimits = async ({ + apiKeyId, + amountSats, + }: { + apiKeyId: string + amountSats: number + }): Promise => { + try { + const request = new CheckSpendingLimitRequest() + request.setApiKeyId(apiKeyId) + request.setAmountSats(amountSats) + + const response = await apiKeysGrpc.checkSpendingLimit( + request, + apiKeysGrpc.apiKeysMetadata, + ) + + const limits = grpcSpendingLimitsToSpendingLimits(response) + + return limits + } catch (err) { + baseLogger.error( + { err, apiKeyId, amountSats }, + "Failed to get API key spending limits", + ) + return handleCommonApiKeysErrors(err) + } + } + + const recordSpending = async ({ + apiKeyId, + amountSats, + transactionId, + }: { + apiKeyId: string + amountSats: number + transactionId: string + }): Promise => { + try { + const request = new RecordSpendingRequest() + request.setApiKeyId(apiKeyId) + request.setAmountSats(amountSats) + request.setTransactionId(transactionId) + + await apiKeysGrpc.recordSpending(request, apiKeysGrpc.apiKeysMetadata) + + return true + } catch (err) { + baseLogger.error( + { err, apiKeyId, amountSats, transactionId }, + "Failed to record API key spending", + ) + return handleCommonApiKeysErrors(err) + } + } + + const reverseSpending = async ({ + transactionId, + }: { + transactionId: string + }): Promise => { + try { + const request = new ReverseSpendingRequest() + request.setTransactionId(transactionId) + + await apiKeysGrpc.reverseSpending(request, apiKeysGrpc.apiKeysMetadata) + + return true + } catch (err) { + baseLogger.error({ err, transactionId }, "Failed to reverse API key spending") + return handleCommonApiKeysErrors(err) + } + } + + return { + getSpendingLimits, + recordSpending, + reverseSpending, + } +} diff --git a/core/api/src/services/api-keys/proto/api_keys.proto b/core/api/src/services/api-keys/proto/api_keys.proto new file mode 100644 index 0000000000..a7b455e472 --- /dev/null +++ b/core/api/src/services/api-keys/proto/api_keys.proto @@ -0,0 +1,66 @@ +syntax = "proto3"; + +package services.api_keys.v1; + +service ApiKeysService { + rpc CheckSpendingLimit (CheckSpendingLimitRequest) returns (CheckSpendingLimitResponse) {} + rpc GetSpendingSummary (GetSpendingSummaryRequest) returns (GetSpendingSummaryResponse) {} + rpc RecordSpending (RecordSpendingRequest) returns (RecordSpendingResponse) {} + rpc ReverseSpending (ReverseSpendingRequest) returns (ReverseSpendingResponse) {} +} + +message CheckSpendingLimitRequest { + string api_key_id = 1; + int64 amount_sats = 2; +} + +message CheckSpendingLimitResponse { + bool allowed = 1; + optional int64 daily_limit_sats = 2; + optional int64 weekly_limit_sats = 3; + optional int64 monthly_limit_sats = 4; + optional int64 annual_limit_sats = 5; + int64 spent_last_24h_sats = 6; + int64 spent_last_7d_sats = 7; + int64 spent_last_30d_sats = 8; + int64 spent_last_365d_sats = 9; + optional int64 remaining_daily_sats = 10; + optional int64 remaining_weekly_sats = 11; + optional int64 remaining_monthly_sats = 12; + optional int64 remaining_annual_sats = 13; +} + +message GetSpendingSummaryRequest { + string api_key_id = 1; +} + +message GetSpendingSummaryResponse { + optional int64 daily_limit_sats = 1; + optional int64 weekly_limit_sats = 2; + optional int64 monthly_limit_sats = 3; + optional int64 annual_limit_sats = 4; + int64 spent_last_24h_sats = 5; + int64 spent_last_7d_sats = 6; + int64 spent_last_30d_sats = 7; + int64 spent_last_365d_sats = 8; + optional int64 remaining_daily_sats = 9; + optional int64 remaining_weekly_sats = 10; + optional int64 remaining_monthly_sats = 11; + optional int64 remaining_annual_sats = 12; +} + +message RecordSpendingRequest { + string api_key_id = 1; + int64 amount_sats = 2; + optional string transaction_id = 3; +} + +message RecordSpendingResponse { +} + +message ReverseSpendingRequest { + string transaction_id = 1; +} + +message ReverseSpendingResponse { +} diff --git a/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts new file mode 100644 index 0000000000..30a87f50ac --- /dev/null +++ b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts @@ -0,0 +1,92 @@ +// package: services.api_keys.v1 +// file: api_keys.proto + +/* tslint:disable */ +/* eslint-disable */ + +import * as grpc from "@grpc/grpc-js"; +import * as api_keys_pb from "./api_keys_pb"; + +interface IApiKeysServiceService extends grpc.ServiceDefinition { + checkSpendingLimit: IApiKeysServiceService_ICheckSpendingLimit; + getSpendingSummary: IApiKeysServiceService_IGetSpendingSummary; + recordSpending: IApiKeysServiceService_IRecordSpending; + reverseSpending: IApiKeysServiceService_IReverseSpending; +} + +interface IApiKeysServiceService_ICheckSpendingLimit extends grpc.MethodDefinition { + path: "/services.api_keys.v1.ApiKeysService/CheckSpendingLimit"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} +interface IApiKeysServiceService_IGetSpendingSummary extends grpc.MethodDefinition { + path: "/services.api_keys.v1.ApiKeysService/GetSpendingSummary"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} +interface IApiKeysServiceService_IRecordSpending extends grpc.MethodDefinition { + path: "/services.api_keys.v1.ApiKeysService/RecordSpending"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} +interface IApiKeysServiceService_IReverseSpending extends grpc.MethodDefinition { + path: "/services.api_keys.v1.ApiKeysService/ReverseSpending"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} + +export const ApiKeysServiceService: IApiKeysServiceService; + +export interface IApiKeysServiceServer extends grpc.UntypedServiceImplementation { + checkSpendingLimit: grpc.handleUnaryCall; + getSpendingSummary: grpc.handleUnaryCall; + recordSpending: grpc.handleUnaryCall; + reverseSpending: grpc.handleUnaryCall; +} + +export interface IApiKeysServiceClient { + checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; + getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; + getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; + recordSpending(request: api_keys_pb.RecordSpendingRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.RecordSpendingResponse) => void): grpc.ClientUnaryCall; + recordSpending(request: api_keys_pb.RecordSpendingRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.RecordSpendingResponse) => void): grpc.ClientUnaryCall; + recordSpending(request: api_keys_pb.RecordSpendingRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.RecordSpendingResponse) => void): grpc.ClientUnaryCall; + reverseSpending(request: api_keys_pb.ReverseSpendingRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.ReverseSpendingResponse) => void): grpc.ClientUnaryCall; + reverseSpending(request: api_keys_pb.ReverseSpendingRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.ReverseSpendingResponse) => void): grpc.ClientUnaryCall; + reverseSpending(request: api_keys_pb.ReverseSpendingRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.ReverseSpendingResponse) => void): grpc.ClientUnaryCall; +} + +export class ApiKeysServiceClient extends grpc.Client implements IApiKeysServiceClient { + constructor(address: string, credentials: grpc.ChannelCredentials, options?: Partial); + public checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + public checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + public checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + public getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; + public getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; + public getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; + public recordSpending(request: api_keys_pb.RecordSpendingRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.RecordSpendingResponse) => void): grpc.ClientUnaryCall; + public recordSpending(request: api_keys_pb.RecordSpendingRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.RecordSpendingResponse) => void): grpc.ClientUnaryCall; + public recordSpending(request: api_keys_pb.RecordSpendingRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.RecordSpendingResponse) => void): grpc.ClientUnaryCall; + public reverseSpending(request: api_keys_pb.ReverseSpendingRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.ReverseSpendingResponse) => void): grpc.ClientUnaryCall; + public reverseSpending(request: api_keys_pb.ReverseSpendingRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.ReverseSpendingResponse) => void): grpc.ClientUnaryCall; + public reverseSpending(request: api_keys_pb.ReverseSpendingRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.ReverseSpendingResponse) => void): grpc.ClientUnaryCall; +} diff --git a/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js new file mode 100644 index 0000000000..250ef7e9e0 --- /dev/null +++ b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js @@ -0,0 +1,143 @@ +// GENERATED CODE -- DO NOT EDIT! + +'use strict'; +var grpc = require('@grpc/grpc-js'); +var api_keys_pb = require('./api_keys_pb.js'); + +function serialize_services_api_keys_v1_CheckSpendingLimitRequest(arg) { + if (!(arg instanceof api_keys_pb.CheckSpendingLimitRequest)) { + throw new Error('Expected argument of type services.api_keys.v1.CheckSpendingLimitRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_CheckSpendingLimitRequest(buffer_arg) { + return api_keys_pb.CheckSpendingLimitRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_CheckSpendingLimitResponse(arg) { + if (!(arg instanceof api_keys_pb.CheckSpendingLimitResponse)) { + throw new Error('Expected argument of type services.api_keys.v1.CheckSpendingLimitResponse'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_CheckSpendingLimitResponse(buffer_arg) { + return api_keys_pb.CheckSpendingLimitResponse.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_GetSpendingSummaryRequest(arg) { + if (!(arg instanceof api_keys_pb.GetSpendingSummaryRequest)) { + throw new Error('Expected argument of type services.api_keys.v1.GetSpendingSummaryRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_GetSpendingSummaryRequest(buffer_arg) { + return api_keys_pb.GetSpendingSummaryRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_GetSpendingSummaryResponse(arg) { + if (!(arg instanceof api_keys_pb.GetSpendingSummaryResponse)) { + throw new Error('Expected argument of type services.api_keys.v1.GetSpendingSummaryResponse'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_GetSpendingSummaryResponse(buffer_arg) { + return api_keys_pb.GetSpendingSummaryResponse.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_RecordSpendingRequest(arg) { + if (!(arg instanceof api_keys_pb.RecordSpendingRequest)) { + throw new Error('Expected argument of type services.api_keys.v1.RecordSpendingRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_RecordSpendingRequest(buffer_arg) { + return api_keys_pb.RecordSpendingRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_RecordSpendingResponse(arg) { + if (!(arg instanceof api_keys_pb.RecordSpendingResponse)) { + throw new Error('Expected argument of type services.api_keys.v1.RecordSpendingResponse'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_RecordSpendingResponse(buffer_arg) { + return api_keys_pb.RecordSpendingResponse.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_ReverseSpendingRequest(arg) { + if (!(arg instanceof api_keys_pb.ReverseSpendingRequest)) { + throw new Error('Expected argument of type services.api_keys.v1.ReverseSpendingRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_ReverseSpendingRequest(buffer_arg) { + return api_keys_pb.ReverseSpendingRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_ReverseSpendingResponse(arg) { + if (!(arg instanceof api_keys_pb.ReverseSpendingResponse)) { + throw new Error('Expected argument of type services.api_keys.v1.ReverseSpendingResponse'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_ReverseSpendingResponse(buffer_arg) { + return api_keys_pb.ReverseSpendingResponse.deserializeBinary(new Uint8Array(buffer_arg)); +} + + +var ApiKeysServiceService = exports.ApiKeysServiceService = { + checkSpendingLimit: { + path: '/services.api_keys.v1.ApiKeysService/CheckSpendingLimit', + requestStream: false, + responseStream: false, + requestType: api_keys_pb.CheckSpendingLimitRequest, + responseType: api_keys_pb.CheckSpendingLimitResponse, + requestSerialize: serialize_services_api_keys_v1_CheckSpendingLimitRequest, + requestDeserialize: deserialize_services_api_keys_v1_CheckSpendingLimitRequest, + responseSerialize: serialize_services_api_keys_v1_CheckSpendingLimitResponse, + responseDeserialize: deserialize_services_api_keys_v1_CheckSpendingLimitResponse, + }, + getSpendingSummary: { + path: '/services.api_keys.v1.ApiKeysService/GetSpendingSummary', + requestStream: false, + responseStream: false, + requestType: api_keys_pb.GetSpendingSummaryRequest, + responseType: api_keys_pb.GetSpendingSummaryResponse, + requestSerialize: serialize_services_api_keys_v1_GetSpendingSummaryRequest, + requestDeserialize: deserialize_services_api_keys_v1_GetSpendingSummaryRequest, + responseSerialize: serialize_services_api_keys_v1_GetSpendingSummaryResponse, + responseDeserialize: deserialize_services_api_keys_v1_GetSpendingSummaryResponse, + }, + recordSpending: { + path: '/services.api_keys.v1.ApiKeysService/RecordSpending', + requestStream: false, + responseStream: false, + requestType: api_keys_pb.RecordSpendingRequest, + responseType: api_keys_pb.RecordSpendingResponse, + requestSerialize: serialize_services_api_keys_v1_RecordSpendingRequest, + requestDeserialize: deserialize_services_api_keys_v1_RecordSpendingRequest, + responseSerialize: serialize_services_api_keys_v1_RecordSpendingResponse, + responseDeserialize: deserialize_services_api_keys_v1_RecordSpendingResponse, + }, + reverseSpending: { + path: '/services.api_keys.v1.ApiKeysService/ReverseSpending', + requestStream: false, + responseStream: false, + requestType: api_keys_pb.ReverseSpendingRequest, + responseType: api_keys_pb.ReverseSpendingResponse, + requestSerialize: serialize_services_api_keys_v1_ReverseSpendingRequest, + requestDeserialize: deserialize_services_api_keys_v1_ReverseSpendingRequest, + responseSerialize: serialize_services_api_keys_v1_ReverseSpendingResponse, + responseDeserialize: deserialize_services_api_keys_v1_ReverseSpendingResponse, + }, +}; + +exports.ApiKeysServiceClient = grpc.makeGenericClientConstructor(ApiKeysServiceService, 'ApiKeysService'); diff --git a/core/api/src/services/api-keys/proto/api_keys_pb.d.ts b/core/api/src/services/api-keys/proto/api_keys_pb.d.ts new file mode 100644 index 0000000000..86d98212eb --- /dev/null +++ b/core/api/src/services/api-keys/proto/api_keys_pb.d.ts @@ -0,0 +1,290 @@ +// package: services.api_keys.v1 +// file: api_keys.proto + +/* tslint:disable */ +/* eslint-disable */ + +import * as jspb from "google-protobuf"; + +export class CheckSpendingLimitRequest extends jspb.Message { + getApiKeyId(): string; + setApiKeyId(value: string): CheckSpendingLimitRequest; + getAmountSats(): number; + setAmountSats(value: number): CheckSpendingLimitRequest; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): CheckSpendingLimitRequest.AsObject; + static toObject(includeInstance: boolean, msg: CheckSpendingLimitRequest): CheckSpendingLimitRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: CheckSpendingLimitRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): CheckSpendingLimitRequest; + static deserializeBinaryFromReader(message: CheckSpendingLimitRequest, reader: jspb.BinaryReader): CheckSpendingLimitRequest; +} + +export namespace CheckSpendingLimitRequest { + export type AsObject = { + apiKeyId: string, + amountSats: number, + } +} + +export class CheckSpendingLimitResponse extends jspb.Message { + getAllowed(): boolean; + setAllowed(value: boolean): CheckSpendingLimitResponse; + + hasDailyLimitSats(): boolean; + clearDailyLimitSats(): void; + getDailyLimitSats(): number | undefined; + setDailyLimitSats(value: number): CheckSpendingLimitResponse; + + hasWeeklyLimitSats(): boolean; + clearWeeklyLimitSats(): void; + getWeeklyLimitSats(): number | undefined; + setWeeklyLimitSats(value: number): CheckSpendingLimitResponse; + + hasMonthlyLimitSats(): boolean; + clearMonthlyLimitSats(): void; + getMonthlyLimitSats(): number | undefined; + setMonthlyLimitSats(value: number): CheckSpendingLimitResponse; + + hasAnnualLimitSats(): boolean; + clearAnnualLimitSats(): void; + getAnnualLimitSats(): number | undefined; + setAnnualLimitSats(value: number): CheckSpendingLimitResponse; + getSpentLast24hSats(): number; + setSpentLast24hSats(value: number): CheckSpendingLimitResponse; + getSpentLast7dSats(): number; + setSpentLast7dSats(value: number): CheckSpendingLimitResponse; + getSpentLast30dSats(): number; + setSpentLast30dSats(value: number): CheckSpendingLimitResponse; + getSpentLast365dSats(): number; + setSpentLast365dSats(value: number): CheckSpendingLimitResponse; + + hasRemainingDailySats(): boolean; + clearRemainingDailySats(): void; + getRemainingDailySats(): number | undefined; + setRemainingDailySats(value: number): CheckSpendingLimitResponse; + + hasRemainingWeeklySats(): boolean; + clearRemainingWeeklySats(): void; + getRemainingWeeklySats(): number | undefined; + setRemainingWeeklySats(value: number): CheckSpendingLimitResponse; + + hasRemainingMonthlySats(): boolean; + clearRemainingMonthlySats(): void; + getRemainingMonthlySats(): number | undefined; + setRemainingMonthlySats(value: number): CheckSpendingLimitResponse; + + hasRemainingAnnualSats(): boolean; + clearRemainingAnnualSats(): void; + getRemainingAnnualSats(): number | undefined; + setRemainingAnnualSats(value: number): CheckSpendingLimitResponse; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): CheckSpendingLimitResponse.AsObject; + static toObject(includeInstance: boolean, msg: CheckSpendingLimitResponse): CheckSpendingLimitResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: CheckSpendingLimitResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): CheckSpendingLimitResponse; + static deserializeBinaryFromReader(message: CheckSpendingLimitResponse, reader: jspb.BinaryReader): CheckSpendingLimitResponse; +} + +export namespace CheckSpendingLimitResponse { + export type AsObject = { + allowed: boolean, + dailyLimitSats?: number, + weeklyLimitSats?: number, + monthlyLimitSats?: number, + annualLimitSats?: number, + spentLast24hSats: number, + spentLast7dSats: number, + spentLast30dSats: number, + spentLast365dSats: number, + remainingDailySats?: number, + remainingWeeklySats?: number, + remainingMonthlySats?: number, + remainingAnnualSats?: number, + } +} + +export class GetSpendingSummaryRequest extends jspb.Message { + getApiKeyId(): string; + setApiKeyId(value: string): GetSpendingSummaryRequest; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): GetSpendingSummaryRequest.AsObject; + static toObject(includeInstance: boolean, msg: GetSpendingSummaryRequest): GetSpendingSummaryRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: GetSpendingSummaryRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): GetSpendingSummaryRequest; + static deserializeBinaryFromReader(message: GetSpendingSummaryRequest, reader: jspb.BinaryReader): GetSpendingSummaryRequest; +} + +export namespace GetSpendingSummaryRequest { + export type AsObject = { + apiKeyId: string, + } +} + +export class GetSpendingSummaryResponse extends jspb.Message { + + hasDailyLimitSats(): boolean; + clearDailyLimitSats(): void; + getDailyLimitSats(): number | undefined; + setDailyLimitSats(value: number): GetSpendingSummaryResponse; + + hasWeeklyLimitSats(): boolean; + clearWeeklyLimitSats(): void; + getWeeklyLimitSats(): number | undefined; + setWeeklyLimitSats(value: number): GetSpendingSummaryResponse; + + hasMonthlyLimitSats(): boolean; + clearMonthlyLimitSats(): void; + getMonthlyLimitSats(): number | undefined; + setMonthlyLimitSats(value: number): GetSpendingSummaryResponse; + + hasAnnualLimitSats(): boolean; + clearAnnualLimitSats(): void; + getAnnualLimitSats(): number | undefined; + setAnnualLimitSats(value: number): GetSpendingSummaryResponse; + getSpentLast24hSats(): number; + setSpentLast24hSats(value: number): GetSpendingSummaryResponse; + getSpentLast7dSats(): number; + setSpentLast7dSats(value: number): GetSpendingSummaryResponse; + getSpentLast30dSats(): number; + setSpentLast30dSats(value: number): GetSpendingSummaryResponse; + getSpentLast365dSats(): number; + setSpentLast365dSats(value: number): GetSpendingSummaryResponse; + + hasRemainingDailySats(): boolean; + clearRemainingDailySats(): void; + getRemainingDailySats(): number | undefined; + setRemainingDailySats(value: number): GetSpendingSummaryResponse; + + hasRemainingWeeklySats(): boolean; + clearRemainingWeeklySats(): void; + getRemainingWeeklySats(): number | undefined; + setRemainingWeeklySats(value: number): GetSpendingSummaryResponse; + + hasRemainingMonthlySats(): boolean; + clearRemainingMonthlySats(): void; + getRemainingMonthlySats(): number | undefined; + setRemainingMonthlySats(value: number): GetSpendingSummaryResponse; + + hasRemainingAnnualSats(): boolean; + clearRemainingAnnualSats(): void; + getRemainingAnnualSats(): number | undefined; + setRemainingAnnualSats(value: number): GetSpendingSummaryResponse; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): GetSpendingSummaryResponse.AsObject; + static toObject(includeInstance: boolean, msg: GetSpendingSummaryResponse): GetSpendingSummaryResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: GetSpendingSummaryResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): GetSpendingSummaryResponse; + static deserializeBinaryFromReader(message: GetSpendingSummaryResponse, reader: jspb.BinaryReader): GetSpendingSummaryResponse; +} + +export namespace GetSpendingSummaryResponse { + export type AsObject = { + dailyLimitSats?: number, + weeklyLimitSats?: number, + monthlyLimitSats?: number, + annualLimitSats?: number, + spentLast24hSats: number, + spentLast7dSats: number, + spentLast30dSats: number, + spentLast365dSats: number, + remainingDailySats?: number, + remainingWeeklySats?: number, + remainingMonthlySats?: number, + remainingAnnualSats?: number, + } +} + +export class RecordSpendingRequest extends jspb.Message { + getApiKeyId(): string; + setApiKeyId(value: string): RecordSpendingRequest; + getAmountSats(): number; + setAmountSats(value: number): RecordSpendingRequest; + + hasTransactionId(): boolean; + clearTransactionId(): void; + getTransactionId(): string | undefined; + setTransactionId(value: string): RecordSpendingRequest; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): RecordSpendingRequest.AsObject; + static toObject(includeInstance: boolean, msg: RecordSpendingRequest): RecordSpendingRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: RecordSpendingRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): RecordSpendingRequest; + static deserializeBinaryFromReader(message: RecordSpendingRequest, reader: jspb.BinaryReader): RecordSpendingRequest; +} + +export namespace RecordSpendingRequest { + export type AsObject = { + apiKeyId: string, + amountSats: number, + transactionId?: string, + } +} + +export class RecordSpendingResponse extends jspb.Message { + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): RecordSpendingResponse.AsObject; + static toObject(includeInstance: boolean, msg: RecordSpendingResponse): RecordSpendingResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: RecordSpendingResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): RecordSpendingResponse; + static deserializeBinaryFromReader(message: RecordSpendingResponse, reader: jspb.BinaryReader): RecordSpendingResponse; +} + +export namespace RecordSpendingResponse { + export type AsObject = { + } +} + +export class ReverseSpendingRequest extends jspb.Message { + getTransactionId(): string; + setTransactionId(value: string): ReverseSpendingRequest; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): ReverseSpendingRequest.AsObject; + static toObject(includeInstance: boolean, msg: ReverseSpendingRequest): ReverseSpendingRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: ReverseSpendingRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): ReverseSpendingRequest; + static deserializeBinaryFromReader(message: ReverseSpendingRequest, reader: jspb.BinaryReader): ReverseSpendingRequest; +} + +export namespace ReverseSpendingRequest { + export type AsObject = { + transactionId: string, + } +} + +export class ReverseSpendingResponse extends jspb.Message { + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): ReverseSpendingResponse.AsObject; + static toObject(includeInstance: boolean, msg: ReverseSpendingResponse): ReverseSpendingResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: ReverseSpendingResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): ReverseSpendingResponse; + static deserializeBinaryFromReader(message: ReverseSpendingResponse, reader: jspb.BinaryReader): ReverseSpendingResponse; +} + +export namespace ReverseSpendingResponse { + export type AsObject = { + } +} diff --git a/core/api/src/services/api-keys/proto/api_keys_pb.js b/core/api/src/services/api-keys/proto/api_keys_pb.js new file mode 100644 index 0000000000..5894b7ee41 --- /dev/null +++ b/core/api/src/services/api-keys/proto/api_keys_pb.js @@ -0,0 +1,2268 @@ +// source: api_keys.proto +/** + * @fileoverview + * @enhanceable + * @suppress {missingRequire} reports error on implicit type usages. + * @suppress {messageConventions} JS Compiler reports an error if a variable or + * field starts with 'MSG_' and isn't a translatable message. + * @public + */ +// GENERATED CODE -- DO NOT EDIT! +/* eslint-disable */ +// @ts-nocheck + +var jspb = require('google-protobuf'); +var goog = jspb; +var global = + (typeof globalThis !== 'undefined' && globalThis) || + (typeof window !== 'undefined' && window) || + (typeof global !== 'undefined' && global) || + (typeof self !== 'undefined' && self) || + (function () { return this; }).call(null) || + Function('return this')(); + +goog.exportSymbol('proto.services.api_keys.v1.CheckSpendingLimitRequest', null, global); +goog.exportSymbol('proto.services.api_keys.v1.CheckSpendingLimitResponse', null, global); +goog.exportSymbol('proto.services.api_keys.v1.GetSpendingSummaryRequest', null, global); +goog.exportSymbol('proto.services.api_keys.v1.GetSpendingSummaryResponse', null, global); +goog.exportSymbol('proto.services.api_keys.v1.RecordSpendingRequest', null, global); +goog.exportSymbol('proto.services.api_keys.v1.RecordSpendingResponse', null, global); +goog.exportSymbol('proto.services.api_keys.v1.ReverseSpendingRequest', null, global); +goog.exportSymbol('proto.services.api_keys.v1.ReverseSpendingResponse', null, global); +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.CheckSpendingLimitRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.CheckSpendingLimitRequest.displayName = 'proto.services.api_keys.v1.CheckSpendingLimitRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.CheckSpendingLimitResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.CheckSpendingLimitResponse.displayName = 'proto.services.api_keys.v1.CheckSpendingLimitResponse'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.GetSpendingSummaryRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.GetSpendingSummaryRequest.displayName = 'proto.services.api_keys.v1.GetSpendingSummaryRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.GetSpendingSummaryResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.GetSpendingSummaryResponse.displayName = 'proto.services.api_keys.v1.GetSpendingSummaryResponse'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.RecordSpendingRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.RecordSpendingRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.RecordSpendingRequest.displayName = 'proto.services.api_keys.v1.RecordSpendingRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.RecordSpendingResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.RecordSpendingResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.RecordSpendingResponse.displayName = 'proto.services.api_keys.v1.RecordSpendingResponse'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.ReverseSpendingRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.ReverseSpendingRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.ReverseSpendingRequest.displayName = 'proto.services.api_keys.v1.ReverseSpendingRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.ReverseSpendingResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.ReverseSpendingResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.ReverseSpendingResponse.displayName = 'proto.services.api_keys.v1.ReverseSpendingResponse'; +} + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.CheckSpendingLimitRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.CheckSpendingLimitRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.toObject = function(includeInstance, msg) { + var f, obj = { +apiKeyId: jspb.Message.getFieldWithDefault(msg, 1, ""), +amountSats: jspb.Message.getFieldWithDefault(msg, 2, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitRequest} + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.CheckSpendingLimitRequest; + return proto.services.api_keys.v1.CheckSpendingLimitRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.CheckSpendingLimitRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitRequest} + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setApiKeyId(value); + break; + case 2: + var value = /** @type {number} */ (reader.readInt64()); + msg.setAmountSats(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.CheckSpendingLimitRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.CheckSpendingLimitRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getApiKeyId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getAmountSats(); + if (f !== 0) { + writer.writeInt64( + 2, + f + ); + } +}; + + +/** + * optional string api_key_id = 1; + * @return {string} + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.prototype.getApiKeyId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitRequest} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.prototype.setApiKeyId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional int64 amount_sats = 2; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.prototype.getAmountSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitRequest} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitRequest.prototype.setAmountSats = function(value) { + return jspb.Message.setProto3IntField(this, 2, value); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.CheckSpendingLimitResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.CheckSpendingLimitResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.toObject = function(includeInstance, msg) { + var f, obj = { +allowed: jspb.Message.getBooleanFieldWithDefault(msg, 1, false), +dailyLimitSats: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f, +weeklyLimitSats: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f, +monthlyLimitSats: (f = jspb.Message.getField(msg, 4)) == null ? undefined : f, +annualLimitSats: (f = jspb.Message.getField(msg, 5)) == null ? undefined : f, +spentLast24hSats: jspb.Message.getFieldWithDefault(msg, 6, 0), +spentLast7dSats: jspb.Message.getFieldWithDefault(msg, 7, 0), +spentLast30dSats: jspb.Message.getFieldWithDefault(msg, 8, 0), +spentLast365dSats: jspb.Message.getFieldWithDefault(msg, 9, 0), +remainingDailySats: (f = jspb.Message.getField(msg, 10)) == null ? undefined : f, +remainingWeeklySats: (f = jspb.Message.getField(msg, 11)) == null ? undefined : f, +remainingMonthlySats: (f = jspb.Message.getField(msg, 12)) == null ? undefined : f, +remainingAnnualSats: (f = jspb.Message.getField(msg, 13)) == null ? undefined : f + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.CheckSpendingLimitResponse; + return proto.services.api_keys.v1.CheckSpendingLimitResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.CheckSpendingLimitResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setAllowed(value); + break; + case 2: + var value = /** @type {number} */ (reader.readInt64()); + msg.setDailyLimitSats(value); + break; + case 3: + var value = /** @type {number} */ (reader.readInt64()); + msg.setWeeklyLimitSats(value); + break; + case 4: + var value = /** @type {number} */ (reader.readInt64()); + msg.setMonthlyLimitSats(value); + break; + case 5: + var value = /** @type {number} */ (reader.readInt64()); + msg.setAnnualLimitSats(value); + break; + case 6: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast24hSats(value); + break; + case 7: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast7dSats(value); + break; + case 8: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast30dSats(value); + break; + case 9: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast365dSats(value); + break; + case 10: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingDailySats(value); + break; + case 11: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingWeeklySats(value); + break; + case 12: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingMonthlySats(value); + break; + case 13: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingAnnualSats(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.CheckSpendingLimitResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.CheckSpendingLimitResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getAllowed(); + if (f) { + writer.writeBool( + 1, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 2)); + if (f != null) { + writer.writeInt64( + 2, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 3)); + if (f != null) { + writer.writeInt64( + 3, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 4)); + if (f != null) { + writer.writeInt64( + 4, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 5)); + if (f != null) { + writer.writeInt64( + 5, + f + ); + } + f = message.getSpentLast24hSats(); + if (f !== 0) { + writer.writeInt64( + 6, + f + ); + } + f = message.getSpentLast7dSats(); + if (f !== 0) { + writer.writeInt64( + 7, + f + ); + } + f = message.getSpentLast30dSats(); + if (f !== 0) { + writer.writeInt64( + 8, + f + ); + } + f = message.getSpentLast365dSats(); + if (f !== 0) { + writer.writeInt64( + 9, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 10)); + if (f != null) { + writer.writeInt64( + 10, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 11)); + if (f != null) { + writer.writeInt64( + 11, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 12)); + if (f != null) { + writer.writeInt64( + 12, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 13)); + if (f != null) { + writer.writeInt64( + 13, + f + ); + } +}; + + +/** + * optional bool allowed = 1; + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getAllowed = function() { + return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 1, false)); +}; + + +/** + * @param {boolean} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setAllowed = function(value) { + return jspb.Message.setProto3BooleanField(this, 1, value); +}; + + +/** + * optional int64 daily_limit_sats = 2; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getDailyLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setDailyLimitSats = function(value) { + return jspb.Message.setField(this, 2, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearDailyLimitSats = function() { + return jspb.Message.setField(this, 2, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasDailyLimitSats = function() { + return jspb.Message.getField(this, 2) != null; +}; + + +/** + * optional int64 weekly_limit_sats = 3; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getWeeklyLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setWeeklyLimitSats = function(value) { + return jspb.Message.setField(this, 3, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearWeeklyLimitSats = function() { + return jspb.Message.setField(this, 3, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasWeeklyLimitSats = function() { + return jspb.Message.getField(this, 3) != null; +}; + + +/** + * optional int64 monthly_limit_sats = 4; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getMonthlyLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 4, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setMonthlyLimitSats = function(value) { + return jspb.Message.setField(this, 4, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearMonthlyLimitSats = function() { + return jspb.Message.setField(this, 4, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasMonthlyLimitSats = function() { + return jspb.Message.getField(this, 4) != null; +}; + + +/** + * optional int64 annual_limit_sats = 5; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getAnnualLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 5, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setAnnualLimitSats = function(value) { + return jspb.Message.setField(this, 5, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearAnnualLimitSats = function() { + return jspb.Message.setField(this, 5, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasAnnualLimitSats = function() { + return jspb.Message.getField(this, 5) != null; +}; + + +/** + * optional int64 spent_last_24h_sats = 6; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast24hSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 6, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast24hSats = function(value) { + return jspb.Message.setProto3IntField(this, 6, value); +}; + + +/** + * optional int64 spent_last_7d_sats = 7; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast7dSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 7, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast7dSats = function(value) { + return jspb.Message.setProto3IntField(this, 7, value); +}; + + +/** + * optional int64 spent_last_30d_sats = 8; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast30dSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 8, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast30dSats = function(value) { + return jspb.Message.setProto3IntField(this, 8, value); +}; + + +/** + * optional int64 spent_last_365d_sats = 9; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast365dSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 9, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast365dSats = function(value) { + return jspb.Message.setProto3IntField(this, 9, value); +}; + + +/** + * optional int64 remaining_daily_sats = 10; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getRemainingDailySats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 10, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setRemainingDailySats = function(value) { + return jspb.Message.setField(this, 10, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearRemainingDailySats = function() { + return jspb.Message.setField(this, 10, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasRemainingDailySats = function() { + return jspb.Message.getField(this, 10) != null; +}; + + +/** + * optional int64 remaining_weekly_sats = 11; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getRemainingWeeklySats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 11, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setRemainingWeeklySats = function(value) { + return jspb.Message.setField(this, 11, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearRemainingWeeklySats = function() { + return jspb.Message.setField(this, 11, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasRemainingWeeklySats = function() { + return jspb.Message.getField(this, 11) != null; +}; + + +/** + * optional int64 remaining_monthly_sats = 12; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getRemainingMonthlySats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 12, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setRemainingMonthlySats = function(value) { + return jspb.Message.setField(this, 12, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearRemainingMonthlySats = function() { + return jspb.Message.setField(this, 12, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasRemainingMonthlySats = function() { + return jspb.Message.getField(this, 12) != null; +}; + + +/** + * optional int64 remaining_annual_sats = 13; + * @return {number} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getRemainingAnnualSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 13, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setRemainingAnnualSats = function(value) { + return jspb.Message.setField(this, 13, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.clearRemainingAnnualSats = function() { + return jspb.Message.setField(this, 13, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasRemainingAnnualSats = function() { + return jspb.Message.getField(this, 13) != null; +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.GetSpendingSummaryRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.GetSpendingSummaryRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.toObject = function(includeInstance, msg) { + var f, obj = { +apiKeyId: jspb.Message.getFieldWithDefault(msg, 1, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryRequest} + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.GetSpendingSummaryRequest; + return proto.services.api_keys.v1.GetSpendingSummaryRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.GetSpendingSummaryRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryRequest} + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setApiKeyId(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.GetSpendingSummaryRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.GetSpendingSummaryRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getApiKeyId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } +}; + + +/** + * optional string api_key_id = 1; + * @return {string} + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.prototype.getApiKeyId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryRequest} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryRequest.prototype.setApiKeyId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.GetSpendingSummaryResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.GetSpendingSummaryResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.toObject = function(includeInstance, msg) { + var f, obj = { +dailyLimitSats: (f = jspb.Message.getField(msg, 1)) == null ? undefined : f, +weeklyLimitSats: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f, +monthlyLimitSats: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f, +annualLimitSats: (f = jspb.Message.getField(msg, 4)) == null ? undefined : f, +spentLast24hSats: jspb.Message.getFieldWithDefault(msg, 5, 0), +spentLast7dSats: jspb.Message.getFieldWithDefault(msg, 6, 0), +spentLast30dSats: jspb.Message.getFieldWithDefault(msg, 7, 0), +spentLast365dSats: jspb.Message.getFieldWithDefault(msg, 8, 0), +remainingDailySats: (f = jspb.Message.getField(msg, 9)) == null ? undefined : f, +remainingWeeklySats: (f = jspb.Message.getField(msg, 10)) == null ? undefined : f, +remainingMonthlySats: (f = jspb.Message.getField(msg, 11)) == null ? undefined : f, +remainingAnnualSats: (f = jspb.Message.getField(msg, 12)) == null ? undefined : f + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.GetSpendingSummaryResponse; + return proto.services.api_keys.v1.GetSpendingSummaryResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.GetSpendingSummaryResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {number} */ (reader.readInt64()); + msg.setDailyLimitSats(value); + break; + case 2: + var value = /** @type {number} */ (reader.readInt64()); + msg.setWeeklyLimitSats(value); + break; + case 3: + var value = /** @type {number} */ (reader.readInt64()); + msg.setMonthlyLimitSats(value); + break; + case 4: + var value = /** @type {number} */ (reader.readInt64()); + msg.setAnnualLimitSats(value); + break; + case 5: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast24hSats(value); + break; + case 6: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast7dSats(value); + break; + case 7: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast30dSats(value); + break; + case 8: + var value = /** @type {number} */ (reader.readInt64()); + msg.setSpentLast365dSats(value); + break; + case 9: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingDailySats(value); + break; + case 10: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingWeeklySats(value); + break; + case 11: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingMonthlySats(value); + break; + case 12: + var value = /** @type {number} */ (reader.readInt64()); + msg.setRemainingAnnualSats(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.GetSpendingSummaryResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.GetSpendingSummaryResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = /** @type {number} */ (jspb.Message.getField(message, 1)); + if (f != null) { + writer.writeInt64( + 1, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 2)); + if (f != null) { + writer.writeInt64( + 2, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 3)); + if (f != null) { + writer.writeInt64( + 3, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 4)); + if (f != null) { + writer.writeInt64( + 4, + f + ); + } + f = message.getSpentLast24hSats(); + if (f !== 0) { + writer.writeInt64( + 5, + f + ); + } + f = message.getSpentLast7dSats(); + if (f !== 0) { + writer.writeInt64( + 6, + f + ); + } + f = message.getSpentLast30dSats(); + if (f !== 0) { + writer.writeInt64( + 7, + f + ); + } + f = message.getSpentLast365dSats(); + if (f !== 0) { + writer.writeInt64( + 8, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 9)); + if (f != null) { + writer.writeInt64( + 9, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 10)); + if (f != null) { + writer.writeInt64( + 10, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 11)); + if (f != null) { + writer.writeInt64( + 11, + f + ); + } + f = /** @type {number} */ (jspb.Message.getField(message, 12)); + if (f != null) { + writer.writeInt64( + 12, + f + ); + } +}; + + +/** + * optional int64 daily_limit_sats = 1; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getDailyLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 1, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setDailyLimitSats = function(value) { + return jspb.Message.setField(this, 1, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearDailyLimitSats = function() { + return jspb.Message.setField(this, 1, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasDailyLimitSats = function() { + return jspb.Message.getField(this, 1) != null; +}; + + +/** + * optional int64 weekly_limit_sats = 2; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getWeeklyLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setWeeklyLimitSats = function(value) { + return jspb.Message.setField(this, 2, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearWeeklyLimitSats = function() { + return jspb.Message.setField(this, 2, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasWeeklyLimitSats = function() { + return jspb.Message.getField(this, 2) != null; +}; + + +/** + * optional int64 monthly_limit_sats = 3; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getMonthlyLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 3, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setMonthlyLimitSats = function(value) { + return jspb.Message.setField(this, 3, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearMonthlyLimitSats = function() { + return jspb.Message.setField(this, 3, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasMonthlyLimitSats = function() { + return jspb.Message.getField(this, 3) != null; +}; + + +/** + * optional int64 annual_limit_sats = 4; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getAnnualLimitSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 4, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setAnnualLimitSats = function(value) { + return jspb.Message.setField(this, 4, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearAnnualLimitSats = function() { + return jspb.Message.setField(this, 4, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasAnnualLimitSats = function() { + return jspb.Message.getField(this, 4) != null; +}; + + +/** + * optional int64 spent_last_24h_sats = 5; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast24hSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 5, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast24hSats = function(value) { + return jspb.Message.setProto3IntField(this, 5, value); +}; + + +/** + * optional int64 spent_last_7d_sats = 6; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast7dSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 6, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast7dSats = function(value) { + return jspb.Message.setProto3IntField(this, 6, value); +}; + + +/** + * optional int64 spent_last_30d_sats = 7; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast30dSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 7, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast30dSats = function(value) { + return jspb.Message.setProto3IntField(this, 7, value); +}; + + +/** + * optional int64 spent_last_365d_sats = 8; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast365dSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 8, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast365dSats = function(value) { + return jspb.Message.setProto3IntField(this, 8, value); +}; + + +/** + * optional int64 remaining_daily_sats = 9; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getRemainingDailySats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 9, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setRemainingDailySats = function(value) { + return jspb.Message.setField(this, 9, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearRemainingDailySats = function() { + return jspb.Message.setField(this, 9, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasRemainingDailySats = function() { + return jspb.Message.getField(this, 9) != null; +}; + + +/** + * optional int64 remaining_weekly_sats = 10; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getRemainingWeeklySats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 10, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setRemainingWeeklySats = function(value) { + return jspb.Message.setField(this, 10, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearRemainingWeeklySats = function() { + return jspb.Message.setField(this, 10, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasRemainingWeeklySats = function() { + return jspb.Message.getField(this, 10) != null; +}; + + +/** + * optional int64 remaining_monthly_sats = 11; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getRemainingMonthlySats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 11, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setRemainingMonthlySats = function(value) { + return jspb.Message.setField(this, 11, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearRemainingMonthlySats = function() { + return jspb.Message.setField(this, 11, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasRemainingMonthlySats = function() { + return jspb.Message.getField(this, 11) != null; +}; + + +/** + * optional int64 remaining_annual_sats = 12; + * @return {number} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getRemainingAnnualSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 12, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setRemainingAnnualSats = function(value) { + return jspb.Message.setField(this, 12, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.clearRemainingAnnualSats = function() { + return jspb.Message.setField(this, 12, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasRemainingAnnualSats = function() { + return jspb.Message.getField(this, 12) != null; +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.RecordSpendingRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.RecordSpendingRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.RecordSpendingRequest.toObject = function(includeInstance, msg) { + var f, obj = { +apiKeyId: jspb.Message.getFieldWithDefault(msg, 1, ""), +amountSats: jspb.Message.getFieldWithDefault(msg, 2, 0), +transactionId: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} + */ +proto.services.api_keys.v1.RecordSpendingRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.RecordSpendingRequest; + return proto.services.api_keys.v1.RecordSpendingRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.RecordSpendingRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} + */ +proto.services.api_keys.v1.RecordSpendingRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setApiKeyId(value); + break; + case 2: + var value = /** @type {number} */ (reader.readInt64()); + msg.setAmountSats(value); + break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.setTransactionId(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.RecordSpendingRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.RecordSpendingRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.RecordSpendingRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getApiKeyId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getAmountSats(); + if (f !== 0) { + writer.writeInt64( + 2, + f + ); + } + f = /** @type {string} */ (jspb.Message.getField(message, 3)); + if (f != null) { + writer.writeString( + 3, + f + ); + } +}; + + +/** + * optional string api_key_id = 1; + * @return {string} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.getApiKeyId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} returns this + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.setApiKeyId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional int64 amount_sats = 2; + * @return {number} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.getAmountSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} returns this + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.setAmountSats = function(value) { + return jspb.Message.setProto3IntField(this, 2, value); +}; + + +/** + * optional string transaction_id = 3; + * @return {string} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.getTransactionId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} returns this + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.setTransactionId = function(value) { + return jspb.Message.setField(this, 3, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} returns this + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.clearTransactionId = function() { + return jspb.Message.setField(this, 3, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.hasTransactionId = function() { + return jspb.Message.getField(this, 3) != null; +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.RecordSpendingResponse.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.RecordSpendingResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.RecordSpendingResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.RecordSpendingResponse.toObject = function(includeInstance, msg) { + var f, obj = { + + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.RecordSpendingResponse} + */ +proto.services.api_keys.v1.RecordSpendingResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.RecordSpendingResponse; + return proto.services.api_keys.v1.RecordSpendingResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.RecordSpendingResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.RecordSpendingResponse} + */ +proto.services.api_keys.v1.RecordSpendingResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.RecordSpendingResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.RecordSpendingResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.RecordSpendingResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.RecordSpendingResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.ReverseSpendingRequest.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.ReverseSpendingRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.ReverseSpendingRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.ReverseSpendingRequest.toObject = function(includeInstance, msg) { + var f, obj = { +transactionId: jspb.Message.getFieldWithDefault(msg, 1, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.ReverseSpendingRequest} + */ +proto.services.api_keys.v1.ReverseSpendingRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.ReverseSpendingRequest; + return proto.services.api_keys.v1.ReverseSpendingRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.ReverseSpendingRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.ReverseSpendingRequest} + */ +proto.services.api_keys.v1.ReverseSpendingRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setTransactionId(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.ReverseSpendingRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.ReverseSpendingRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.ReverseSpendingRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.ReverseSpendingRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getTransactionId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } +}; + + +/** + * optional string transaction_id = 1; + * @return {string} + */ +proto.services.api_keys.v1.ReverseSpendingRequest.prototype.getTransactionId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.ReverseSpendingRequest} returns this + */ +proto.services.api_keys.v1.ReverseSpendingRequest.prototype.setTransactionId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.ReverseSpendingResponse.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.ReverseSpendingResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.ReverseSpendingResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.ReverseSpendingResponse.toObject = function(includeInstance, msg) { + var f, obj = { + + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.ReverseSpendingResponse} + */ +proto.services.api_keys.v1.ReverseSpendingResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.ReverseSpendingResponse; + return proto.services.api_keys.v1.ReverseSpendingResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.ReverseSpendingResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.ReverseSpendingResponse} + */ +proto.services.api_keys.v1.ReverseSpendingResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.ReverseSpendingResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.ReverseSpendingResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.ReverseSpendingResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.ReverseSpendingResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; +}; + + +goog.object.extend(exports, proto.services.api_keys.v1); diff --git a/core/api/src/services/api-keys/proto/buf.gen.yaml b/core/api/src/services/api-keys/proto/buf.gen.yaml new file mode 100644 index 0000000000..addcc3c669 --- /dev/null +++ b/core/api/src/services/api-keys/proto/buf.gen.yaml @@ -0,0 +1,16 @@ +# /proto/buf.gen.yaml +version: v1 + +plugins: + - name: js + out: . + opt: import_style=commonjs,binary + path: ../../../../node_modules/.bin/protoc-gen-js + - name: grpc + out: . + opt: grpc_js + path: ../../../../node_modules/.bin/grpc_tools_node_protoc_plugin + - name: ts + out: . + opt: grpc_js + path: ../../../../node_modules/.bin/protoc-gen-ts From 8e1b25373c064cc8c1fb65dc09c4cf2f6bd6f48d Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 2 Mar 2026 16:42:48 +0300 Subject: [PATCH 02/33] test: add hold invoice reversal bats tests --- .../api-keys-hold-invoice-reversal.bats | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 bats/core/api-keys/api-keys-hold-invoice-reversal.bats diff --git a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats new file mode 100644 index 0000000000..e6d01c7e16 --- /dev/null +++ b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats @@ -0,0 +1,148 @@ +#!/usr/bin/env bats + +# Tests for API key spending reversal when hold invoices are canceled/timeout + +load "../../helpers/_common.bash" +load "../../helpers/cli.bash" +load "../../helpers/user.bash" +load "../../helpers/onchain.bash" +load "../../helpers/ln.bash" + +random_uuid() { + if [[ -e /proc/sys/kernel/random/uuid ]]; then + cat /proc/sys/kernel/random/uuid + else + uuidgen + fi +} + +new_key_name() { + random_uuid +} + +ALICE='alice' +BOB='bob' + +setup_file() { + clear_cache + + # Ensure LND has sufficient balance for lightning tests + lnd1_balance=$(lnd_cli channelbalance | jq -r '.balance // 0') + if [[ $lnd1_balance -lt "1000000" ]]; then + create_user 'lnd_funding' + fund_user_lightning 'lnd_funding' 'lnd_funding.btc_wallet_id' '5000000' + fi + + create_user "$ALICE" + fund_user_onchain "$ALICE" 'btc_wallet' +} + +@test "hold-invoice-reversal: create api key with daily limit" { + key_name="$(new_key_name)" + cache_value 'hold_invoice_key_name' "$key_name" + + variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" + + exec_graphql 'alice' 'api-key-create' "$variables" + key="$(graphql_output '.data.apiKeyCreate.apiKey')" + secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" + + cache_value "api-key-hold-invoice-secret" "$secret" + + name=$(echo "$key" | jq -r '.name') + [[ "${name}" = "${key_name}" ]] || exit 1 + + key_id=$(echo "$key" | jq -r '.id') + cache_value "hold-invoice-api-key-id" "$key_id" + + # Set daily limit to 10000 sats + variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + + daily_limit="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.dailyLimitSats')" + [[ "${daily_limit}" = "10000" ]] || exit 1 + + spent_24h="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.spentLast24HSats')" + [[ "${spent_24h}" = "0" ]] || exit 1 +} + +@test "hold-invoice-reversal: pay hold invoice and check spending recorded" { + # Generate a random preimage and hash for the hold invoice + secret=$(xxd -l 32 -c 256 -p /dev/urandom) + payment_hash=$(echo -n $secret | xxd -r -p | sha256sum | cut -d ' ' -f1) + + cache_value "hold-invoice-preimage" "$secret" + cache_value "hold-invoice-payment-hash" "$payment_hash" + + # Create a hold invoice on external LND with explicit hash + invoice_response="$(lnd_outside_cli addholdinvoice "$payment_hash" --amt 5000 --memo 'Test hold invoice')" + payment_request="$(echo $invoice_response | jq -r '.payment_request')" + + cache_value "hold-invoice-payment-request" "$payment_request" + + # Pay the invoice with API key + variables=$( + jq -n \ + --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ + --arg payment_request "$payment_request" \ + '{input: {walletId: $wallet_id, paymentRequest: $payment_request}}' + ) + + exec_graphql 'api-key-hold-invoice-secret' 'ln-invoice-payment-send' "$variables" + send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" + + # Payment should be pending (held) + [[ "${send_status}" = "PENDING" || "${send_status}" = "SUCCESS" ]] || exit 1 + + # Check that spending was recorded + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.spentLast24HSats')" + + # Should have recorded 5000 sats spending + [[ "${spent_24h}" -ge "5000" ]] || exit 1 + + echo "✅ Spending recorded: ${spent_24h} sats" +} + +@test "hold-invoice-reversal: verify payment is held on external LND" { + payment_hash="$(read_value 'hold-invoice-payment-hash')" + + # Check invoice status on external LND + invoice_info="$(lnd_outside_cli lookupinvoice "$payment_hash")" + state="$(echo $invoice_info | jq -r '.state')" + + # Invoice should be in ACCEPTED state (held, not settled) + [[ "${state}" = "ACCEPTED" ]] || exit 1 + + echo "✅ Invoice is held (ACCEPTED state)" + echo "Payment hash: $payment_hash" +} + +@test "hold-invoice-reversal: cancel hold invoice" { + payment_hash="$(read_value 'hold-invoice-payment-hash')" + + # Cancel the held invoice on external LND + cancel_result="$(lnd_outside_cli cancelinvoice "$payment_hash" 2>&1 || true)" + + echo "Cancel result: $cancel_result" + + # Give trigger server time to process the cancellation + sleep 10 +} + +@test "hold-invoice-reversal: verify spending was reversed after cancellation" { + # Trigger the pending payment update to process the cancellation + # This simulates what the trigger server does every 5 minutes + + # Wait a bit more for the system to process + sleep 5 + + # Check that spending was reversed + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.spentLast24HSats')" + + # Spending should be back to 0 after reversal + [[ "${spent_24h}" = "0" ]] || exit 1 + + echo "✅ Spending reversed: ${spent_24h} sats" +} \ No newline at end of file From a25aac5d4ff6c6ad810c928830f17f49efeb9c6e Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 9 Mar 2026 15:42:56 +0300 Subject: [PATCH 03/33] fix: align part2 with part1 schema naming and generic limit mutations --- apps/dashboard/app/api-keys/server-actions.ts | 89 ++-- apps/dashboard/components/api-keys/list.tsx | 32 +- apps/dashboard/services/graphql/generated.ts | 410 ++---------------- .../services/graphql/mutations/api-keys.ts | 297 ++----------- .../api-keys-hold-invoice-reversal.bats | 12 +- bats/core/api-keys/api-keys-limits.bats | 122 +++--- .../src/domain/api-keys/spending-limits.ts | 24 +- core/api/src/services/api-keys/convert.ts | 8 +- .../services/api-keys/proto/api_keys.proto | 16 +- .../services/api-keys/proto/api_keys_pb.d.ts | 48 +- .../services/api-keys/proto/api_keys_pb.js | 96 ++-- 11 files changed, 303 insertions(+), 851 deletions(-) diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index 1c50aef755..6db18b893e 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -8,16 +8,10 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { createApiKey, revokeApiKey, - setApiKeyDailyLimit, - setApiKeyWeeklyLimit, - setApiKeyMonthlyLimit, - setApiKeyAnnualLimit, + setApiKeyLimit, removeApiKeyLimit, - removeApiKeyWeeklyLimit, - removeApiKeyMonthlyLimit, - removeApiKeyAnnualLimit, } from "@/services/graphql/mutations/api-keys" -import { Scope } from "@/services/graphql/generated" +import { LimitTimeWindow, Scope } from "@/services/graphql/generated" export const revokeApiKeyServerAction = async (id: string) => { if (!id || typeof id !== "string") { @@ -128,35 +122,24 @@ export const createApiKeyServerAction = async ( if (data?.apiKeyCreate.apiKey.id) { const apiKeyId = data.apiKeyCreate.apiKey.id try { - const dailyLimitSats = form.get("dailyLimitSats") - if (dailyLimitSats && dailyLimitSats !== "") { - const limit = parseInt(dailyLimitSats as string, 10) - if (limit > 0) { - await setApiKeyDailyLimit({ id: apiKeyId, dailyLimitSats: limit }) - } - } - - const weeklyLimitSats = form.get("weeklyLimitSats") - if (weeklyLimitSats && weeklyLimitSats !== "") { - const limit = parseInt(weeklyLimitSats as string, 10) - if (limit > 0) { - await setApiKeyWeeklyLimit({ id: apiKeyId, weeklyLimitSats: limit }) - } - } - - const monthlyLimitSats = form.get("monthlyLimitSats") - if (monthlyLimitSats && monthlyLimitSats !== "") { - const limit = parseInt(monthlyLimitSats as string, 10) - if (limit > 0) { - await setApiKeyMonthlyLimit({ id: apiKeyId, monthlyLimitSats: limit }) - } - } - - const annualLimitSats = form.get("annualLimitSats") - if (annualLimitSats && annualLimitSats !== "") { - const limit = parseInt(annualLimitSats as string, 10) - if (limit > 0) { - await setApiKeyAnnualLimit({ id: apiKeyId, annualLimitSats: limit }) + const limitFields: Array<{ formField: string; timeWindow: LimitTimeWindow }> = [ + { formField: "dailyLimitSats", timeWindow: LimitTimeWindow.Daily }, + { formField: "weeklyLimitSats", timeWindow: LimitTimeWindow.Weekly }, + { formField: "monthlyLimitSats", timeWindow: LimitTimeWindow.Monthly }, + { formField: "annualLimitSats", timeWindow: LimitTimeWindow.Annual }, + ] + + for (const { formField, timeWindow } of limitFields) { + const value = form.get(formField) + if (value && value !== "") { + const limit = parseInt(value as string, 10) + if (limit > 0) { + await setApiKeyLimit({ + id: apiKeyId, + limitTimeWindow: timeWindow, + limitSats: limit, + }) + } } } } catch (err) { @@ -195,9 +178,9 @@ export const setDailyLimit = async ({ } try { - await setApiKeyDailyLimit({ id, dailyLimitSats }) + await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Daily, limitSats: dailyLimitSats }) } catch (err) { - console.log("error in setApiKeyDailyLimit ", err) + console.log("error in setApiKeyLimit (daily) ", err) throw new Error("Failed to set API key daily limit") } @@ -226,9 +209,9 @@ export const setWeeklyLimit = async ({ } try { - await setApiKeyWeeklyLimit({ id, weeklyLimitSats }) + await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Weekly, limitSats: weeklyLimitSats }) } catch (err) { - console.log("error in setApiKeyWeeklyLimit ", err) + console.log("error in setApiKeyLimit (weekly) ", err) throw new Error("Failed to set API key weekly limit") } @@ -257,9 +240,9 @@ export const setMonthlyLimit = async ({ } try { - await setApiKeyMonthlyLimit({ id, monthlyLimitSats }) + await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Monthly, limitSats: monthlyLimitSats }) } catch (err) { - console.log("error in setApiKeyMonthlyLimit ", err) + console.log("error in setApiKeyLimit (monthly) ", err) throw new Error("Failed to set API key monthly limit") } @@ -288,9 +271,9 @@ export const setAnnualLimit = async ({ } try { - await setApiKeyAnnualLimit({ id, annualLimitSats }) + await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Annual, limitSats: annualLimitSats }) } catch (err) { - console.log("error in setApiKeyAnnualLimit ", err) + console.log("error in setApiKeyLimit (annual) ", err) throw new Error("Failed to set API key annual limit") } @@ -309,9 +292,9 @@ export const removeLimit = async ({ id }: { id: string }) => { } try { - await removeApiKeyLimit({ id }) + await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Daily }) } catch (err) { - console.log("error in removeApiKeyLimit ", err) + console.log("error in removeApiKeyLimit (daily) ", err) throw new Error("Failed to remove API key limit") } @@ -330,9 +313,9 @@ export const removeWeeklyLimit = async ({ id }: { id: string }) => { } try { - await removeApiKeyWeeklyLimit({ id }) + await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Weekly }) } catch (err) { - console.log("error in removeApiKeyWeeklyLimit ", err) + console.log("error in removeApiKeyLimit (weekly) ", err) throw new Error("Failed to remove API key weekly limit") } @@ -351,9 +334,9 @@ export const removeMonthlyLimit = async ({ id }: { id: string }) => { } try { - await removeApiKeyMonthlyLimit({ id }) + await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Monthly }) } catch (err) { - console.log("error in removeApiKeyMonthlyLimit ", err) + console.log("error in removeApiKeyLimit (monthly) ", err) throw new Error("Failed to remove API key monthly limit") } @@ -372,9 +355,9 @@ export const removeAnnualLimit = async ({ id }: { id: string }) => { } try { - await removeApiKeyAnnualLimit({ id }) + await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Annual }) } catch (err) { - console.log("error in removeApiKeyAnnualLimit ", err) + console.log("error in removeApiKeyLimit (annual) ", err) throw new Error("Failed to remove API key annual limit") } diff --git a/apps/dashboard/components/api-keys/list.tsx b/apps/dashboard/components/api-keys/list.tsx index e742cd6cd4..5e94cd7ff4 100644 --- a/apps/dashboard/components/api-keys/list.tsx +++ b/apps/dashboard/components/api-keys/list.tsx @@ -44,14 +44,14 @@ const ApiKeysList: React.FC = ({ const weeklyLimitSats = limits?.weeklyLimitSats const monthlyLimitSats = limits?.monthlyLimitSats const annualLimitSats = limits?.annualLimitSats - const spentLast24HSats = limits?.spentLast24HSats ?? 0 - const spentLast7DSats = limits?.spentLast7DSats ?? 0 - const spentLast30DSats = limits?.spentLast30DSats ?? 0 - const spentLast365DSats = limits?.spentLast365DSats ?? 0 + const dailySpentSats = limits?.dailySpentSats ?? 0 + const weeklySpentSats = limits?.weeklySpentSats ?? 0 + const monthlySpentSats = limits?.monthlySpentSats ?? 0 + const annualSpentSats = limits?.annualSpentSats ?? 0 const remainingDailyLimitSats = dailyLimitSats !== null && dailyLimitSats !== undefined - ? dailyLimitSats - spentLast24HSats + ? dailyLimitSats - dailySpentSats : null const hasAnyLimit = @@ -71,7 +71,7 @@ const ApiKeysList: React.FC = ({ Daily: {dailyLimitSats.toLocaleString()} sats - Spent: {spentLast24HSats.toLocaleString()} / Remaining:{" "} + Spent: {dailySpentSats.toLocaleString()} / Remaining:{" "} {remainingDailyLimitSats?.toLocaleString() || 0} @@ -83,8 +83,8 @@ const ApiKeysList: React.FC = ({ sats - Spent: {spentLast7DSats.toLocaleString()} / Remaining:{" "} - {(weeklyLimitSats - spentLast7DSats).toLocaleString()} + Spent: {weeklySpentSats.toLocaleString()} / Remaining:{" "} + {(weeklyLimitSats - weeklySpentSats).toLocaleString()} )} @@ -95,8 +95,8 @@ const ApiKeysList: React.FC = ({ sats - Spent: {spentLast30DSats.toLocaleString()} / Remaining:{" "} - {(monthlyLimitSats - spentLast30DSats).toLocaleString()} + Spent: {monthlySpentSats.toLocaleString()} / Remaining:{" "} + {(monthlyLimitSats - monthlySpentSats).toLocaleString()} )} @@ -107,8 +107,8 @@ const ApiKeysList: React.FC = ({ sats - Spent: {spentLast365DSats.toLocaleString()} / Remaining:{" "} - {(annualLimitSats - spentLast365DSats).toLocaleString()} + Spent: {annualSpentSats.toLocaleString()} / Remaining:{" "} + {(annualLimitSats - annualSpentSats).toLocaleString()} )} @@ -132,10 +132,10 @@ const ApiKeysList: React.FC = ({ annual: annualLimitSats ?? null, }} spent={{ - last24h: spentLast24HSats, - last7d: spentLast7DSats, - last30d: spentLast30DSats, - last365d: spentLast365DSats, + last24h: dailySpentSats, + last7d: weeklySpentSats, + last30d: monthlySpentSats, + last365d: annualSpentSats, }} /> diff --git a/apps/dashboard/services/graphql/generated.ts b/apps/dashboard/services/graphql/generated.ts index c93b739c97..86451398d9 100644 --- a/apps/dashboard/services/graphql/generated.ts +++ b/apps/dashboard/services/graphql/generated.ts @@ -1607,10 +1607,12 @@ export type OneDayAccountLimit = AccountLimit & { export type OpenDeepLinkAction = { readonly __typename: 'OpenDeepLinkAction'; readonly deepLink: Scalars['String']['output']; + readonly label?: Maybe; }; export type OpenExternalLinkAction = { readonly __typename: 'OpenExternalLinkAction'; + readonly label?: Maybe; readonly url: Scalars['String']['output']; }; @@ -2534,7 +2536,7 @@ export type ApiKeyCreateMutationVariables = Exact<{ }>; -export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; +export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly dailySpentSats: number, readonly weeklySpentSats: number, readonly monthlySpentSats: number, readonly annualSpentSats: number } } } }; export type ApiKeyRevokeMutationVariables = Exact<{ input: ApiKeyRevokeInput; @@ -2543,61 +2545,19 @@ export type ApiKeyRevokeMutationVariables = Exact<{ export type ApiKeyRevokeMutation = { readonly __typename: 'Mutation', readonly apiKeyRevoke: { readonly __typename: 'ApiKeyRevokePayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; -export type ApiKeySetDailyLimitMutationVariables = Exact<{ - input: ApiKeySetDailyLimitInput; -}>; - - -export type ApiKeySetDailyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetDailyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; - -export type ApiKeyRemoveDailyLimitMutationVariables = Exact<{ - input: ApiKeyRemoveLimitInput; -}>; - - -export type ApiKeyRemoveDailyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveDailyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; - -export type ApiKeySetWeeklyLimitMutationVariables = Exact<{ - input: ApiKeySetWeeklyLimitInput; -}>; - - -export type ApiKeySetWeeklyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetWeeklyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; - -export type ApiKeyRemoveWeeklyLimitMutationVariables = Exact<{ - input: ApiKeyRemoveLimitInput; -}>; - - -export type ApiKeyRemoveWeeklyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveWeeklyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; - -export type ApiKeySetMonthlyLimitMutationVariables = Exact<{ - input: ApiKeySetMonthlyLimitInput; -}>; - - -export type ApiKeySetMonthlyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetMonthlyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; - -export type ApiKeyRemoveMonthlyLimitMutationVariables = Exact<{ - input: ApiKeyRemoveLimitInput; -}>; - - -export type ApiKeyRemoveMonthlyLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveMonthlyLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; - -export type ApiKeySetAnnualLimitMutationVariables = Exact<{ - input: ApiKeySetAnnualLimitInput; +export type ApiKeySetLimitMutationVariables = Exact<{ + input: ApiKeySetLimitInput; }>; -export type ApiKeySetAnnualLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetAnnualLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; +export type ApiKeySetLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly dailySpentSats: number, readonly weeklySpentSats: number, readonly monthlySpentSats: number, readonly annualSpentSats: number } } } }; -export type ApiKeyRemoveAnnualLimitMutationVariables = Exact<{ +export type ApiKeyRemoveLimitMutationVariables = Exact<{ input: ApiKeyRemoveLimitInput; }>; -export type ApiKeyRemoveAnnualLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveAnnualLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly spentLast24HSats: number, readonly spentLast7DSats: number, readonly spentLast30DSats: number, readonly spentLast365DSats: number } } } }; +export type ApiKeyRemoveLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly dailySpentSats: number, readonly weeklySpentSats: number, readonly monthlySpentSats: number, readonly annualSpentSats: number } } } }; export type CallbackEndpointAddMutationVariables = Exact<{ input: CallbackEndpointAddInput; @@ -2726,10 +2686,10 @@ export const ApiKeyCreateDocument = gql` weeklyLimitSats monthlyLimitSats annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats + dailySpentSats + weeklySpentSats + monthlySpentSats + annualSpentSats } } apiKeySecret @@ -2804,193 +2764,9 @@ export function useApiKeyRevokeMutation(baseOptions?: Apollo.MutationHookOptions export type ApiKeyRevokeMutationHookResult = ReturnType; export type ApiKeyRevokeMutationResult = Apollo.MutationResult; export type ApiKeyRevokeMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeySetDailyLimitDocument = gql` - mutation ApiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { - apiKeySetDailyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } -} - `; -export type ApiKeySetDailyLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeySetDailyLimitMutation__ - * - * To run a mutation, you first call `useApiKeySetDailyLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeySetDailyLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeySetDailyLimitMutation, { data, loading, error }] = useApiKeySetDailyLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeySetDailyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeySetDailyLimitDocument, options); - } -export type ApiKeySetDailyLimitMutationHookResult = ReturnType; -export type ApiKeySetDailyLimitMutationResult = Apollo.MutationResult; -export type ApiKeySetDailyLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeyRemoveDailyLimitDocument = gql` - mutation ApiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveDailyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } -} - `; -export type ApiKeyRemoveDailyLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeyRemoveDailyLimitMutation__ - * - * To run a mutation, you first call `useApiKeyRemoveDailyLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeyRemoveDailyLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeyRemoveDailyLimitMutation, { data, loading, error }] = useApiKeyRemoveDailyLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeyRemoveDailyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeyRemoveDailyLimitDocument, options); - } -export type ApiKeyRemoveDailyLimitMutationHookResult = ReturnType; -export type ApiKeyRemoveDailyLimitMutationResult = Apollo.MutationResult; -export type ApiKeyRemoveDailyLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeySetWeeklyLimitDocument = gql` - mutation ApiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { - apiKeySetWeeklyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } -} - `; -export type ApiKeySetWeeklyLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeySetWeeklyLimitMutation__ - * - * To run a mutation, you first call `useApiKeySetWeeklyLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeySetWeeklyLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeySetWeeklyLimitMutation, { data, loading, error }] = useApiKeySetWeeklyLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeySetWeeklyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeySetWeeklyLimitDocument, options); - } -export type ApiKeySetWeeklyLimitMutationHookResult = ReturnType; -export type ApiKeySetWeeklyLimitMutationResult = Apollo.MutationResult; -export type ApiKeySetWeeklyLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeyRemoveWeeklyLimitDocument = gql` - mutation ApiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveWeeklyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } -} - `; -export type ApiKeyRemoveWeeklyLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeyRemoveWeeklyLimitMutation__ - * - * To run a mutation, you first call `useApiKeyRemoveWeeklyLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeyRemoveWeeklyLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeyRemoveWeeklyLimitMutation, { data, loading, error }] = useApiKeyRemoveWeeklyLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeyRemoveWeeklyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeyRemoveWeeklyLimitDocument, options); - } -export type ApiKeyRemoveWeeklyLimitMutationHookResult = ReturnType; -export type ApiKeyRemoveWeeklyLimitMutationResult = Apollo.MutationResult; -export type ApiKeyRemoveWeeklyLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeySetMonthlyLimitDocument = gql` - mutation ApiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { - apiKeySetMonthlyLimit(input: $input) { +export const ApiKeySetLimitDocument = gql` + mutation ApiKeySetLimit($input: ApiKeySetLimitInput!) { + apiKeySetLimit(input: $input) { apiKey { id name @@ -2999,136 +2775,44 @@ export const ApiKeySetMonthlyLimitDocument = gql` weeklyLimitSats monthlyLimitSats annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } -} - `; -export type ApiKeySetMonthlyLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeySetMonthlyLimitMutation__ - * - * To run a mutation, you first call `useApiKeySetMonthlyLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeySetMonthlyLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeySetMonthlyLimitMutation, { data, loading, error }] = useApiKeySetMonthlyLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeySetMonthlyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeySetMonthlyLimitDocument, options); - } -export type ApiKeySetMonthlyLimitMutationHookResult = ReturnType; -export type ApiKeySetMonthlyLimitMutationResult = Apollo.MutationResult; -export type ApiKeySetMonthlyLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeyRemoveMonthlyLimitDocument = gql` - mutation ApiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveMonthlyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } -} - `; -export type ApiKeyRemoveMonthlyLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeyRemoveMonthlyLimitMutation__ - * - * To run a mutation, you first call `useApiKeyRemoveMonthlyLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeyRemoveMonthlyLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeyRemoveMonthlyLimitMutation, { data, loading, error }] = useApiKeyRemoveMonthlyLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeyRemoveMonthlyLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeyRemoveMonthlyLimitDocument, options); - } -export type ApiKeyRemoveMonthlyLimitMutationHookResult = ReturnType; -export type ApiKeyRemoveMonthlyLimitMutationResult = Apollo.MutationResult; -export type ApiKeyRemoveMonthlyLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeySetAnnualLimitDocument = gql` - mutation ApiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { - apiKeySetAnnualLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats + dailySpentSats + weeklySpentSats + monthlySpentSats + annualSpentSats } } } } `; -export type ApiKeySetAnnualLimitMutationFn = Apollo.MutationFunction; +export type ApiKeySetLimitMutationFn = Apollo.MutationFunction; /** - * __useApiKeySetAnnualLimitMutation__ + * __useApiKeySetLimitMutation__ * - * To run a mutation, you first call `useApiKeySetAnnualLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeySetAnnualLimitMutation` returns a tuple that includes: + * To run a mutation, you first call `useApiKeySetLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeySetLimitMutation` returns a tuple that includes: * - A mutate function that you can call at any time to execute the mutation * - An object with fields that represent the current status of the mutation's execution * * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * * @example - * const [apiKeySetAnnualLimitMutation, { data, loading, error }] = useApiKeySetAnnualLimitMutation({ + * const [apiKeySetLimitMutation, { data, loading, error }] = useApiKeySetLimitMutation({ * variables: { * input: // value for 'input' * }, * }); */ -export function useApiKeySetAnnualLimitMutation(baseOptions?: Apollo.MutationHookOptions) { +export function useApiKeySetLimitMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeySetAnnualLimitDocument, options); + return Apollo.useMutation(ApiKeySetLimitDocument, options); } -export type ApiKeySetAnnualLimitMutationHookResult = ReturnType; -export type ApiKeySetAnnualLimitMutationResult = Apollo.MutationResult; -export type ApiKeySetAnnualLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeyRemoveAnnualLimitDocument = gql` - mutation ApiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveAnnualLimit(input: $input) { +export type ApiKeySetLimitMutationHookResult = ReturnType; +export type ApiKeySetLimitMutationResult = Apollo.MutationResult; +export type ApiKeySetLimitMutationOptions = Apollo.BaseMutationOptions; +export const ApiKeyRemoveLimitDocument = gql` + mutation ApiKeyRemoveLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveLimit(input: $input) { apiKey { id name @@ -3137,41 +2821,41 @@ export const ApiKeyRemoveAnnualLimitDocument = gql` weeklyLimitSats monthlyLimitSats annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats + dailySpentSats + weeklySpentSats + monthlySpentSats + annualSpentSats } } } } `; -export type ApiKeyRemoveAnnualLimitMutationFn = Apollo.MutationFunction; +export type ApiKeyRemoveLimitMutationFn = Apollo.MutationFunction; /** - * __useApiKeyRemoveAnnualLimitMutation__ + * __useApiKeyRemoveLimitMutation__ * - * To run a mutation, you first call `useApiKeyRemoveAnnualLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeyRemoveAnnualLimitMutation` returns a tuple that includes: + * To run a mutation, you first call `useApiKeyRemoveLimitMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useApiKeyRemoveLimitMutation` returns a tuple that includes: * - A mutate function that you can call at any time to execute the mutation * - An object with fields that represent the current status of the mutation's execution * * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; * * @example - * const [apiKeyRemoveAnnualLimitMutation, { data, loading, error }] = useApiKeyRemoveAnnualLimitMutation({ + * const [apiKeyRemoveLimitMutation, { data, loading, error }] = useApiKeyRemoveLimitMutation({ * variables: { * input: // value for 'input' * }, * }); */ -export function useApiKeyRemoveAnnualLimitMutation(baseOptions?: Apollo.MutationHookOptions) { +export function useApiKeyRemoveLimitMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeyRemoveAnnualLimitDocument, options); + return Apollo.useMutation(ApiKeyRemoveLimitDocument, options); } -export type ApiKeyRemoveAnnualLimitMutationHookResult = ReturnType; -export type ApiKeyRemoveAnnualLimitMutationResult = Apollo.MutationResult; -export type ApiKeyRemoveAnnualLimitMutationOptions = Apollo.BaseMutationOptions; +export type ApiKeyRemoveLimitMutationHookResult = ReturnType; +export type ApiKeyRemoveLimitMutationResult = Apollo.MutationResult; +export type ApiKeyRemoveLimitMutationOptions = Apollo.BaseMutationOptions; export const CallbackEndpointAddDocument = gql` mutation CallbackEndpointAdd($input: CallbackEndpointAddInput!) { callbackEndpointAdd(input: $input) { @@ -5268,10 +4952,12 @@ export interface OneTimeAuthCodeScalarConfig extends GraphQLScalarTypeConfig = { deepLink?: Resolver; + label?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; export type OpenExternalLinkActionResolvers = { + label?: Resolver, ParentType, ContextType>; url?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/apps/dashboard/services/graphql/mutations/api-keys.ts b/apps/dashboard/services/graphql/mutations/api-keys.ts index b5714ba196..6326c85b6e 100644 --- a/apps/dashboard/services/graphql/mutations/api-keys.ts +++ b/apps/dashboard/services/graphql/mutations/api-keys.ts @@ -6,22 +6,11 @@ import { ApiKeyCreateMutation, ApiKeyRevokeDocument, ApiKeyRevokeMutation, - ApiKeySetDailyLimitDocument, - ApiKeySetDailyLimitMutation, - ApiKeySetWeeklyLimitDocument, - ApiKeySetWeeklyLimitMutation, - ApiKeySetMonthlyLimitDocument, - ApiKeySetMonthlyLimitMutation, - ApiKeySetAnnualLimitDocument, - ApiKeySetAnnualLimitMutation, - ApiKeyRemoveDailyLimitDocument, - ApiKeyRemoveDailyLimitMutation, - ApiKeyRemoveWeeklyLimitDocument, - ApiKeyRemoveWeeklyLimitMutation, - ApiKeyRemoveMonthlyLimitDocument, - ApiKeyRemoveMonthlyLimitMutation, - ApiKeyRemoveAnnualLimitDocument, - ApiKeyRemoveAnnualLimitMutation, + ApiKeySetLimitDocument, + ApiKeySetLimitMutation, + ApiKeyRemoveLimitDocument, + ApiKeyRemoveLimitMutation, + LimitTimeWindow, Scope, } from "../generated" @@ -42,10 +31,10 @@ gql` weeklyLimitSats monthlyLimitSats annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats + dailySpentSats + weeklySpentSats + monthlySpentSats + annualSpentSats } } apiKeySecret @@ -67,8 +56,8 @@ gql` } } - mutation ApiKeySetDailyLimit($input: ApiKeySetDailyLimitInput!) { - apiKeySetDailyLimit(input: $input) { + mutation ApiKeySetLimit($input: ApiKeySetLimitInput!) { + apiKeySetLimit(input: $input) { apiKey { id name @@ -77,17 +66,17 @@ gql` weeklyLimitSats monthlyLimitSats annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats + dailySpentSats + weeklySpentSats + monthlySpentSats + annualSpentSats } } } } - mutation ApiKeyRemoveDailyLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveDailyLimit(input: $input) { + mutation ApiKeyRemoveLimit($input: ApiKeyRemoveLimitInput!) { + apiKeyRemoveLimit(input: $input) { apiKey { id name @@ -96,124 +85,10 @@ gql` weeklyLimitSats monthlyLimitSats annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } - } - - mutation ApiKeySetWeeklyLimit($input: ApiKeySetWeeklyLimitInput!) { - apiKeySetWeeklyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } - } - - mutation ApiKeyRemoveWeeklyLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveWeeklyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } - } - - mutation ApiKeySetMonthlyLimit($input: ApiKeySetMonthlyLimitInput!) { - apiKeySetMonthlyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } - } - - mutation ApiKeyRemoveMonthlyLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveMonthlyLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } - } - - mutation ApiKeySetAnnualLimit($input: ApiKeySetAnnualLimitInput!) { - apiKeySetAnnualLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats - } - } - } - } - - mutation ApiKeyRemoveAnnualLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveAnnualLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - spentLast24HSats - spentLast7DSats - spentLast30DSats - spentLast365DSats + dailySpentSats + weeklySpentSats + monthlySpentSats + annualSpentSats } } } @@ -256,138 +131,44 @@ export async function revokeApiKey({ id }: { id: string }) { } } -export async function setApiKeyDailyLimit({ +export async function setApiKeyLimit({ id, - dailyLimitSats, + limitTimeWindow, + limitSats, }: { id: string - dailyLimitSats: number + limitTimeWindow: LimitTimeWindow + limitSats: number }) { const client = await apolloClient.authenticated() try { - const { data } = await client.mutate({ - mutation: ApiKeySetDailyLimitDocument, - variables: { input: { id, dailyLimitSats } }, + const { data } = await client.mutate({ + mutation: ApiKeySetLimitDocument, + variables: { input: { id, limitTimeWindow, limitSats } }, }) return data } catch (error) { - console.error("Error executing mutation: apiKeySetDailyLimit ==> ", error) - throw new Error("Error in apiKeySetDailyLimit") + console.error("Error executing mutation: apiKeySetLimit ==> ", error) + throw new Error("Error in apiKeySetLimit") } } -export async function setApiKeyWeeklyLimit({ +export async function removeApiKeyLimit({ id, - weeklyLimitSats, + limitTimeWindow, }: { id: string - weeklyLimitSats: number + limitTimeWindow: LimitTimeWindow }) { const client = await apolloClient.authenticated() try { - const { data } = await client.mutate({ - mutation: ApiKeySetWeeklyLimitDocument, - variables: { input: { id, weeklyLimitSats } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeySetWeeklyLimit ==> ", error) - throw new Error("Error in apiKeySetWeeklyLimit") - } -} - -export async function setApiKeyMonthlyLimit({ - id, - monthlyLimitSats, -}: { - id: string - monthlyLimitSats: number -}) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeySetMonthlyLimitDocument, - variables: { input: { id, monthlyLimitSats } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeySetMonthlyLimit ==> ", error) - throw new Error("Error in apiKeySetMonthlyLimit") - } -} - -export async function setApiKeyAnnualLimit({ - id, - annualLimitSats, -}: { - id: string - annualLimitSats: number -}) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeySetAnnualLimitDocument, - variables: { input: { id, annualLimitSats } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeySetAnnualLimit ==> ", error) - throw new Error("Error in apiKeySetAnnualLimit") - } -} - -export async function removeApiKeyLimit({ id }: { id: string }) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeyRemoveDailyLimitDocument, - variables: { input: { id } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeyRemoveDailyLimit ==> ", error) - throw new Error("Error in apiKeyRemoveDailyLimit") - } -} - -export async function removeApiKeyWeeklyLimit({ id }: { id: string }) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeyRemoveWeeklyLimitDocument, - variables: { input: { id } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeyRemoveWeeklyLimit ==> ", error) - throw new Error("Error in apiKeyRemoveWeeklyLimit") - } -} - -export async function removeApiKeyMonthlyLimit({ id }: { id: string }) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeyRemoveMonthlyLimitDocument, - variables: { input: { id } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeyRemoveMonthlyLimit ==> ", error) - throw new Error("Error in apiKeyRemoveMonthlyLimit") - } -} - -export async function removeApiKeyAnnualLimit({ id }: { id: string }) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeyRemoveAnnualLimitDocument, - variables: { input: { id } }, + const { data } = await client.mutate({ + mutation: ApiKeyRemoveLimitDocument, + variables: { input: { id, limitTimeWindow } }, }) return data } catch (error) { - console.error("Error executing mutation: apiKeyRemoveAnnualLimit ==> ", error) - throw new Error("Error in apiKeyRemoveAnnualLimit") + console.error("Error executing mutation: apiKeyRemoveLimit ==> ", error) + throw new Error("Error in apiKeyRemoveLimit") } } diff --git a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats index e6d01c7e16..9255a25c55 100644 --- a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats +++ b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats @@ -56,13 +56,13 @@ setup_file() { cache_value "hold-invoice-api-key-id" "$key_id" # Set daily limit to 10000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" - daily_limit="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.dailyLimitSats')" + daily_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.dailyLimitSats')" [[ "${daily_limit}" = "10000" ]] || exit 1 - spent_24h="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.dailySpentSats')" [[ "${spent_24h}" = "0" ]] || exit 1 } @@ -96,7 +96,7 @@ setup_file() { # Check that spending was recorded exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.dailySpentSats')" # Should have recorded 5000 sats spending [[ "${spent_24h}" -ge "5000" ]] || exit 1 @@ -139,7 +139,7 @@ setup_file() { # Check that spending was reversed exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.dailySpentSats')" # Spending should be back to 0 after reversal [[ "${spent_24h}" = "0" ]] || exit 1 diff --git a/bats/core/api-keys/api-keys-limits.bats b/bats/core/api-keys/api-keys-limits.bats index 87c32c873e..02f8c70b38 100644 --- a/bats/core/api-keys/api-keys-limits.bats +++ b/bats/core/api-keys/api-keys-limits.bats @@ -59,13 +59,13 @@ setup_file() { cache_value "limit-api-key-id" "$key_id" # Set daily limit to 10000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" - daily_limit="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.dailyLimitSats')" + daily_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.dailyLimitSats')" [[ "${daily_limit}" = "10000" ]] || exit 1 - spent_24h="$(graphql_output '.data.apiKeySetDailyLimit.apiKey.limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.dailySpentSats')" [[ "${spent_24h}" = "0" ]] || exit 1 } @@ -88,7 +88,7 @@ setup_file() { # Check spending was recorded exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "$amount" ]] || exit 1 } @@ -123,20 +123,20 @@ setup_file() { key_id=$(read_value "limit-api-key-id") # Set weekly limit to 50000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"weeklyLimitSats\":50000}}" - exec_graphql 'alice' 'api-key-set-weekly-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"WEEKLY\",\"limitSats\":50000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" - weekly_limit="$(graphql_output '.data.apiKeySetWeeklyLimit.apiKey.limits.weeklyLimitSats')" + weekly_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.weeklyLimitSats')" [[ "${weekly_limit}" = "50000" ]] || exit 1 } @test "api-keys-limits: remove daily limit" { key_id=$(read_value "limit-api-key-id") - variables="{\"input\":{\"id\":\"${key_id}\"}}" - exec_graphql 'alice' 'api-key-remove-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\"}}" + exec_graphql 'alice' 'api-key-remove-limit' "$variables" - daily_limit="$(graphql_output '.data.apiKeyRemoveDailyLimit.apiKey.limits.dailyLimitSats')" + daily_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.dailyLimitSats')" [[ "${daily_limit}" = "null" ]] || exit 1 } @@ -159,7 +159,7 @@ setup_file() { # Check total spending across all time periods exec_graphql 'alice' 'api-keys' - spent_7d="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast7DSats')" + spent_7d="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.weeklySpentSats')" # Should have accumulated spending from previous tests [[ "${spent_7d}" -ge "8000" ]] || exit 1 @@ -169,21 +169,21 @@ setup_file() { key_id=$(read_value "limit-api-key-id") # Set monthly limit to 100000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"monthlyLimitSats\":100000}}" - exec_graphql 'alice' 'api-key-set-monthly-limit' "$variables" - monthly_limit="$(graphql_output '.data.apiKeySetMonthlyLimit.apiKey.limits.monthlyLimitSats')" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"MONTHLY\",\"limitSats\":100000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" + monthly_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.monthlyLimitSats')" [[ "${monthly_limit}" = "100000" ]] || exit 1 - spent_30d="$(graphql_output '.data.apiKeySetMonthlyLimit.apiKey.limits.spentLast30DSats')" + spent_30d="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.monthlySpentSats')" [[ "${spent_30d}" -ge "8000" ]] || exit 1 # Set annual limit to 500000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"annualLimitSats\":500000}}" - exec_graphql 'alice' 'api-key-set-annual-limit' "$variables" - annual_limit="$(graphql_output '.data.apiKeySetAnnualLimit.apiKey.limits.annualLimitSats')" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"ANNUAL\",\"limitSats\":500000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" + annual_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.annualLimitSats')" [[ "${annual_limit}" = "500000" ]] || exit 1 - spent_365d="$(graphql_output '.data.apiKeySetAnnualLimit.apiKey.limits.spentLast365DSats')" + spent_365d="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.annualSpentSats')" [[ "${spent_365d}" -ge "8000" ]] || exit 1 } @@ -240,10 +240,10 @@ setup_file() { exec_graphql 'alice' 'api-keys' key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" - spent_24h=$(echo "$key_data" | jq -r '.limits.spentLast24HSats') - spent_7d=$(echo "$key_data" | jq -r '.limits.spentLast7DSats') - spent_30d=$(echo "$key_data" | jq -r '.limits.spentLast30DSats') - spent_365d=$(echo "$key_data" | jq -r '.limits.spentLast365DSats') + spent_24h=$(echo "$key_data" | jq -r '.limits.dailySpentSats') + spent_7d=$(echo "$key_data" | jq -r '.limits.weeklySpentSats') + spent_30d=$(echo "$key_data" | jq -r '.limits.monthlySpentSats') + spent_365d=$(echo "$key_data" | jq -r '.limits.annualSpentSats') [[ "${spent_24h}" -ge "30000" ]] || exit 1 [[ "${spent_7d}" -ge "38000" ]] || exit 1 @@ -267,10 +267,10 @@ setup_file() { [[ "${annual_limit}" = "500000" ]] || exit 1 # Verify spending is consistent across all time windows (since all payments are within last 24h) - spent_24h=$(echo "$key_data" | jq -r '.limits.spentLast24HSats') - spent_7d=$(echo "$key_data" | jq -r '.limits.spentLast7DSats') - spent_30d=$(echo "$key_data" | jq -r '.limits.spentLast30DSats') - spent_365d=$(echo "$key_data" | jq -r '.limits.spentLast365DSats') + spent_24h=$(echo "$key_data" | jq -r '.limits.dailySpentSats') + spent_7d=$(echo "$key_data" | jq -r '.limits.weeklySpentSats') + spent_30d=$(echo "$key_data" | jq -r '.limits.monthlySpentSats') + spent_365d=$(echo "$key_data" | jq -r '.limits.annualSpentSats') [[ "${spent_24h}" = "${spent_7d}" ]] || exit 1 [[ "${spent_7d}" = "${spent_30d}" ]] || exit 1 @@ -281,9 +281,9 @@ setup_file() { key_id=$(read_value "limit-api-key-id") # Update weekly limit to 40000 (already spent ~38000) - variables="{\"input\":{\"id\":\"${key_id}\",\"weeklyLimitSats\":40000}}" - exec_graphql 'alice' 'api-key-set-weekly-limit' "$variables" - weekly_limit="$(graphql_output '.data.apiKeySetWeeklyLimit.apiKey.limits.weeklyLimitSats')" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"WEEKLY\",\"limitSats\":40000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" + weekly_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.weeklyLimitSats')" [[ "${weekly_limit}" = "40000" ]] || exit 1 # Try to send 3000 - should fail as it would exceed updated limit @@ -312,19 +312,21 @@ setup_file() { key_id=$(read_value "limit-api-key-id") # Remove weekly limit - variables="{\"input\":{\"id\":\"${key_id}\"}}" - exec_graphql 'alice' 'api-key-remove-weekly-limit' "$variables" - weekly_limit="$(graphql_output '.data.apiKeyRemoveWeeklyLimit.apiKey.limits.weeklyLimitSats')" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"WEEKLY\"}}" + exec_graphql 'alice' 'api-key-remove-limit' "$variables" + weekly_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.weeklyLimitSats')" [[ "${weekly_limit}" = "null" ]] || exit 1 # Remove monthly limit - exec_graphql 'alice' 'api-key-remove-monthly-limit' "$variables" - monthly_limit="$(graphql_output '.data.apiKeyRemoveMonthlyLimit.apiKey.limits.monthlyLimitSats')" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"MONTHLY\"}}" + exec_graphql 'alice' 'api-key-remove-limit' "$variables" + monthly_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.monthlyLimitSats')" [[ "${monthly_limit}" = "null" ]] || exit 1 # Remove annual limit - exec_graphql 'alice' 'api-key-remove-annual-limit' "$variables" - annual_limit="$(graphql_output '.data.apiKeyRemoveAnnualLimit.apiKey.limits.annualLimitSats')" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"ANNUAL\"}}" + exec_graphql 'alice' 'api-key-remove-limit' "$variables" + annual_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.annualLimitSats')" [[ "${annual_limit}" = "null" ]] || exit 1 } @@ -348,7 +350,7 @@ setup_file() { # Spending should still be tracked even without limits exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "130000" ]] || exit 1 } @@ -372,8 +374,8 @@ setup_file() { cache_value "ln-api-key-id" "$key_id" # Set daily limit to 5000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":5000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":5000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" # Create invoice for 3000 sats invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" @@ -394,7 +396,7 @@ setup_file() { # Verify spending was recorded exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "3000" ]] || exit 1 } @@ -435,8 +437,8 @@ setup_file() { cache_value "onchain-api-key-id" "$key_id" # Set daily limit to 10000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":10000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":10000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" # Create onchain address onchain_address=$(bitcoin_cli getnewaddress) @@ -456,7 +458,7 @@ setup_file() { # Verify spending was recorded exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "5000" ]] || exit 1 } @@ -486,16 +488,16 @@ setup_file() { # Check intraledger key spending (original key from earlier tests) exec_graphql 'alice' 'api-keys' - intraledger_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.spentLast24HSats')" + intraledger_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.dailySpentSats')" [[ "${intraledger_spent}" -ge "130000" ]] || exit 1 # Check lightning key spending (separate key) - ln_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.spentLast24HSats')" + ln_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.dailySpentSats')" [[ "${ln_spent}" -ge "3000" ]] || exit 1 [[ "${ln_spent}" -lt "10000" ]] || exit 1 # Check onchain key spending (separate key) - onchain_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.spentLast24HSats')" + onchain_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.dailySpentSats')" [[ "${onchain_spent}" -ge "5000" ]] || exit 1 [[ "${onchain_spent}" -lt "10000" ]] || exit 1 @@ -518,8 +520,8 @@ setup_file() { cache_value "api-key-usd-secret" "$secret" # Set daily limit to 50000 sats (in satoshi equivalent) - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":50000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":50000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" # Send USD intraledger payment (amount in cents) variables=$( @@ -536,7 +538,7 @@ setup_file() { # Verify spending was recorded (converted to sats) exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$key_name'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$key_name'") | .limits.dailySpentSats')" # USD amount converted to sats should be tracked [[ "${spent_24h}" -gt "0" ]] || exit 1 } @@ -557,8 +559,8 @@ setup_file() { cache_value "ln-noamount-api-key-id" "$key_id" # Set daily limit to 8000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":8000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":8000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" # Create no-amount invoice invoice_response="$(lnd_outside_cli addinvoice)" @@ -579,7 +581,7 @@ setup_file() { # Verify spending was recorded exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "4000" ]] || exit 1 } @@ -620,8 +622,8 @@ setup_file() { cache_value "api-key-ln-noamount-usd-secret" "$secret" # Set daily limit to 8000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":8000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":8000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" # Create no-amount invoice invoice_response="$(lnd_outside_cli addinvoice)" @@ -642,7 +644,7 @@ setup_file() { # Verify spending was recorded (converted to sats) exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_usd_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_usd_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -gt "0" ]] || exit 1 } @@ -661,8 +663,8 @@ setup_file() { cache_value "api-key-lnurl-secret" "$secret" # Set daily limit to 5000 sats - variables="{\"input\":{\"id\":\"${key_id}\",\"dailyLimitSats\":5000}}" - exec_graphql 'alice' 'api-key-set-daily-limit' "$variables" + variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":5000}}" + exec_graphql 'alice' 'api-key-set-limit' "$variables" # Send payment via lnurl (to xyz_zap_receiver) lnurl="lnurl1dp68gup69uhkcmmrv9kxsmmnwsarxvpsxghjuam9d3kz66mwdamkutmvde6hymrs9au8j7jl0fshqhmjv43k26tkv4eq5ndl2y" @@ -681,7 +683,7 @@ setup_file() { # Verify spending was recorded exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'lnurl_key_name')'") | .limits.spentLast24HSats')" + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'lnurl_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "2000" ]] || exit 1 } diff --git a/core/api/src/domain/api-keys/spending-limits.ts b/core/api/src/domain/api-keys/spending-limits.ts index 75112fc680..7c4ab3661a 100644 --- a/core/api/src/domain/api-keys/spending-limits.ts +++ b/core/api/src/domain/api-keys/spending-limits.ts @@ -5,10 +5,10 @@ export type SpendingLimits = { weeklyLimitSats: number | null monthlyLimitSats: number | null annualLimitSats: number | null - spentLast24hSats: number - spentLast7dSats: number - spentLast30dSats: number - spentLast365dSats: number + dailySpentSats: number + weeklySpentSats: number + monthlySpentSats: number + annualSpentSats: number } type ValidationResult = @@ -32,21 +32,21 @@ export const validateSpendingLimit = ({ weeklyLimitSats, monthlyLimitSats, annualLimitSats, - spentLast24hSats, - spentLast7dSats, - spentLast30dSats, - spentLast365dSats, + dailySpentSats, + weeklySpentSats, + monthlySpentSats, + annualSpentSats, } = limits // Calculate remaining amounts const remainingDailySats = - dailyLimitSats !== null ? dailyLimitSats - spentLast24hSats : null + dailyLimitSats !== null ? dailyLimitSats - dailySpentSats : null const remainingWeeklySats = - weeklyLimitSats !== null ? weeklyLimitSats - spentLast7dSats : null + weeklyLimitSats !== null ? weeklyLimitSats - weeklySpentSats : null const remainingMonthlySats = - monthlyLimitSats !== null ? monthlyLimitSats - spentLast30dSats : null + monthlyLimitSats !== null ? monthlyLimitSats - monthlySpentSats : null const remainingAnnualSats = - annualLimitSats !== null ? annualLimitSats - spentLast365dSats : null + annualLimitSats !== null ? annualLimitSats - annualSpentSats : null if ( dailyLimitSats !== null && diff --git a/core/api/src/services/api-keys/convert.ts b/core/api/src/services/api-keys/convert.ts index 562c6fde8d..5dcdf2162c 100644 --- a/core/api/src/services/api-keys/convert.ts +++ b/core/api/src/services/api-keys/convert.ts @@ -17,8 +17,8 @@ export const grpcSpendingLimitsToSpendingLimits = ( annualLimitSats: response.hasAnnualLimitSats() ? (response.getAnnualLimitSats() ?? null) : null, - spentLast24hSats: response.getSpentLast24hSats(), - spentLast7dSats: response.getSpentLast7dSats(), - spentLast30dSats: response.getSpentLast30dSats(), - spentLast365dSats: response.getSpentLast365dSats(), + dailySpentSats: response.getDailySpentSats(), + weeklySpentSats: response.getWeeklySpentSats(), + monthlySpentSats: response.getMonthlySpentSats(), + annualSpentSats: response.getAnnualSpentSats(), }) diff --git a/core/api/src/services/api-keys/proto/api_keys.proto b/core/api/src/services/api-keys/proto/api_keys.proto index a7b455e472..6878dc496a 100644 --- a/core/api/src/services/api-keys/proto/api_keys.proto +++ b/core/api/src/services/api-keys/proto/api_keys.proto @@ -20,10 +20,10 @@ message CheckSpendingLimitResponse { optional int64 weekly_limit_sats = 3; optional int64 monthly_limit_sats = 4; optional int64 annual_limit_sats = 5; - int64 spent_last_24h_sats = 6; - int64 spent_last_7d_sats = 7; - int64 spent_last_30d_sats = 8; - int64 spent_last_365d_sats = 9; + int64 daily_spent_sats = 6; + int64 weekly_spent_sats = 7; + int64 monthly_spent_sats = 8; + int64 annual_spent_sats = 9; optional int64 remaining_daily_sats = 10; optional int64 remaining_weekly_sats = 11; optional int64 remaining_monthly_sats = 12; @@ -39,10 +39,10 @@ message GetSpendingSummaryResponse { optional int64 weekly_limit_sats = 2; optional int64 monthly_limit_sats = 3; optional int64 annual_limit_sats = 4; - int64 spent_last_24h_sats = 5; - int64 spent_last_7d_sats = 6; - int64 spent_last_30d_sats = 7; - int64 spent_last_365d_sats = 8; + int64 daily_spent_sats = 5; + int64 weekly_spent_sats = 6; + int64 monthly_spent_sats = 7; + int64 annual_spent_sats = 8; optional int64 remaining_daily_sats = 9; optional int64 remaining_weekly_sats = 10; optional int64 remaining_monthly_sats = 11; diff --git a/core/api/src/services/api-keys/proto/api_keys_pb.d.ts b/core/api/src/services/api-keys/proto/api_keys_pb.d.ts index 86d98212eb..892a5dbaab 100644 --- a/core/api/src/services/api-keys/proto/api_keys_pb.d.ts +++ b/core/api/src/services/api-keys/proto/api_keys_pb.d.ts @@ -52,14 +52,14 @@ export class CheckSpendingLimitResponse extends jspb.Message { clearAnnualLimitSats(): void; getAnnualLimitSats(): number | undefined; setAnnualLimitSats(value: number): CheckSpendingLimitResponse; - getSpentLast24hSats(): number; - setSpentLast24hSats(value: number): CheckSpendingLimitResponse; - getSpentLast7dSats(): number; - setSpentLast7dSats(value: number): CheckSpendingLimitResponse; - getSpentLast30dSats(): number; - setSpentLast30dSats(value: number): CheckSpendingLimitResponse; - getSpentLast365dSats(): number; - setSpentLast365dSats(value: number): CheckSpendingLimitResponse; + getDailySpentSats(): number; + setDailySpentSats(value: number): CheckSpendingLimitResponse; + getWeeklySpentSats(): number; + setWeeklySpentSats(value: number): CheckSpendingLimitResponse; + getMonthlySpentSats(): number; + setMonthlySpentSats(value: number): CheckSpendingLimitResponse; + getAnnualSpentSats(): number; + setAnnualSpentSats(value: number): CheckSpendingLimitResponse; hasRemainingDailySats(): boolean; clearRemainingDailySats(): void; @@ -98,10 +98,10 @@ export namespace CheckSpendingLimitResponse { weeklyLimitSats?: number, monthlyLimitSats?: number, annualLimitSats?: number, - spentLast24hSats: number, - spentLast7dSats: number, - spentLast30dSats: number, - spentLast365dSats: number, + dailySpentSats: number, + weeklySpentSats: number, + monthlySpentSats: number, + annualSpentSats: number, remainingDailySats?: number, remainingWeeklySats?: number, remainingMonthlySats?: number, @@ -150,14 +150,14 @@ export class GetSpendingSummaryResponse extends jspb.Message { clearAnnualLimitSats(): void; getAnnualLimitSats(): number | undefined; setAnnualLimitSats(value: number): GetSpendingSummaryResponse; - getSpentLast24hSats(): number; - setSpentLast24hSats(value: number): GetSpendingSummaryResponse; - getSpentLast7dSats(): number; - setSpentLast7dSats(value: number): GetSpendingSummaryResponse; - getSpentLast30dSats(): number; - setSpentLast30dSats(value: number): GetSpendingSummaryResponse; - getSpentLast365dSats(): number; - setSpentLast365dSats(value: number): GetSpendingSummaryResponse; + getDailySpentSats(): number; + setDailySpentSats(value: number): GetSpendingSummaryResponse; + getWeeklySpentSats(): number; + setWeeklySpentSats(value: number): GetSpendingSummaryResponse; + getMonthlySpentSats(): number; + setMonthlySpentSats(value: number): GetSpendingSummaryResponse; + getAnnualSpentSats(): number; + setAnnualSpentSats(value: number): GetSpendingSummaryResponse; hasRemainingDailySats(): boolean; clearRemainingDailySats(): void; @@ -195,10 +195,10 @@ export namespace GetSpendingSummaryResponse { weeklyLimitSats?: number, monthlyLimitSats?: number, annualLimitSats?: number, - spentLast24hSats: number, - spentLast7dSats: number, - spentLast30dSats: number, - spentLast365dSats: number, + dailySpentSats: number, + weeklySpentSats: number, + monthlySpentSats: number, + annualSpentSats: number, remainingDailySats?: number, remainingWeeklySats?: number, remainingMonthlySats?: number, diff --git a/core/api/src/services/api-keys/proto/api_keys_pb.js b/core/api/src/services/api-keys/proto/api_keys_pb.js index 5894b7ee41..9222de34d0 100644 --- a/core/api/src/services/api-keys/proto/api_keys_pb.js +++ b/core/api/src/services/api-keys/proto/api_keys_pb.js @@ -394,10 +394,10 @@ dailyLimitSats: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f, weeklyLimitSats: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f, monthlyLimitSats: (f = jspb.Message.getField(msg, 4)) == null ? undefined : f, annualLimitSats: (f = jspb.Message.getField(msg, 5)) == null ? undefined : f, -spentLast24hSats: jspb.Message.getFieldWithDefault(msg, 6, 0), -spentLast7dSats: jspb.Message.getFieldWithDefault(msg, 7, 0), -spentLast30dSats: jspb.Message.getFieldWithDefault(msg, 8, 0), -spentLast365dSats: jspb.Message.getFieldWithDefault(msg, 9, 0), +dailySpentSats: jspb.Message.getFieldWithDefault(msg, 6, 0), +weeklySpentSats: jspb.Message.getFieldWithDefault(msg, 7, 0), +monthlySpentSats: jspb.Message.getFieldWithDefault(msg, 8, 0), +annualSpentSats: jspb.Message.getFieldWithDefault(msg, 9, 0), remainingDailySats: (f = jspb.Message.getField(msg, 10)) == null ? undefined : f, remainingWeeklySats: (f = jspb.Message.getField(msg, 11)) == null ? undefined : f, remainingMonthlySats: (f = jspb.Message.getField(msg, 12)) == null ? undefined : f, @@ -460,19 +460,19 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.deserializeBinaryFromReade break; case 6: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast24hSats(value); + msg.setDailySpentSats(value); break; case 7: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast7dSats(value); + msg.setWeeklySpentSats(value); break; case 8: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast30dSats(value); + msg.setMonthlySpentSats(value); break; case 9: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast365dSats(value); + msg.setAnnualSpentSats(value); break; case 10: var value = /** @type {number} */ (reader.readInt64()); @@ -554,28 +554,28 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.serializeBinaryToWriter = f ); } - f = message.getSpentLast24hSats(); + f = message.getDailySpentSats(); if (f !== 0) { writer.writeInt64( 6, f ); } - f = message.getSpentLast7dSats(); + f = message.getWeeklySpentSats(); if (f !== 0) { writer.writeInt64( 7, f ); } - f = message.getSpentLast30dSats(); + f = message.getMonthlySpentSats(); if (f !== 0) { writer.writeInt64( 8, f ); } - f = message.getSpentLast365dSats(); + f = message.getAnnualSpentSats(); if (f !== 0) { writer.writeInt64( 9, @@ -776,10 +776,10 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasAnnualLimitSa /** - * optional int64 spent_last_24h_sats = 6; + * optional int64 daily_spent_sats = 6; * @return {number} */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast24hSats = function() { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getDailySpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 6, 0)); }; @@ -788,16 +788,16 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast24hS * @param {number} value * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast24hSats = function(value) { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setDailySpentSats = function(value) { return jspb.Message.setProto3IntField(this, 6, value); }; /** - * optional int64 spent_last_7d_sats = 7; + * optional int64 weekly_spent_sats = 7; * @return {number} */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast7dSats = function() { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getWeeklySpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 7, 0)); }; @@ -806,16 +806,16 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast7dSa * @param {number} value * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast7dSats = function(value) { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setWeeklySpentSats = function(value) { return jspb.Message.setProto3IntField(this, 7, value); }; /** - * optional int64 spent_last_30d_sats = 8; + * optional int64 monthly_spent_sats = 8; * @return {number} */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast30dSats = function() { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getMonthlySpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 8, 0)); }; @@ -824,16 +824,16 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast30dS * @param {number} value * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast30dSats = function(value) { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setMonthlySpentSats = function(value) { return jspb.Message.setProto3IntField(this, 8, value); }; /** - * optional int64 spent_last_365d_sats = 9; + * optional int64 annual_spent_sats = 9; * @return {number} */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast365dSats = function() { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getAnnualSpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 9, 0)); }; @@ -842,7 +842,7 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.getSpentLast365d * @param {number} value * @return {!proto.services.api_keys.v1.CheckSpendingLimitResponse} returns this */ -proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setSpentLast365dSats = function(value) { +proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.setAnnualSpentSats = function(value) { return jspb.Message.setProto3IntField(this, 9, value); }; @@ -1157,10 +1157,10 @@ dailyLimitSats: (f = jspb.Message.getField(msg, 1)) == null ? undefined : f, weeklyLimitSats: (f = jspb.Message.getField(msg, 2)) == null ? undefined : f, monthlyLimitSats: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f, annualLimitSats: (f = jspb.Message.getField(msg, 4)) == null ? undefined : f, -spentLast24hSats: jspb.Message.getFieldWithDefault(msg, 5, 0), -spentLast7dSats: jspb.Message.getFieldWithDefault(msg, 6, 0), -spentLast30dSats: jspb.Message.getFieldWithDefault(msg, 7, 0), -spentLast365dSats: jspb.Message.getFieldWithDefault(msg, 8, 0), +dailySpentSats: jspb.Message.getFieldWithDefault(msg, 5, 0), +weeklySpentSats: jspb.Message.getFieldWithDefault(msg, 6, 0), +monthlySpentSats: jspb.Message.getFieldWithDefault(msg, 7, 0), +annualSpentSats: jspb.Message.getFieldWithDefault(msg, 8, 0), remainingDailySats: (f = jspb.Message.getField(msg, 9)) == null ? undefined : f, remainingWeeklySats: (f = jspb.Message.getField(msg, 10)) == null ? undefined : f, remainingMonthlySats: (f = jspb.Message.getField(msg, 11)) == null ? undefined : f, @@ -1219,19 +1219,19 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.deserializeBinaryFromReade break; case 5: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast24hSats(value); + msg.setDailySpentSats(value); break; case 6: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast7dSats(value); + msg.setWeeklySpentSats(value); break; case 7: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast30dSats(value); + msg.setMonthlySpentSats(value); break; case 8: var value = /** @type {number} */ (reader.readInt64()); - msg.setSpentLast365dSats(value); + msg.setAnnualSpentSats(value); break; case 9: var value = /** @type {number} */ (reader.readInt64()); @@ -1306,28 +1306,28 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.serializeBinaryToWriter = f ); } - f = message.getSpentLast24hSats(); + f = message.getDailySpentSats(); if (f !== 0) { writer.writeInt64( 5, f ); } - f = message.getSpentLast7dSats(); + f = message.getWeeklySpentSats(); if (f !== 0) { writer.writeInt64( 6, f ); } - f = message.getSpentLast30dSats(); + f = message.getMonthlySpentSats(); if (f !== 0) { writer.writeInt64( 7, f ); } - f = message.getSpentLast365dSats(); + f = message.getAnnualSpentSats(); if (f !== 0) { writer.writeInt64( 8, @@ -1510,10 +1510,10 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.hasAnnualLimitSa /** - * optional int64 spent_last_24h_sats = 5; + * optional int64 daily_spent_sats = 5; * @return {number} */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast24hSats = function() { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getDailySpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 5, 0)); }; @@ -1522,16 +1522,16 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast24hS * @param {number} value * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast24hSats = function(value) { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setDailySpentSats = function(value) { return jspb.Message.setProto3IntField(this, 5, value); }; /** - * optional int64 spent_last_7d_sats = 6; + * optional int64 weekly_spent_sats = 6; * @return {number} */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast7dSats = function() { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getWeeklySpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 6, 0)); }; @@ -1540,16 +1540,16 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast7dSa * @param {number} value * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast7dSats = function(value) { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setWeeklySpentSats = function(value) { return jspb.Message.setProto3IntField(this, 6, value); }; /** - * optional int64 spent_last_30d_sats = 7; + * optional int64 monthly_spent_sats = 7; * @return {number} */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast30dSats = function() { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getMonthlySpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 7, 0)); }; @@ -1558,16 +1558,16 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast30dS * @param {number} value * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast30dSats = function(value) { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setMonthlySpentSats = function(value) { return jspb.Message.setProto3IntField(this, 7, value); }; /** - * optional int64 spent_last_365d_sats = 8; + * optional int64 annual_spent_sats = 8; * @return {number} */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast365dSats = function() { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getAnnualSpentSats = function() { return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 8, 0)); }; @@ -1576,7 +1576,7 @@ proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.getSpentLast365d * @param {number} value * @return {!proto.services.api_keys.v1.GetSpendingSummaryResponse} returns this */ -proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setSpentLast365dSats = function(value) { +proto.services.api_keys.v1.GetSpendingSummaryResponse.prototype.setAnnualSpentSats = function(value) { return jspb.Message.setProto3IntField(this, 8, value); }; From c6ae38a45c2bfeabd4d688efe55aebf4619d43cd Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:22:38 +0300 Subject: [PATCH 04/33] refactor: introduce ApiKeysServiceError base class and fix error levels --- core/api/src/domain/api-keys/errors.ts | 16 ++++++++-------- core/api/src/domain/api-keys/index.types.d.ts | 7 +------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/core/api/src/domain/api-keys/errors.ts b/core/api/src/domain/api-keys/errors.ts index f8754662fc..c5135b7c57 100644 --- a/core/api/src/domain/api-keys/errors.ts +++ b/core/api/src/domain/api-keys/errors.ts @@ -1,21 +1,21 @@ import { DomainError, ErrorLevel } from "@/domain/shared" -export class ApiKeyLimitExceededError extends DomainError { - level = ErrorLevel.Critical -} +export class ApiKeysServiceError extends DomainError {} -export class ApiKeyInvalidLimitError extends DomainError { - level = ErrorLevel.Critical +export class ApiKeyLimitExceededError extends ApiKeysServiceError { + level = ErrorLevel.Info } -export class ApiKeySpendingRecordError extends DomainError { +export class ApiKeyInvalidLimitError extends ApiKeysServiceError {} + +export class ApiKeySpendingRecordError extends ApiKeysServiceError { level = ErrorLevel.Critical } -export class InvalidApiKeyIdError extends DomainError { +export class InvalidApiKeyIdError extends ApiKeysServiceError { level = ErrorLevel.Warn } -export class UnknownApiKeysServiceError extends DomainError { +export class UnknownApiKeysServiceError extends ApiKeysServiceError { level = ErrorLevel.Critical } diff --git a/core/api/src/domain/api-keys/index.types.d.ts b/core/api/src/domain/api-keys/index.types.d.ts index 17954aeb40..014f77aa8a 100644 --- a/core/api/src/domain/api-keys/index.types.d.ts +++ b/core/api/src/domain/api-keys/index.types.d.ts @@ -1,9 +1,4 @@ -type ApiKeysServiceError = - | import("./errors").InvalidApiKeyIdError - | import("./errors").ApiKeyLimitExceededError - | import("./errors").ApiKeySpendingRecordError - | import("./errors").ApiKeyInvalidLimitError - | import("./errors").UnknownApiKeysServiceError +type ApiKeysServiceError = import("./errors").ApiKeysServiceError type SpendingLimits = import("./spending-limits").SpendingLimits From 9014c687d7d1365836d695e0f1b73a26de0183da Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:22:43 +0300 Subject: [PATCH 05/33] refactor: simplify spending limit validation and include period in error message --- .../src/domain/api-keys/spending-limits.ts | 123 +++++------------- 1 file changed, 30 insertions(+), 93 deletions(-) diff --git a/core/api/src/domain/api-keys/spending-limits.ts b/core/api/src/domain/api-keys/spending-limits.ts index 7c4ab3661a..e68731247f 100644 --- a/core/api/src/domain/api-keys/spending-limits.ts +++ b/core/api/src/domain/api-keys/spending-limits.ts @@ -11,106 +11,43 @@ export type SpendingLimits = { annualSpentSats: number } -type ValidationResult = - | { - allowed: true - } - | { - allowed: false - error: ApiKeyLimitExceededError - } - export const validateSpendingLimit = ({ amountSats, limits, }: { amountSats: number limits: SpendingLimits -}): ValidationResult => { - const { - dailyLimitSats, - weeklyLimitSats, - monthlyLimitSats, - annualLimitSats, - dailySpentSats, - weeklySpentSats, - monthlySpentSats, - annualSpentSats, - } = limits - - // Calculate remaining amounts - const remainingDailySats = - dailyLimitSats !== null ? dailyLimitSats - dailySpentSats : null - const remainingWeeklySats = - weeklyLimitSats !== null ? weeklyLimitSats - weeklySpentSats : null - const remainingMonthlySats = - monthlyLimitSats !== null ? monthlyLimitSats - monthlySpentSats : null - const remainingAnnualSats = - annualLimitSats !== null ? annualLimitSats - annualSpentSats : null - - if ( - dailyLimitSats !== null && - remainingDailySats !== null && - remainingDailySats < amountSats - ) { - return { - allowed: false, - error: new ApiKeyLimitExceededError({ - daily: remainingDailySats, - weekly: remainingWeeklySats, - monthly: remainingMonthlySats, - annual: remainingAnnualSats, - }), - } - } - - if ( - weeklyLimitSats !== null && - remainingWeeklySats !== null && - remainingWeeklySats < amountSats - ) { - return { - allowed: false, - error: new ApiKeyLimitExceededError({ - daily: remainingDailySats, - weekly: remainingWeeklySats, - monthly: remainingMonthlySats, - annual: remainingAnnualSats, - }), - } - } - - if ( - monthlyLimitSats !== null && - remainingMonthlySats !== null && - remainingMonthlySats < amountSats - ) { - return { - allowed: false, - error: new ApiKeyLimitExceededError({ - daily: remainingDailySats, - weekly: remainingWeeklySats, - monthly: remainingMonthlySats, - annual: remainingAnnualSats, - }), - } - } - - if ( - annualLimitSats !== null && - remainingAnnualSats !== null && - remainingAnnualSats < amountSats - ) { - return { - allowed: false, - error: new ApiKeyLimitExceededError({ - daily: remainingDailySats, - weekly: remainingWeeklySats, - monthly: remainingMonthlySats, - annual: remainingAnnualSats, - }), +}): true | ApiKeyLimitExceededError => { + const checks = [ + { + period: "daily", + limit: limits.dailyLimitSats, + spent: limits.dailySpentSats, + }, + { + period: "weekly", + limit: limits.weeklyLimitSats, + spent: limits.weeklySpentSats, + }, + { + period: "monthly", + limit: limits.monthlyLimitSats, + spent: limits.monthlySpentSats, + }, + { + period: "annual", + limit: limits.annualLimitSats, + spent: limits.annualSpentSats, + }, + ] as const + + for (const { period, limit, spent } of checks) { + if (limit && limit - spent < amountSats) { + return new ApiKeyLimitExceededError( + `${period} spending limit exceeded, remaining: ${limit - spent} sats`, + ) } } - return { allowed: true } + return true } From 439d5421505f47a43b01a4b5a77ecae5efdf15f9 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:22:48 +0300 Subject: [PATCH 06/33] fix: make recordSpending fire-and-forget and simplify validation checks --- core/api/src/app/payments/send-intraledger.ts | 9 ++++---- core/api/src/app/payments/send-lightning.ts | 23 +++++++++---------- core/api/src/app/payments/send-on-chain.ts | 23 ++++++++++--------- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 9b471c1105..97db4efc2d 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -263,9 +263,7 @@ const executePaymentViaIntraledger = async < if (limits instanceof Error) return limits const validation = validateSpendingLimit({ amountSats, limits }) - if (!validation.allowed) { - return validation.error - } + if (validation instanceof Error) return validation } const checkLimits = @@ -339,7 +337,6 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - // Record API key spending after successful payment if (apiKeyId) { const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ @@ -347,7 +344,9 @@ const executePaymentViaIntraledger = async < amountSats, transactionId: journalId, }) - if (recordResult instanceof Error) return recordResult + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) + } } return { diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 1f9da2e453..3135f1d6ef 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -467,10 +467,7 @@ const executePaymentViaIntraledger = async < if (limits instanceof Error) return limits const validation = validateSpendingLimit({ amountSats, limits }) - - if (!validation.allowed) { - return validation.error - } + if (validation instanceof Error) return validation } const paymentHash = paymentFlow.paymentHashForFlow() @@ -573,7 +570,6 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - // Record API key spending after successful payment if (apiKeyId) { const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ @@ -581,7 +577,9 @@ const executePaymentViaIntraledger = async < amountSats, transactionId: journalId, }) - if (recordResult instanceof Error) return recordResult + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) + } } return { @@ -792,10 +790,7 @@ const executePaymentViaLn = async ({ if (limits instanceof Error) return limits const validation = validateSpendingLimit({ amountSats, limits }) - - if (!validation.allowed) { - return validation.error - } + if (validation instanceof Error) return validation } const limitCheck = await checkWithdrawalLimits({ @@ -864,7 +859,9 @@ const executePaymentViaLn = async ({ amountSats, transactionId: paymentSendAttemptResult.journalId, }) - if (recordResult instanceof Error) return recordResult + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) + } } return getPendingPaymentResponse({ walletId: senderWalletId, @@ -885,7 +882,9 @@ const executePaymentViaLn = async ({ amountSats, transactionId: paymentSendAttemptResult.journalId, }) - if (recordResult instanceof Error) return recordResult + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) + } } return { diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index 1c34a3e646..491175c972 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -51,7 +51,10 @@ import { WalletsRepository, } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" -import { addAttributesToCurrentSpan } from "@/services/tracing" +import { + addAttributesToCurrentSpan, + recordExceptionInCurrentSpan, +} from "@/services/tracing" import { ApiKeysService } from "@/services/api-keys" import { validateSpendingLimit } from "@/domain/api-keys" @@ -306,9 +309,7 @@ const executePaymentViaIntraledger = async < if (limits instanceof Error) return limits const validation = validateSpendingLimit({ amountSats, limits }) - if (!validation.allowed) { - return validation.error - } + if (validation instanceof Error) return validation } const checkLimits = @@ -379,7 +380,6 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - // Record API key spending after successful payment if (apiKeyId) { const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ @@ -387,7 +387,9 @@ const executePaymentViaIntraledger = async < amountSats, transactionId: journalId, }) - if (recordResult instanceof Error) return recordResult + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) + } } return { @@ -585,9 +587,7 @@ const executePaymentViaOnChain = async < if (limits instanceof Error) return limits const validation = validateSpendingLimit({ amountSats, limits }) - if (!validation.allowed) { - return validation.error - } + if (validation instanceof Error) return validation } const limitCheck = await checkWithdrawalLimits({ @@ -621,7 +621,6 @@ const executePaymentViaOnChain = async < }) if (walletTransaction instanceof Error) return walletTransaction - // Record API key spending after successful payment if (apiKeyId) { const paymentFlow = await builder.proposedAmounts() if (!(paymentFlow instanceof Error)) { @@ -631,7 +630,9 @@ const executePaymentViaOnChain = async < amountSats, transactionId: journalId, }) - if (recordResult instanceof Error) return recordResult + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) + } } } From 96a801ce4bcd24db954777b4ac374a20fc7a7bbb Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:22:55 +0300 Subject: [PATCH 07/33] fix: move ApiKeysService instantiation to module scope in update-pending-payments --- core/api/src/app/payments/update-pending-payments.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/api/src/app/payments/update-pending-payments.ts b/core/api/src/app/payments/update-pending-payments.ts index b7658f5047..e81a748f36 100644 --- a/core/api/src/app/payments/update-pending-payments.ts +++ b/core/api/src/app/payments/update-pending-payments.ts @@ -44,6 +44,8 @@ import { } from "@/services/tracing" import { runInParallel } from "@/utils" +const apiKeys = ApiKeysService() + export const updatePendingPayments = async (logger: Logger): Promise => { const ledgerService = LedgerService() const walletIdsWithPendingPayments = ledgerService.listWalletIdsWithPendingPayments() @@ -345,7 +347,6 @@ const lockedPendingPaymentSteps = async ({ // pendingPayment is a different version to latest payment from lnd satsAmount !== toSats(paymentFlow.btcPaymentAmount.amount) ) { - const apiKeys = ApiKeysService() paymentLogger.warn( { success: false, id: paymentHash, payment: pendingPayment }, "payment has failed. reverting transaction", From e3e4c5f6ff7b4a75ebe8c63bb6edbd558d6df142 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:22:59 +0300 Subject: [PATCH 08/33] feat: add tracing to ApiKeysService via wrapAsyncFunctionsToRunInSpan --- core/api/src/services/api-keys/index.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/core/api/src/services/api-keys/index.ts b/core/api/src/services/api-keys/index.ts index f9d5f0fbe5..82814f0d7d 100644 --- a/core/api/src/services/api-keys/index.ts +++ b/core/api/src/services/api-keys/index.ts @@ -11,6 +11,7 @@ import * as apiKeysGrpc from "./grpc-client" import { handleCommonApiKeysErrors } from "./errors" import { baseLogger } from "@/services/logger" +import { wrapAsyncFunctionsToRunInSpan } from "@/services/tracing" import { SpendingLimits } from "@/domain/api-keys" export const ApiKeysService = (): IApiKeysService => { @@ -31,9 +32,7 @@ export const ApiKeysService = (): IApiKeysService => { apiKeysGrpc.apiKeysMetadata, ) - const limits = grpcSpendingLimitsToSpendingLimits(response) - - return limits + return grpcSpendingLimitsToSpendingLimits(response) } catch (err) { baseLogger.error( { err, apiKeyId, amountSats }, @@ -88,9 +87,12 @@ export const ApiKeysService = (): IApiKeysService => { } } - return { - getSpendingLimits, - recordSpending, - reverseSpending, - } + return wrapAsyncFunctionsToRunInSpan({ + namespace: "services.api-keys", + fns: { + getSpendingLimits, + recordSpending, + reverseSpending, + }, + }) } From 86b97bb2374ec02f6c1038d7c913f1a116554aac Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:23:04 +0300 Subject: [PATCH 09/33] fix: reject zero amount in check_spending_limit for consistency with record_spending --- core/api-keys/src/limits/mod.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index b86cae4760..ee04b8529a 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -62,7 +62,7 @@ impl Limits { api_key_id: IdentityApiKeyId, amount_sats: i64, ) -> Result { - if amount_sats < 0 { + if amount_sats <= 0 { return Err(LimitError::InvalidLimitAmount); } @@ -454,6 +454,13 @@ mod tests { assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); } + #[tokio::test] + async fn check_spending_limit_rejects_zero_amount() { + let limits = test_limits(); + let result = limits.check_spending_limit(test_api_key_id(), 0).await; + assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); + } + #[tokio::test] async fn record_spending_rejects_zero_amount() { let limits = test_limits(); From 7a33c536781830627bf1f289aa4a6ef5ea1c03c7 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:40:51 +0300 Subject: [PATCH 10/33] fix: add ApiKeysServiceError to graphql error map --- core/api/src/graphql/error-map.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 604d4c3aaa..0d7385efb6 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -842,6 +842,7 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "InvalidErrorCodeForPhoneMetadataError": case "InvalidCountryCodeForPhoneMetadataError": case "MultipleWalletsFoundForAccountIdAndCurrency": + case "ApiKeysServiceError": message = `Unexpected error occurred, please try again or contact support if it persists (code: ${ error.name }${error.message ? ": " + error.message : ""})` From 7fc86357d0b1d33b659760c2b5f9f34fb491eb78 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Tue, 17 Mar 2026 15:51:13 +0300 Subject: [PATCH 11/33] chore: remove unnecessary comments from dashboard api-keys code --- apps/dashboard/app/api-keys/server-actions.ts | 1 - apps/dashboard/components/api-keys/limit.tsx | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index 6db18b893e..c0a005ac15 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -118,7 +118,6 @@ export const createApiKeyServerAction = async ( } } - // Set budget limits if provided if (data?.apiKeyCreate.apiKey.id) { const apiKeyId = data.apiKeyCreate.apiKey.id try { diff --git a/apps/dashboard/components/api-keys/limit.tsx b/apps/dashboard/components/api-keys/limit.tsx index 7f95e84580..7ff7d2b5b1 100644 --- a/apps/dashboard/components/api-keys/limit.tsx +++ b/apps/dashboard/components/api-keys/limit.tsx @@ -117,7 +117,7 @@ const Limit: React.FC = ({ id, limits, spent }) => { break } setOpen(false) - window.location.reload() // Refresh to show updated data + window.location.reload() } catch (error) { console.error("Error setting limit:", error) alert("Failed to set limit. Please try again.") @@ -159,7 +159,7 @@ const Limit: React.FC = ({ id, limits, spent }) => { break } setOpen(false) - window.location.reload() // Refresh to show updated data + window.location.reload() } catch (error) { console.error("Error removing limit:", error) alert("Failed to remove limit. Please try again.") From 8bc4a63efeaa5f1c381ed048b1f67f982df69e81 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Wed, 18 Mar 2026 14:55:47 +0300 Subject: [PATCH 12/33] refactor: use proper domain types in api-keys spending limits interface --- core/api/src/app/payments/send-intraledger.ts | 11 +++--- core/api/src/app/payments/send-lightning.ts | 25 ++++++------- core/api/src/app/payments/send-on-chain.ts | 37 ++++++++----------- core/api/src/app/wallets/index.types.d.ts | 10 ++--- core/api/src/domain/api-keys/index.types.d.ts | 16 +++++--- .../src/domain/api-keys/spending-limits.ts | 21 ++++++----- core/api/src/servers/index.files.d.ts | 2 +- core/api/src/servers/middlewares/session.ts | 2 +- core/api/src/services/api-keys/convert.ts | 20 ++++++---- core/api/src/services/api-keys/index.ts | 25 ++++++------- 10 files changed, 84 insertions(+), 85 deletions(-) diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 97db4efc2d..0dbb93a54c 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -245,7 +245,7 @@ const executePaymentViaIntraledger = async < recipientUser: User senderUser: User memo: string | null - apiKeyId?: string + apiKeyId?: ApiKeyId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -255,14 +255,14 @@ const executePaymentViaIntraledger = async < if (priceRatioForLimits instanceof Error) return priceRatioForLimits if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const amount = paymentFlow.btcPaymentAmount const limits = await apiKeys.getSpendingLimits({ apiKeyId, - amountSats, + amount, }) if (limits instanceof Error) return limits - const validation = validateSpendingLimit({ amountSats, limits }) + const validation = validateSpendingLimit({ amount, limits }) if (validation instanceof Error) return validation } @@ -338,10 +338,9 @@ const executePaymentViaIntraledger = async < }) if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ apiKeyId, - amountSats, + amount: paymentFlow.btcPaymentAmount, transactionId: journalId, }) if (recordResult instanceof Error) { diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 3135f1d6ef..f13958cdd7 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -448,7 +448,7 @@ const executePaymentViaIntraledger = async < senderWalletId: WalletId recipientAccount: Account memo: string | null - apiKeyId?: string + apiKeyId?: ApiKeyId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -458,15 +458,15 @@ const executePaymentViaIntraledger = async < if (priceRatioForLimits instanceof Error) return priceRatioForLimits if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const amount = paymentFlow.btcPaymentAmount const limits = await apiKeys.getSpendingLimits({ apiKeyId, - amountSats, + amount, }) if (limits instanceof Error) return limits - const validation = validateSpendingLimit({ amountSats, limits }) + const validation = validateSpendingLimit({ amount, limits }) if (validation instanceof Error) return validation } @@ -571,10 +571,9 @@ const executePaymentViaIntraledger = async < }) if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ apiKeyId, - amountSats, + amount: paymentFlow.btcPaymentAmount, transactionId: journalId, }) if (recordResult instanceof Error) { @@ -769,7 +768,7 @@ const executePaymentViaLn = async ({ paymentFlow: PaymentFlow senderAccount: Account memo: string | null - apiKeyId?: string + apiKeyId?: ApiKeyId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.Lightning, @@ -781,15 +780,15 @@ const executePaymentViaLn = async ({ if (priceRatioForLimits instanceof Error) return priceRatioForLimits if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const amount = paymentFlow.btcPaymentAmount const limits = await apiKeys.getSpendingLimits({ apiKeyId, - amountSats, + amount, }) if (limits instanceof Error) return limits - const validation = validateSpendingLimit({ amountSats, limits }) + const validation = validateSpendingLimit({ amount, limits }) if (validation instanceof Error) return validation } @@ -853,10 +852,9 @@ const executePaymentViaLn = async ({ case PaymentSendAttemptResultType.Pending: if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ apiKeyId, - amountSats, + amount: paymentFlow.btcPaymentAmount, transactionId: paymentSendAttemptResult.journalId, }) if (recordResult instanceof Error) { @@ -876,10 +874,9 @@ const executePaymentViaLn = async ({ default: if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ apiKeyId, - amountSats, + amount: paymentFlow.btcPaymentAmount, transactionId: paymentSendAttemptResult.journalId, }) if (recordResult instanceof Error) { diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index 491175c972..bf77155399 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -263,7 +263,7 @@ const executePaymentViaIntraledger = async < senderAccount: Account memo: string | null sendAll: boolean - apiKeyId?: string + apiKeyId?: ApiKeyId }): Promise => { const paymentFlow = await builder.withoutMinerFee() if (paymentFlow instanceof Error) return paymentFlow @@ -301,14 +301,14 @@ const executePaymentViaIntraledger = async < if (priceRatioForLimits instanceof Error) return priceRatioForLimits if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) + const amount = paymentFlow.btcPaymentAmount const limits = await apiKeys.getSpendingLimits({ apiKeyId, - amountSats, + amount, }) if (limits instanceof Error) return limits - const validation = validateSpendingLimit({ amountSats, limits }) + const validation = validateSpendingLimit({ amount, limits }) if (validation instanceof Error) return validation } @@ -381,10 +381,9 @@ const executePaymentViaIntraledger = async < }) if (apiKeyId) { - const amountSats = Number(paymentFlow.btcPaymentAmount.amount) const recordResult = await apiKeys.recordSpending({ apiKeyId, - amountSats, + amount: paymentFlow.btcPaymentAmount, transactionId: journalId, }) if (recordResult instanceof Error) { @@ -566,7 +565,7 @@ const executePaymentViaOnChain = async < memo: string | null sendAll: boolean logger: Logger - apiKeyId?: string + apiKeyId?: ApiKeyId }): Promise => { const senderWalletDescriptor = await builder.senderWalletDescriptor() if (senderWalletDescriptor instanceof Error) return senderWalletDescriptor @@ -579,14 +578,14 @@ const executePaymentViaOnChain = async < if (priceRatioForLimits instanceof Error) return priceRatioForLimits if (apiKeyId) { - const amountSats = Number(proposedAmounts.btc.amount) + const amount = proposedAmounts.btc const limits = await apiKeys.getSpendingLimits({ apiKeyId, - amountSats, + amount, }) if (limits instanceof Error) return limits - const validation = validateSpendingLimit({ amountSats, limits }) + const validation = validateSpendingLimit({ amount, limits }) if (validation instanceof Error) return validation } @@ -622,17 +621,13 @@ const executePaymentViaOnChain = async < if (walletTransaction instanceof Error) return walletTransaction if (apiKeyId) { - const paymentFlow = await builder.proposedAmounts() - if (!(paymentFlow instanceof Error)) { - const amountSats = Number(paymentFlow.btc.amount) - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amountSats, - transactionId: journalId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) - } + const recordResult = await apiKeys.recordSpending({ + apiKeyId, + amount: proposedAmounts.btc, + transactionId: journalId, + }) + if (recordResult instanceof Error) { + recordExceptionInCurrentSpan({ error: recordResult }) } } diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 87f6a6883b..2dee0f6d3f 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -98,7 +98,7 @@ type PaymentSendArgs = { senderWalletId: WalletId senderAccount: Account memo: string | null - apiKeyId?: string + apiKeyId?: ApiKeyId } type PayInvoiceByWalletIdArgs = PaymentSendArgs & { @@ -128,7 +128,7 @@ type PayAllOnChainByWalletIdArgs = { address: string speed: PayoutSpeed memo: string | null - apiKeyId?: string + apiKeyId?: ApiKeyId } type PayOnChainByWalletIdWithoutCurrencyArgs = { @@ -138,7 +138,7 @@ type PayOnChainByWalletIdWithoutCurrencyArgs = { address: string speed: PayoutSpeed memo: string | null - apiKeyId?: string + apiKeyId?: ApiKeyId } type PayOnChainByWalletIdArgs = PayOnChainByWalletIdWithoutCurrencyArgs & { @@ -151,7 +151,7 @@ type LnAddressPaymentSendArgs = { senderAccount: Account lnAddress: string amount: number - apiKeyId?: string + apiKeyId?: ApiKeyId } type LnurlPaymentSendArgs = { @@ -159,7 +159,7 @@ type LnurlPaymentSendArgs = { senderAccount: Account lnurl: string amount: number - apiKeyId?: string + apiKeyId?: ApiKeyId } type GetDepositFeeConfigurationResult = { diff --git a/core/api/src/domain/api-keys/index.types.d.ts b/core/api/src/domain/api-keys/index.types.d.ts index 014f77aa8a..491878b4d0 100644 --- a/core/api/src/domain/api-keys/index.types.d.ts +++ b/core/api/src/domain/api-keys/index.types.d.ts @@ -1,18 +1,22 @@ +type ApiKeyId = string & { readonly brand: unique symbol } + type ApiKeysServiceError = import("./errors").ApiKeysServiceError type SpendingLimits = import("./spending-limits").SpendingLimits interface IApiKeysService { getSpendingLimits(args: { - apiKeyId: string - amountSats: number + apiKeyId: ApiKeyId + amount: BtcPaymentAmount }): Promise recordSpending(args: { - apiKeyId: string - amountSats: number - transactionId: string + apiKeyId: ApiKeyId + amount: BtcPaymentAmount + transactionId: LedgerJournalId }): Promise - reverseSpending(args: { transactionId: string }): Promise + reverseSpending(args: { + transactionId: LedgerJournalId + }): Promise } diff --git a/core/api/src/domain/api-keys/spending-limits.ts b/core/api/src/domain/api-keys/spending-limits.ts index e68731247f..a631d831ca 100644 --- a/core/api/src/domain/api-keys/spending-limits.ts +++ b/core/api/src/domain/api-keys/spending-limits.ts @@ -1,23 +1,24 @@ import { ApiKeyLimitExceededError } from "./errors" export type SpendingLimits = { - dailyLimitSats: number | null - weeklyLimitSats: number | null - monthlyLimitSats: number | null - annualLimitSats: number | null - dailySpentSats: number - weeklySpentSats: number - monthlySpentSats: number - annualSpentSats: number + dailyLimitSats: Satoshis | null + weeklyLimitSats: Satoshis | null + monthlyLimitSats: Satoshis | null + annualLimitSats: Satoshis | null + dailySpentSats: Satoshis + weeklySpentSats: Satoshis + monthlySpentSats: Satoshis + annualSpentSats: Satoshis } export const validateSpendingLimit = ({ - amountSats, + amount, limits, }: { - amountSats: number + amount: BtcPaymentAmount limits: SpendingLimits }): true | ApiKeyLimitExceededError => { + const amountSats = Number(amount.amount) as Satoshis const checks = [ { period: "daily", diff --git a/core/api/src/servers/index.files.d.ts b/core/api/src/servers/index.files.d.ts index a640143199..0c70ee544f 100644 --- a/core/api/src/servers/index.files.d.ts +++ b/core/api/src/servers/index.files.d.ts @@ -18,7 +18,7 @@ type GraphQLPublicContextAuth = GraphQLPublicContext & { domainAccount: Account scope: ScopesOauth2[] | undefined appId: string | undefined - apiKeyId?: string + apiKeyId?: ApiKeyId } type GraphQLAdminContext = { diff --git a/core/api/src/servers/middlewares/session.ts b/core/api/src/servers/middlewares/session.ts index f1b200fd77..ed3b4c7df4 100644 --- a/core/api/src/servers/middlewares/session.ts +++ b/core/api/src/servers/middlewares/session.ts @@ -30,7 +30,7 @@ export const sessionPublicContext = async ({ ) const sub = tokenPayload?.sub const appId = tokenPayload?.client_id - const apiKeyId = tokenPayload?.api_key_id + const apiKeyId = tokenPayload?.api_key_id as ApiKeyId | undefined // note: value should match (ie: "anon") if not an accountId // settings from dev/ory/oathkeeper.yml/authenticator/anonymous/config/subjet diff --git a/core/api/src/services/api-keys/convert.ts b/core/api/src/services/api-keys/convert.ts index 5dcdf2162c..241997d3af 100644 --- a/core/api/src/services/api-keys/convert.ts +++ b/core/api/src/services/api-keys/convert.ts @@ -1,24 +1,28 @@ import { CheckSpendingLimitResponse } from "./proto/api_keys_pb" +import { toSats } from "@/domain/bitcoin" import { SpendingLimits } from "@/domain/api-keys" +const toSatsOrNull = (value: number | undefined): Satoshis | null => + value !== undefined ? toSats(value) : null + export const grpcSpendingLimitsToSpendingLimits = ( response: CheckSpendingLimitResponse, ): SpendingLimits => ({ dailyLimitSats: response.hasDailyLimitSats() - ? (response.getDailyLimitSats() ?? null) + ? toSatsOrNull(response.getDailyLimitSats()) : null, weeklyLimitSats: response.hasWeeklyLimitSats() - ? (response.getWeeklyLimitSats() ?? null) + ? toSatsOrNull(response.getWeeklyLimitSats()) : null, monthlyLimitSats: response.hasMonthlyLimitSats() - ? (response.getMonthlyLimitSats() ?? null) + ? toSatsOrNull(response.getMonthlyLimitSats()) : null, annualLimitSats: response.hasAnnualLimitSats() - ? (response.getAnnualLimitSats() ?? null) + ? toSatsOrNull(response.getAnnualLimitSats()) : null, - dailySpentSats: response.getDailySpentSats(), - weeklySpentSats: response.getWeeklySpentSats(), - monthlySpentSats: response.getMonthlySpentSats(), - annualSpentSats: response.getAnnualSpentSats(), + dailySpentSats: toSats(response.getDailySpentSats()), + weeklySpentSats: toSats(response.getWeeklySpentSats()), + monthlySpentSats: toSats(response.getMonthlySpentSats()), + annualSpentSats: toSats(response.getAnnualSpentSats()), }) diff --git a/core/api/src/services/api-keys/index.ts b/core/api/src/services/api-keys/index.ts index 82814f0d7d..65c1300b7e 100644 --- a/core/api/src/services/api-keys/index.ts +++ b/core/api/src/services/api-keys/index.ts @@ -17,12 +17,13 @@ import { SpendingLimits } from "@/domain/api-keys" export const ApiKeysService = (): IApiKeysService => { const getSpendingLimits = async ({ apiKeyId, - amountSats, + amount, }: { - apiKeyId: string - amountSats: number + apiKeyId: ApiKeyId + amount: BtcPaymentAmount }): Promise => { try { + const amountSats = Number(amount.amount) const request = new CheckSpendingLimitRequest() request.setApiKeyId(apiKeyId) request.setAmountSats(amountSats) @@ -34,24 +35,22 @@ export const ApiKeysService = (): IApiKeysService => { return grpcSpendingLimitsToSpendingLimits(response) } catch (err) { - baseLogger.error( - { err, apiKeyId, amountSats }, - "Failed to get API key spending limits", - ) + baseLogger.error({ err, apiKeyId, amount }, "Failed to get API key spending limits") return handleCommonApiKeysErrors(err) } } const recordSpending = async ({ apiKeyId, - amountSats, + amount, transactionId, }: { - apiKeyId: string - amountSats: number - transactionId: string + apiKeyId: ApiKeyId + amount: BtcPaymentAmount + transactionId: LedgerJournalId }): Promise => { try { + const amountSats = Number(amount.amount) const request = new RecordSpendingRequest() request.setApiKeyId(apiKeyId) request.setAmountSats(amountSats) @@ -62,7 +61,7 @@ export const ApiKeysService = (): IApiKeysService => { return true } catch (err) { baseLogger.error( - { err, apiKeyId, amountSats, transactionId }, + { err, apiKeyId, amount, transactionId }, "Failed to record API key spending", ) return handleCommonApiKeysErrors(err) @@ -72,7 +71,7 @@ export const ApiKeysService = (): IApiKeysService => { const reverseSpending = async ({ transactionId, }: { - transactionId: string + transactionId: LedgerJournalId }): Promise => { try { const request = new ReverseSpendingRequest() From a73903f5f4ea57f0ffeead81675423163c2a3ffe Mon Sep 17 00:00:00 2001 From: basar Date: Mon, 23 Mar 2026 12:09:46 +0300 Subject: [PATCH 13/33] feat: Api keys usage limit part2 atomic check and lock --- ...4376e721165b22f4b49a93ce24a097914e536.json | 40 ++ ...84bebe11d59fd343c083b745f9ce4ee09ed0d.json | 16 + ...53f9cf2e2299c9a4dd5a9e111b2cb1400d283.json | 23 ++ ...14d9958cb372c32088c0bbf7dbc9b4c6a897b.json | 16 + ...b5a7ab0b025f6103ce6f0aa95fb12d950d005.json | 34 -- ...7a482cab3bec0207c9e9d5df0f2f4e71c66a.json} | 4 +- core/api-keys/proto/api_keys.proto | 11 + core/api-keys/src/app/mod.rs | 15 +- core/api-keys/src/grpc/server/mod.rs | 49 ++- core/api-keys/src/limits/error.rs | 9 + core/api-keys/src/limits/mod.rs | 173 +++++++- core/api/src/app/payments/send-intraledger.ts | 37 +- core/api/src/app/payments/send-lightning.ts | 114 ++++-- core/api/src/app/payments/send-on-chain.ts | 85 ++-- core/api/src/domain/api-keys/index.ts | 1 - core/api/src/domain/api-keys/index.types.d.ts | 13 +- .../src/domain/api-keys/spending-limits.ts | 54 --- core/api/src/services/api-keys/convert.ts | 28 -- core/api/src/services/api-keys/errors.ts | 8 +- core/api/src/services/api-keys/grpc-client.ts | 20 +- core/api/src/services/api-keys/index.ts | 26 +- .../services/api-keys/proto/api_keys.proto | 11 + .../api-keys/proto/api_keys_grpc_pb.d.ts | 17 + .../api-keys/proto/api_keys_grpc_pb.js | 33 ++ .../services/api-keys/proto/api_keys_pb.d.ts | 49 +++ .../services/api-keys/proto/api_keys_pb.js | 384 +++++++++++++++++- 26 files changed, 1027 insertions(+), 243 deletions(-) create mode 100644 core/api-keys/.sqlx/query-484c75fe89364613e436dee47c34376e721165b22f4b49a93ce24a097914e536.json create mode 100644 core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json create mode 100644 core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json create mode 100644 core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json delete mode 100644 core/api-keys/.sqlx/query-b52c808ef2afc69dcd45ddd6889b5a7ab0b025f6103ce6f0aa95fb12d950d005.json rename core/api-keys/.sqlx/{query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json => query-eb63f3d930396dbcdc2f1fae84837a482cab3bec0207c9e9d5df0f2f4e71c66a.json} (65%) delete mode 100644 core/api/src/domain/api-keys/spending-limits.ts delete mode 100644 core/api/src/services/api-keys/convert.ts diff --git a/core/api-keys/.sqlx/query-484c75fe89364613e436dee47c34376e721165b22f4b49a93ce24a097914e536.json b/core/api-keys/.sqlx/query-484c75fe89364613e436dee47c34376e721165b22f4b49a93ce24a097914e536.json new file mode 100644 index 0000000000..1210fbefc9 --- /dev/null +++ b/core/api-keys/.sqlx/query-484c75fe89364613e436dee47c34376e721165b22f4b49a93ce24a097914e536.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT daily_limit_sats, weekly_limit_sats, monthly_limit_sats, annual_limit_sats\n FROM api_key_limits\n WHERE api_key_id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "daily_limit_sats", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "weekly_limit_sats", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "monthly_limit_sats", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "annual_limit_sats", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true, + true, + true + ] + }, + "hash": "484c75fe89364613e436dee47c34376e721165b22f4b49a93ce24a097914e536" +} diff --git a/core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json b/core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json new file mode 100644 index 0000000000..6439ef21bf --- /dev/null +++ b/core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at)\n VALUES ($1, $2, $3, NOW())\n ON CONFLICT (transaction_id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d" +} diff --git a/core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json b/core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json new file mode 100644 index 0000000000..3217e80dd9 --- /dev/null +++ b/core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 as \"exists!\"\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283" +} diff --git a/core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json b/core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json new file mode 100644 index 0000000000..399e702bd4 --- /dev/null +++ b/core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_key_transactions\n SET transaction_id = $1\n WHERE transaction_id = $2\n AND api_key_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b" +} diff --git a/core/api-keys/.sqlx/query-b52c808ef2afc69dcd45ddd6889b5a7ab0b025f6103ce6f0aa95fb12d950d005.json b/core/api-keys/.sqlx/query-b52c808ef2afc69dcd45ddd6889b5a7ab0b025f6103ce6f0aa95fb12d950d005.json deleted file mode 100644 index c499cc970a..0000000000 --- a/core/api-keys/.sqlx/query-b52c808ef2afc69dcd45ddd6889b5a7ab0b025f6103ce6f0aa95fb12d950d005.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH updated_key AS (\n UPDATE identity_api_keys k\n SET last_used_at = NOW(), hashed_key = digest($1, 'sha256')\n FROM identities i\n WHERE k.identity_id = i.id\n AND k.revoked = false\n AND k.encrypted_key = crypt($1, k.encrypted_key)\n AND (k.expires_at > NOW() OR k.expires_at IS NULL)\n RETURNING k.id, i.subject_id, k.scopes\n )\n SELECT id, subject_id, scopes FROM updated_key", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "subject_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "scopes", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "b52c808ef2afc69dcd45ddd6889b5a7ab0b025f6103ce6f0aa95fb12d950d005" -} diff --git a/core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json b/core/api-keys/.sqlx/query-eb63f3d930396dbcdc2f1fae84837a482cab3bec0207c9e9d5df0f2f4e71c66a.json similarity index 65% rename from core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json rename to core/api-keys/.sqlx/query-eb63f3d930396dbcdc2f1fae84837a482cab3bec0207c9e9d5df0f2f4e71c66a.json index a12fbb75a3..1908eefb7c 100644 --- a/core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json +++ b/core/api-keys/.sqlx/query-eb63f3d930396dbcdc2f1fae84837a482cab3bec0207c9e9d5df0f2f4e71c66a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at)\n VALUES ($1, $2, $3, NOW())\n ON CONFLICT (transaction_id) DO NOTHING\n ", + "query": "\n INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at)\n VALUES ($1, $2, $3, NOW())\n ", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06" + "hash": "eb63f3d930396dbcdc2f1fae84837a482cab3bec0207c9e9d5df0f2f4e71c66a" } diff --git a/core/api-keys/proto/api_keys.proto b/core/api-keys/proto/api_keys.proto index 6878dc496a..ffd4bd88a6 100644 --- a/core/api-keys/proto/api_keys.proto +++ b/core/api-keys/proto/api_keys.proto @@ -4,6 +4,7 @@ package services.api_keys.v1; service ApiKeysService { rpc CheckSpendingLimit (CheckSpendingLimitRequest) returns (CheckSpendingLimitResponse) {} + rpc CheckAndLockSpending (CheckAndLockSpendingRequest) returns (CheckAndLockSpendingResponse) {} rpc GetSpendingSummary (GetSpendingSummaryRequest) returns (GetSpendingSummaryResponse) {} rpc RecordSpending (RecordSpendingRequest) returns (RecordSpendingResponse) {} rpc ReverseSpending (ReverseSpendingRequest) returns (ReverseSpendingResponse) {} @@ -30,6 +31,15 @@ message CheckSpendingLimitResponse { optional int64 remaining_annual_sats = 13; } +message CheckAndLockSpendingRequest { + string api_key_id = 1; + int64 amount_sats = 2; +} + +message CheckAndLockSpendingResponse { + string ephemeral_id = 1; +} + message GetSpendingSummaryRequest { string api_key_id = 1; } @@ -53,6 +63,7 @@ message RecordSpendingRequest { string api_key_id = 1; int64 amount_sats = 2; optional string transaction_id = 3; + optional string ephemeral_id = 4; } message RecordSpendingResponse { diff --git a/core/api-keys/src/app/mod.rs b/core/api-keys/src/app/mod.rs index e3223fee78..6074e9b435 100644 --- a/core/api-keys/src/app/mod.rs +++ b/core/api-keys/src/app/mod.rs @@ -98,16 +98,29 @@ impl ApiKeysApp { .await?) } + #[tracing::instrument(name = "app.check_and_lock_spending", skip_all)] + pub async fn check_and_lock_spending( + &self, + api_key_id: IdentityApiKeyId, + amount_sats: i64, + ) -> Result { + Ok(self + .limits + .check_and_lock_spending(api_key_id, amount_sats) + .await?) + } + #[tracing::instrument(name = "app.record_spending", skip_all)] pub async fn record_spending( &self, api_key_id: IdentityApiKeyId, amount_sats: i64, transaction_id: Option, + ephemeral_id: Option, ) -> Result<(), ApplicationError> { Ok(self .limits - .record_spending(api_key_id, amount_sats, transaction_id) + .record_spending(api_key_id, amount_sats, transaction_id, ephemeral_id) .await?) } diff --git a/core/api-keys/src/grpc/server/mod.rs b/core/api-keys/src/grpc/server/mod.rs index b8af08d4ee..4cbeed0e89 100644 --- a/core/api-keys/src/grpc/server/mod.rs +++ b/core/api-keys/src/grpc/server/mod.rs @@ -11,7 +11,7 @@ use tracing::{grpc, instrument}; use self::proto::{api_keys_service_server::ApiKeysService, *}; use super::config::*; -use crate::{app::ApiKeysApp, identity::IdentityApiKeyId}; +use crate::{app::{ApiKeysApp, ApplicationError}, identity::IdentityApiKeyId, limits::LimitError}; use std::sync::Arc; pub struct ApiKeys { @@ -72,6 +72,39 @@ impl ApiKeysService for ApiKeys { })) } + #[instrument(name = "api_keys.check_and_lock_spending", skip_all, err)] + async fn check_and_lock_spending( + &self, + request: Request, + ) -> Result, Status> { + grpc::extract_tracing(&request); + let request = request.into_inner(); + let CheckAndLockSpendingRequest { + api_key_id, + amount_sats, + } = request; + + let api_key_id = api_key_id + .parse::() + .map_err(|e| Status::invalid_argument(format!("Invalid API key ID: {}", e)))?; + + let ephemeral_id = self + .app + .check_and_lock_spending(api_key_id, amount_sats) + .await + .map_err(|e| match &e { + ApplicationError::Limit(LimitError::LimitExceeded(_)) => { + Status::failed_precondition(e.to_string()) + } + ApplicationError::Limit(LimitError::InvalidLimitAmount) => { + Status::invalid_argument(e.to_string()) + } + _ => Status::internal(e.to_string()), + })?; + + Ok(Response::new(CheckAndLockSpendingResponse { ephemeral_id })) + } + #[instrument(name = "api_keys.get_spending_summary", skip_all, err)] async fn get_spending_summary( &self, @@ -131,6 +164,7 @@ impl ApiKeysService for ApiKeys { api_key_id, amount_sats, transaction_id, + ephemeral_id, } = request; let api_key_id = api_key_id @@ -138,9 +172,18 @@ impl ApiKeysService for ApiKeys { .map_err(|e| Status::invalid_argument(format!("Invalid API key ID: {}", e)))?; self.app - .record_spending(api_key_id, amount_sats, transaction_id) + .record_spending(api_key_id, amount_sats, transaction_id, ephemeral_id) .await - .map_err(|e| Status::internal(e.to_string()))?; + .map_err(|e| match &e { + ApplicationError::Limit(LimitError::InvalidLimitAmount) | + ApplicationError::Limit(LimitError::MissingTransactionId) => { + Status::invalid_argument(e.to_string()) + } + ApplicationError::Limit(LimitError::EphemeralNotFound(_)) => { + Status::not_found(e.to_string()) + } + _ => Status::internal(e.to_string()), + })?; Ok(Response::new(RecordSpendingResponse {})) } diff --git a/core/api-keys/src/limits/error.rs b/core/api-keys/src/limits/error.rs index c5495ff775..027b0be721 100644 --- a/core/api-keys/src/limits/error.rs +++ b/core/api-keys/src/limits/error.rs @@ -7,4 +7,13 @@ pub enum LimitError { #[error("Invalid limit amount (must be positive)")] InvalidLimitAmount, + + #[error("Missing transaction id for ephemeral finalization")] + MissingTransactionId, + + #[error("{0} spending limit exceeded")] + LimitExceeded(String), + + #[error("Ephemeral reservation not found: {0}")] + EphemeralNotFound(String), } diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index ee04b8529a..a15a5f1b34 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -94,30 +94,157 @@ impl Limits { }) } - #[tracing::instrument(name = "limits.record_spending", skip(self))] - pub async fn record_spending( + #[tracing::instrument(name = "limits.check_and_lock_spending", skip(self))] + pub async fn check_and_lock_spending( &self, api_key_id: IdentityApiKeyId, amount_sats: i64, - transaction_id: Option, - ) -> Result<(), LimitError> { + ) -> Result { if amount_sats <= 0 { return Err(LimitError::InvalidLimitAmount); } + let mut tx = self.pool.begin().await?; + + let limits = sqlx::query!( + r#" + SELECT daily_limit_sats, weekly_limit_sats, monthly_limit_sats, annual_limit_sats + FROM api_key_limits + WHERE api_key_id = $1 + FOR UPDATE + "#, + api_key_id as IdentityApiKeyId, + ) + .fetch_optional(&mut *tx) + .await?; + + let (daily_limit, weekly_limit, monthly_limit, annual_limit) = match limits { + Some(row) => ( + row.daily_limit_sats, + row.weekly_limit_sats, + row.monthly_limit_sats, + row.annual_limit_sats, + ), + None => (None, None, None, None), + }; + + let spending = sqlx::query!( + r#" + SELECT + COALESCE(SUM(amount_sats) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours'), 0)::bigint AS "daily_spent_sats!", + COALESCE(SUM(amount_sats) FILTER (WHERE created_at > NOW() - INTERVAL '7 days'), 0)::bigint AS "weekly_spent_sats!", + COALESCE(SUM(amount_sats) FILTER (WHERE created_at > NOW() - INTERVAL '30 days'), 0)::bigint AS "monthly_spent_sats!", + COALESCE(SUM(amount_sats) FILTER (WHERE created_at > NOW() - INTERVAL '365 days'), 0)::bigint AS "annual_spent_sats!" + FROM api_key_transactions + WHERE api_key_id = $1 + AND created_at > NOW() - INTERVAL '365 days' + "#, + api_key_id as IdentityApiKeyId, + ) + .fetch_one(&mut *tx) + .await?; + + let checks = [ + ("daily", daily_limit, spending.daily_spent_sats), + ("weekly", weekly_limit, spending.weekly_spent_sats), + ("monthly", monthly_limit, spending.monthly_spent_sats), + ("annual", annual_limit, spending.annual_spent_sats), + ]; + + for (period, limit, spent) in &checks { + if let Some(limit) = limit { + if spent.saturating_add(amount_sats) > *limit { + tx.rollback().await?; + return Err(LimitError::LimitExceeded(period.to_string())); + } + } + } + + let ephemeral_id = uuid::Uuid::new_v4().to_string(); + sqlx::query!( r#" INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at) VALUES ($1, $2, $3, NOW()) - ON CONFLICT (transaction_id) DO NOTHING "#, api_key_id as IdentityApiKeyId, amount_sats, - transaction_id, + &ephemeral_id, ) - .execute(&self.pool) + .execute(&mut *tx) .await?; + tx.commit().await?; + + Ok(ephemeral_id) + } + + #[tracing::instrument(name = "limits.record_spending", skip(self))] + pub async fn record_spending( + &self, + api_key_id: IdentityApiKeyId, + amount_sats: i64, + transaction_id: Option, + ephemeral_id: Option, + ) -> Result<(), LimitError> { + if amount_sats <= 0 { + return Err(LimitError::InvalidLimitAmount); + } + + match ephemeral_id { + Some(eid) => { + let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; + + let result = sqlx::query!( + r#" + UPDATE api_key_transactions + SET transaction_id = $1 + WHERE transaction_id = $2 + AND api_key_id = $3 + "#, + &txn_id, + &eid, + api_key_id as IdentityApiKeyId, + ) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + // Check if already finalized (idempotent retry) + let already_recorded = sqlx::query_scalar!( + r#" + SELECT 1 as "exists!" + FROM api_key_transactions + WHERE transaction_id = $1 + AND api_key_id = $2 + "#, + &txn_id, + api_key_id as IdentityApiKeyId, + ) + .fetch_optional(&self.pool) + .await?; + + if already_recorded.is_none() { + return Err(LimitError::EphemeralNotFound(eid)); + } + } + } + None => { + sqlx::query!( + r#" + INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (transaction_id) DO NOTHING + "#, + api_key_id as IdentityApiKeyId, + amount_sats, + transaction_id, + ) + .execute(&self.pool) + .await?; + } + } + Ok(()) } @@ -464,14 +591,14 @@ mod tests { #[tokio::test] async fn record_spending_rejects_zero_amount() { let limits = test_limits(); - let result = limits.record_spending(test_api_key_id(), 0, None).await; + let result = limits.record_spending(test_api_key_id(), 0, None, None).await; assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); } #[tokio::test] async fn record_spending_rejects_negative_amount() { let limits = test_limits(); - let result = limits.record_spending(test_api_key_id(), -100, None).await; + let result = limits.record_spending(test_api_key_id(), -100, None, None).await; assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); } @@ -530,4 +657,32 @@ mod tests { let result = limits.set_annual_limit(test_api_key_id(), -1).await; assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); } + + #[tokio::test] + async fn check_and_lock_spending_rejects_zero_amount() { + let limits = test_limits(); + let result = limits.check_and_lock_spending(test_api_key_id(), 0).await; + assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); + } + + #[tokio::test] + async fn check_and_lock_spending_rejects_negative_amount() { + let limits = test_limits(); + let result = limits.check_and_lock_spending(test_api_key_id(), -1).await; + assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); + } + + #[tokio::test] + async fn record_spending_requires_transaction_id_with_ephemeral_id() { + let limits = test_limits(); + let result = limits + .record_spending( + test_api_key_id(), + 1000, + None, + Some("ephemeral-123".to_string()), + ) + .await; + assert!(matches!(result, Err(LimitError::MissingTransactionId))); + } } diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 0dbb93a54c..18310146d1 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -45,7 +45,6 @@ import { } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" import { ApiKeysService } from "@/services/api-keys" -import { validateSpendingLimit } from "@/domain/api-keys" const dealer = DealerPriceService() const apiKeys = ApiKeysService() @@ -124,6 +123,16 @@ const intraledgerPaymentSendWalletId = async ({ "payment.finalRecipient": JSON.stringify(paymentFlow.recipientWalletDescriptor()), }) + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: paymentFlow.btcPaymentAmount, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + const paymentSendResult = await executePaymentViaIntraledger({ paymentFlow, senderAccount, @@ -133,7 +142,16 @@ const intraledgerPaymentSendWalletId = async ({ senderUser, memo, apiKeyId, + ephemeralId, }) + + if (paymentSendResult instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } + if (paymentSendResult instanceof Error) return paymentSendResult if (senderAccount.id !== recipientAccount.id) { @@ -237,6 +255,7 @@ const executePaymentViaIntraledger = async < senderUser, memo, apiKeyId, + ephemeralId, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -246,6 +265,7 @@ const executePaymentViaIntraledger = async < senderUser: User memo: string | null apiKeyId?: ApiKeyId + ephemeralId?: EphemeralId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -254,18 +274,6 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - if (apiKeyId) { - const amount = paymentFlow.btcPaymentAmount - const limits = await apiKeys.getSpendingLimits({ - apiKeyId, - amount, - }) - if (limits instanceof Error) return limits - - const validation = validateSpendingLimit({ amount, limits }) - if (validation instanceof Error) return validation - } - const checkLimits = senderAccount.id === recipientAccount.id ? checkTradeIntraAccountLimits @@ -337,11 +345,12 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - if (apiKeyId) { + if (apiKeyId && ephemeralId) { const recordResult = await apiKeys.recordSpending({ apiKeyId, amount: paymentFlow.btcPaymentAmount, transactionId: journalId, + ephemeralId, }) if (recordResult instanceof Error) { recordExceptionInCurrentSpan({ error: recordResult }) diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index f13958cdd7..cbf84467a4 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -77,7 +77,6 @@ import { } from "@/app/wallets" import { ResourceExpiredLockServiceError } from "@/domain/lock" -import { validateSpendingLimit } from "@/domain/api-keys" const dealer = DealerPriceService() const apiKeys = ApiKeysService() @@ -120,13 +119,31 @@ export const payInvoiceByWalletId = async ({ } = validatedPaymentInputs if (paymentFlow.settlementMethod !== SettlementMethod.IntraLedger) { - return executePaymentViaLn({ + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: paymentFlow.btcPaymentAmount, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + + const paymentSendResult = await executePaymentViaLn({ decodedInvoice, paymentFlow, senderAccount, memo, apiKeyId, + ephemeralId, }) + if (paymentSendResult instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } + return paymentSendResult } const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() @@ -143,6 +160,16 @@ export const payInvoiceByWalletId = async ({ const accountValidator = AccountValidator(recipientAccount) if (accountValidator instanceof Error) return accountValidator + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: paymentFlow.btcPaymentAmount, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + const paymentSendResult = await executePaymentViaIntraledger({ paymentFlow, senderWalletId, @@ -150,7 +177,14 @@ export const payInvoiceByWalletId = async ({ recipientAccount, memo, apiKeyId, + ephemeralId, }) + if (paymentSendResult instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } if (paymentSendResult instanceof Error) return paymentSendResult if (senderAccount.id !== recipientAccount.id) { @@ -206,13 +240,31 @@ const payNoAmountInvoiceByWalletId = async ({ } = validatedNoAmountPaymentInputs if (paymentFlow.settlementMethod !== SettlementMethod.IntraLedger) { - return executePaymentViaLn({ + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: paymentFlow.btcPaymentAmount, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + + const paymentSendResult = await executePaymentViaLn({ decodedInvoice, paymentFlow, senderAccount, memo, apiKeyId, + ephemeralId, }) + if (paymentSendResult instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } + return paymentSendResult } const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() @@ -230,6 +282,16 @@ const payNoAmountInvoiceByWalletId = async ({ const accountValidator = AccountValidator(recipientAccount) if (accountValidator instanceof Error) return accountValidator + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: paymentFlow.btcPaymentAmount, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + const paymentSendResult = await executePaymentViaIntraledger({ paymentFlow, senderWalletId, @@ -237,7 +299,14 @@ const payNoAmountInvoiceByWalletId = async ({ recipientAccount, memo, apiKeyId, + ephemeralId, }) + if (paymentSendResult instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } if (paymentSendResult instanceof Error) return paymentSendResult if (senderAccount.id !== recipientAccount.id) { @@ -442,6 +511,7 @@ const executePaymentViaIntraledger = async < recipientAccount, memo, apiKeyId, + ephemeralId, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -449,6 +519,7 @@ const executePaymentViaIntraledger = async < recipientAccount: Account memo: string | null apiKeyId?: ApiKeyId + ephemeralId?: EphemeralId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -457,19 +528,6 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - if (apiKeyId) { - const amount = paymentFlow.btcPaymentAmount - const limits = await apiKeys.getSpendingLimits({ - apiKeyId, - amount, - }) - - if (limits instanceof Error) return limits - - const validation = validateSpendingLimit({ amount, limits }) - if (validation instanceof Error) return validation - } - const paymentHash = paymentFlow.paymentHashForFlow() if (paymentHash instanceof Error) return paymentHash @@ -570,11 +628,12 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - if (apiKeyId) { + if (apiKeyId && ephemeralId) { const recordResult = await apiKeys.recordSpending({ apiKeyId, amount: paymentFlow.btcPaymentAmount, transactionId: journalId, + ephemeralId, }) if (recordResult instanceof Error) { recordExceptionInCurrentSpan({ error: recordResult }) @@ -763,12 +822,14 @@ const executePaymentViaLn = async ({ senderAccount, memo, apiKeyId, + ephemeralId, }: { decodedInvoice: LnInvoice paymentFlow: PaymentFlow senderAccount: Account memo: string | null apiKeyId?: ApiKeyId + ephemeralId?: EphemeralId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.Lightning, @@ -779,19 +840,6 @@ const executePaymentViaLn = async ({ const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - if (apiKeyId) { - const amount = paymentFlow.btcPaymentAmount - const limits = await apiKeys.getSpendingLimits({ - apiKeyId, - amount, - }) - - if (limits instanceof Error) return limits - - const validation = validateSpendingLimit({ amount, limits }) - if (validation instanceof Error) return validation - } - const limitCheck = await checkWithdrawalLimits({ amount: paymentFlow.usdPaymentAmount, accountId: senderAccount.id, @@ -851,11 +899,12 @@ const executePaymentViaLn = async ({ return paymentSendAttemptResult.error case PaymentSendAttemptResultType.Pending: - if (apiKeyId) { + if (apiKeyId && ephemeralId) { const recordResult = await apiKeys.recordSpending({ apiKeyId, amount: paymentFlow.btcPaymentAmount, transactionId: paymentSendAttemptResult.journalId, + ephemeralId, }) if (recordResult instanceof Error) { recordExceptionInCurrentSpan({ error: recordResult }) @@ -873,11 +922,12 @@ const executePaymentViaLn = async ({ }) default: - if (apiKeyId) { + if (apiKeyId && ephemeralId) { const recordResult = await apiKeys.recordSpending({ apiKeyId, amount: paymentFlow.btcPaymentAmount, transactionId: paymentSendAttemptResult.journalId, + ephemeralId, }) if (recordResult instanceof Error) { recordExceptionInCurrentSpan({ error: recordResult }) diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index bf77155399..eab0cebfc6 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -56,7 +56,6 @@ import { recordExceptionInCurrentSpan, } from "@/services/tracing" import { ApiKeysService } from "@/services/api-keys" -import { validateSpendingLimit } from "@/domain/api-keys" const { dustThreshold } = getOnChainWalletConfig() const dealer = DealerPriceService() @@ -180,13 +179,36 @@ const payOnChainByWalletId = async ({ .withAmount(amount) .withConversion(withConversionArgs) - return executePaymentViaIntraledger({ + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const proposedAmounts = await builder.proposedAmounts() + if (proposedAmounts instanceof Error) return proposedAmounts + + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: proposedAmounts.btc, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + + const result = await executePaymentViaIntraledger({ builder, senderAccount, memo, sendAll, apiKeyId, + ephemeralId, }) + + if (result instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } + + return result } const builder = withSenderBuilder @@ -194,7 +216,20 @@ const payOnChainByWalletId = async ({ .withAmount(amount) .withConversion(withConversionArgs) - return executePaymentViaOnChain({ + let ephemeralId: EphemeralId | undefined + if (apiKeyId) { + const proposedAmounts = await builder.proposedAmounts() + if (proposedAmounts instanceof Error) return proposedAmounts + + const lockResult = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount: proposedAmounts.btc, + }) + if (lockResult instanceof Error) return lockResult + ephemeralId = lockResult + } + + const result = await executePaymentViaOnChain({ builder, senderDisplayCurrency: senderAccount.displayCurrency, speed, @@ -202,7 +237,17 @@ const payOnChainByWalletId = async ({ sendAll, logger: onchainLogger, apiKeyId, + ephemeralId, }) + + if (result instanceof Error && ephemeralId) { + const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ error: reverseResult }) + } + } + + return result } export const payOnChainByWalletIdForBtcWallet = async ( @@ -258,12 +303,14 @@ const executePaymentViaIntraledger = async < memo, sendAll, apiKeyId, + ephemeralId, }: { builder: OPFBWithConversion | OPFBWithError senderAccount: Account memo: string | null sendAll: boolean apiKeyId?: ApiKeyId + ephemeralId?: EphemeralId }): Promise => { const paymentFlow = await builder.withoutMinerFee() if (paymentFlow instanceof Error) return paymentFlow @@ -300,18 +347,6 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - if (apiKeyId) { - const amount = paymentFlow.btcPaymentAmount - const limits = await apiKeys.getSpendingLimits({ - apiKeyId, - amount, - }) - if (limits instanceof Error) return limits - - const validation = validateSpendingLimit({ amount, limits }) - if (validation instanceof Error) return validation - } - const checkLimits = senderAccount.id === recipientAccount.id ? checkTradeIntraAccountLimits @@ -380,11 +415,12 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - if (apiKeyId) { + if (apiKeyId && ephemeralId) { const recordResult = await apiKeys.recordSpending({ apiKeyId, amount: paymentFlow.btcPaymentAmount, transactionId: journalId, + ephemeralId, }) if (recordResult instanceof Error) { recordExceptionInCurrentSpan({ error: recordResult }) @@ -558,6 +594,7 @@ const executePaymentViaOnChain = async < sendAll, logger, apiKeyId, + ephemeralId, }: { builder: OPFBWithConversion | OPFBWithError senderDisplayCurrency: DisplayCurrency @@ -566,6 +603,7 @@ const executePaymentViaOnChain = async < sendAll: boolean logger: Logger apiKeyId?: ApiKeyId + ephemeralId?: EphemeralId }): Promise => { const senderWalletDescriptor = await builder.senderWalletDescriptor() if (senderWalletDescriptor instanceof Error) return senderWalletDescriptor @@ -577,18 +615,6 @@ const executePaymentViaOnChain = async < const priceRatioForLimits = await getPriceRatioForLimits(proposedAmounts) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - if (apiKeyId) { - const amount = proposedAmounts.btc - const limits = await apiKeys.getSpendingLimits({ - apiKeyId, - amount, - }) - if (limits instanceof Error) return limits - - const validation = validateSpendingLimit({ amount, limits }) - if (validation instanceof Error) return validation - } - const limitCheck = await checkWithdrawalLimits({ amount: proposedAmounts.usd, accountId: senderWalletDescriptor.accountId, @@ -620,11 +646,12 @@ const executePaymentViaOnChain = async < }) if (walletTransaction instanceof Error) return walletTransaction - if (apiKeyId) { + if (apiKeyId && ephemeralId) { const recordResult = await apiKeys.recordSpending({ apiKeyId, amount: proposedAmounts.btc, transactionId: journalId, + ephemeralId, }) if (recordResult instanceof Error) { recordExceptionInCurrentSpan({ error: recordResult }) diff --git a/core/api/src/domain/api-keys/index.ts b/core/api/src/domain/api-keys/index.ts index 63365aab92..a079f46484 100644 --- a/core/api/src/domain/api-keys/index.ts +++ b/core/api/src/domain/api-keys/index.ts @@ -1,2 +1 @@ export * from "./errors" -export * from "./spending-limits" diff --git a/core/api/src/domain/api-keys/index.types.d.ts b/core/api/src/domain/api-keys/index.types.d.ts index 491878b4d0..1514420817 100644 --- a/core/api/src/domain/api-keys/index.types.d.ts +++ b/core/api/src/domain/api-keys/index.types.d.ts @@ -1,22 +1,21 @@ type ApiKeyId = string & { readonly brand: unique symbol } -type ApiKeysServiceError = import("./errors").ApiKeysServiceError +type EphemeralId = string & { readonly brand: unique symbol } -type SpendingLimits = import("./spending-limits").SpendingLimits +type ApiKeysServiceError = import("./errors").ApiKeysServiceError interface IApiKeysService { - getSpendingLimits(args: { + checkAndLockSpending(args: { apiKeyId: ApiKeyId amount: BtcPaymentAmount - }): Promise + }): Promise recordSpending(args: { apiKeyId: ApiKeyId amount: BtcPaymentAmount transactionId: LedgerJournalId + ephemeralId: EphemeralId }): Promise - reverseSpending(args: { - transactionId: LedgerJournalId - }): Promise + reverseSpending(args: { transactionId: string }): Promise } diff --git a/core/api/src/domain/api-keys/spending-limits.ts b/core/api/src/domain/api-keys/spending-limits.ts deleted file mode 100644 index a631d831ca..0000000000 --- a/core/api/src/domain/api-keys/spending-limits.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ApiKeyLimitExceededError } from "./errors" - -export type SpendingLimits = { - dailyLimitSats: Satoshis | null - weeklyLimitSats: Satoshis | null - monthlyLimitSats: Satoshis | null - annualLimitSats: Satoshis | null - dailySpentSats: Satoshis - weeklySpentSats: Satoshis - monthlySpentSats: Satoshis - annualSpentSats: Satoshis -} - -export const validateSpendingLimit = ({ - amount, - limits, -}: { - amount: BtcPaymentAmount - limits: SpendingLimits -}): true | ApiKeyLimitExceededError => { - const amountSats = Number(amount.amount) as Satoshis - const checks = [ - { - period: "daily", - limit: limits.dailyLimitSats, - spent: limits.dailySpentSats, - }, - { - period: "weekly", - limit: limits.weeklyLimitSats, - spent: limits.weeklySpentSats, - }, - { - period: "monthly", - limit: limits.monthlyLimitSats, - spent: limits.monthlySpentSats, - }, - { - period: "annual", - limit: limits.annualLimitSats, - spent: limits.annualSpentSats, - }, - ] as const - - for (const { period, limit, spent } of checks) { - if (limit && limit - spent < amountSats) { - return new ApiKeyLimitExceededError( - `${period} spending limit exceeded, remaining: ${limit - spent} sats`, - ) - } - } - - return true -} diff --git a/core/api/src/services/api-keys/convert.ts b/core/api/src/services/api-keys/convert.ts deleted file mode 100644 index 241997d3af..0000000000 --- a/core/api/src/services/api-keys/convert.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CheckSpendingLimitResponse } from "./proto/api_keys_pb" - -import { toSats } from "@/domain/bitcoin" -import { SpendingLimits } from "@/domain/api-keys" - -const toSatsOrNull = (value: number | undefined): Satoshis | null => - value !== undefined ? toSats(value) : null - -export const grpcSpendingLimitsToSpendingLimits = ( - response: CheckSpendingLimitResponse, -): SpendingLimits => ({ - dailyLimitSats: response.hasDailyLimitSats() - ? toSatsOrNull(response.getDailyLimitSats()) - : null, - weeklyLimitSats: response.hasWeeklyLimitSats() - ? toSatsOrNull(response.getWeeklyLimitSats()) - : null, - monthlyLimitSats: response.hasMonthlyLimitSats() - ? toSatsOrNull(response.getMonthlyLimitSats()) - : null, - annualLimitSats: response.hasAnnualLimitSats() - ? toSatsOrNull(response.getAnnualLimitSats()) - : null, - dailySpentSats: toSats(response.getDailySpentSats()), - weeklySpentSats: toSats(response.getWeeklySpentSats()), - monthlySpentSats: toSats(response.getMonthlySpentSats()), - annualSpentSats: toSats(response.getAnnualSpentSats()), -}) diff --git a/core/api/src/services/api-keys/errors.ts b/core/api/src/services/api-keys/errors.ts index ac5c2a3d59..109db199e5 100644 --- a/core/api/src/services/api-keys/errors.ts +++ b/core/api/src/services/api-keys/errors.ts @@ -1,5 +1,6 @@ import { ApiKeyInvalidLimitError, + ApiKeyLimitExceededError, ApiKeySpendingRecordError, InvalidApiKeyIdError, UnknownApiKeysServiceError, @@ -12,6 +13,9 @@ export const handleCommonApiKeysErrors = (err: Error | string | unknown) => { const match = (knownErrDetail: RegExp): boolean => knownErrDetail.test(errMsg) switch (true) { + case match(KnownApiKeysErrorMessages.LimitExceeded): + return new ApiKeyLimitExceededError(errMsg) + case match(KnownApiKeysErrorMessages.InvalidApiKeyId): return new InvalidApiKeyIdError(errMsg) @@ -27,8 +31,10 @@ export const handleCommonApiKeysErrors = (err: Error | string | unknown) => { } export const KnownApiKeysErrorMessages = { + LimitExceeded: /spending limit exceeded/, InvalidApiKeyId: /Invalid API key ID/, - InvalidAmountError: /Negative amount not allowed|Amount must be positive/, + InvalidAmountError: + /Negative amount not allowed|Amount must be positive|Invalid limit amount \(must be positive\)/, InvalidLimitError: /Invalid limit value/, DatabaseError: /Database/, } as const diff --git a/core/api/src/services/api-keys/grpc-client.ts b/core/api/src/services/api-keys/grpc-client.ts index c5d5ab9fec..1723645e34 100644 --- a/core/api/src/services/api-keys/grpc-client.ts +++ b/core/api/src/services/api-keys/grpc-client.ts @@ -5,10 +5,8 @@ import { credentials, Metadata } from "@grpc/grpc-js" import { ApiKeysServiceClient } from "./proto/api_keys_grpc_pb" import { - CheckSpendingLimitRequest, - CheckSpendingLimitResponse, - GetSpendingSummaryRequest, - GetSpendingSummaryResponse, + CheckAndLockSpendingRequest, + CheckAndLockSpendingResponse, RecordSpendingRequest, RecordSpendingResponse, ReverseSpendingRequest, @@ -26,17 +24,11 @@ const apiKeysClient = new ApiKeysServiceClient( export const apiKeysMetadata = new Metadata() -export const checkSpendingLimit = promisify< - CheckSpendingLimitRequest, +export const checkAndLockSpending = promisify< + CheckAndLockSpendingRequest, Metadata, - CheckSpendingLimitResponse ->(apiKeysClient.checkSpendingLimit.bind(apiKeysClient)) - -export const getSpendingSummary = promisify< - GetSpendingSummaryRequest, - Metadata, - GetSpendingSummaryResponse ->(apiKeysClient.getSpendingSummary.bind(apiKeysClient)) + CheckAndLockSpendingResponse +>(apiKeysClient.checkAndLockSpending.bind(apiKeysClient)) export const recordSpending = promisify< RecordSpendingRequest, diff --git a/core/api/src/services/api-keys/index.ts b/core/api/src/services/api-keys/index.ts index 65c1300b7e..ee51bf0271 100644 --- a/core/api/src/services/api-keys/index.ts +++ b/core/api/src/services/api-keys/index.ts @@ -1,7 +1,5 @@ -import { grpcSpendingLimitsToSpendingLimits } from "./convert" - import { - CheckSpendingLimitRequest, + CheckAndLockSpendingRequest, RecordSpendingRequest, ReverseSpendingRequest, } from "./proto/api_keys_pb" @@ -12,30 +10,29 @@ import { handleCommonApiKeysErrors } from "./errors" import { baseLogger } from "@/services/logger" import { wrapAsyncFunctionsToRunInSpan } from "@/services/tracing" -import { SpendingLimits } from "@/domain/api-keys" export const ApiKeysService = (): IApiKeysService => { - const getSpendingLimits = async ({ + const checkAndLockSpending = async ({ apiKeyId, amount, }: { apiKeyId: ApiKeyId amount: BtcPaymentAmount - }): Promise => { + }): Promise => { try { const amountSats = Number(amount.amount) - const request = new CheckSpendingLimitRequest() + const request = new CheckAndLockSpendingRequest() request.setApiKeyId(apiKeyId) request.setAmountSats(amountSats) - const response = await apiKeysGrpc.checkSpendingLimit( + const response = await apiKeysGrpc.checkAndLockSpending( request, apiKeysGrpc.apiKeysMetadata, ) - return grpcSpendingLimitsToSpendingLimits(response) + return response.getEphemeralId() as EphemeralId } catch (err) { - baseLogger.error({ err, apiKeyId, amount }, "Failed to get API key spending limits") + baseLogger.error({ err, apiKeyId, amount }, "Failed to check and lock spending") return handleCommonApiKeysErrors(err) } } @@ -44,10 +41,12 @@ export const ApiKeysService = (): IApiKeysService => { apiKeyId, amount, transactionId, + ephemeralId, }: { apiKeyId: ApiKeyId amount: BtcPaymentAmount transactionId: LedgerJournalId + ephemeralId: EphemeralId }): Promise => { try { const amountSats = Number(amount.amount) @@ -55,13 +54,14 @@ export const ApiKeysService = (): IApiKeysService => { request.setApiKeyId(apiKeyId) request.setAmountSats(amountSats) request.setTransactionId(transactionId) + request.setEphemeralId(ephemeralId) await apiKeysGrpc.recordSpending(request, apiKeysGrpc.apiKeysMetadata) return true } catch (err) { baseLogger.error( - { err, apiKeyId, amount, transactionId }, + { err, apiKeyId, amount, transactionId, ephemeralId }, "Failed to record API key spending", ) return handleCommonApiKeysErrors(err) @@ -71,7 +71,7 @@ export const ApiKeysService = (): IApiKeysService => { const reverseSpending = async ({ transactionId, }: { - transactionId: LedgerJournalId + transactionId: string }): Promise => { try { const request = new ReverseSpendingRequest() @@ -89,7 +89,7 @@ export const ApiKeysService = (): IApiKeysService => { return wrapAsyncFunctionsToRunInSpan({ namespace: "services.api-keys", fns: { - getSpendingLimits, + checkAndLockSpending, recordSpending, reverseSpending, }, diff --git a/core/api/src/services/api-keys/proto/api_keys.proto b/core/api/src/services/api-keys/proto/api_keys.proto index 6878dc496a..ffd4bd88a6 100644 --- a/core/api/src/services/api-keys/proto/api_keys.proto +++ b/core/api/src/services/api-keys/proto/api_keys.proto @@ -4,6 +4,7 @@ package services.api_keys.v1; service ApiKeysService { rpc CheckSpendingLimit (CheckSpendingLimitRequest) returns (CheckSpendingLimitResponse) {} + rpc CheckAndLockSpending (CheckAndLockSpendingRequest) returns (CheckAndLockSpendingResponse) {} rpc GetSpendingSummary (GetSpendingSummaryRequest) returns (GetSpendingSummaryResponse) {} rpc RecordSpending (RecordSpendingRequest) returns (RecordSpendingResponse) {} rpc ReverseSpending (ReverseSpendingRequest) returns (ReverseSpendingResponse) {} @@ -30,6 +31,15 @@ message CheckSpendingLimitResponse { optional int64 remaining_annual_sats = 13; } +message CheckAndLockSpendingRequest { + string api_key_id = 1; + int64 amount_sats = 2; +} + +message CheckAndLockSpendingResponse { + string ephemeral_id = 1; +} + message GetSpendingSummaryRequest { string api_key_id = 1; } @@ -53,6 +63,7 @@ message RecordSpendingRequest { string api_key_id = 1; int64 amount_sats = 2; optional string transaction_id = 3; + optional string ephemeral_id = 4; } message RecordSpendingResponse { diff --git a/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts index 30a87f50ac..10e4e9bba2 100644 --- a/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts +++ b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.d.ts @@ -9,6 +9,7 @@ import * as api_keys_pb from "./api_keys_pb"; interface IApiKeysServiceService extends grpc.ServiceDefinition { checkSpendingLimit: IApiKeysServiceService_ICheckSpendingLimit; + checkAndLockSpending: IApiKeysServiceService_ICheckAndLockSpending; getSpendingSummary: IApiKeysServiceService_IGetSpendingSummary; recordSpending: IApiKeysServiceService_IRecordSpending; reverseSpending: IApiKeysServiceService_IReverseSpending; @@ -23,6 +24,15 @@ interface IApiKeysServiceService_ICheckSpendingLimit extends grpc.MethodDefiniti responseSerialize: grpc.serialize; responseDeserialize: grpc.deserialize; } +interface IApiKeysServiceService_ICheckAndLockSpending extends grpc.MethodDefinition { + path: "/services.api_keys.v1.ApiKeysService/CheckAndLockSpending"; + requestStream: false; + responseStream: false; + requestSerialize: grpc.serialize; + requestDeserialize: grpc.deserialize; + responseSerialize: grpc.serialize; + responseDeserialize: grpc.deserialize; +} interface IApiKeysServiceService_IGetSpendingSummary extends grpc.MethodDefinition { path: "/services.api_keys.v1.ApiKeysService/GetSpendingSummary"; requestStream: false; @@ -55,6 +65,7 @@ export const ApiKeysServiceService: IApiKeysServiceService; export interface IApiKeysServiceServer extends grpc.UntypedServiceImplementation { checkSpendingLimit: grpc.handleUnaryCall; + checkAndLockSpending: grpc.handleUnaryCall; getSpendingSummary: grpc.handleUnaryCall; recordSpending: grpc.handleUnaryCall; reverseSpending: grpc.handleUnaryCall; @@ -64,6 +75,9 @@ export interface IApiKeysServiceClient { checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + checkAndLockSpending(request: api_keys_pb.CheckAndLockSpendingRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckAndLockSpendingResponse) => void): grpc.ClientUnaryCall; + checkAndLockSpending(request: api_keys_pb.CheckAndLockSpendingRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckAndLockSpendingResponse) => void): grpc.ClientUnaryCall; + checkAndLockSpending(request: api_keys_pb.CheckAndLockSpendingRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckAndLockSpendingResponse) => void): grpc.ClientUnaryCall; getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; @@ -80,6 +94,9 @@ export class ApiKeysServiceClient extends grpc.Client implements IApiKeysService public checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; public checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; public checkSpendingLimit(request: api_keys_pb.CheckSpendingLimitRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckSpendingLimitResponse) => void): grpc.ClientUnaryCall; + public checkAndLockSpending(request: api_keys_pb.CheckAndLockSpendingRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckAndLockSpendingResponse) => void): grpc.ClientUnaryCall; + public checkAndLockSpending(request: api_keys_pb.CheckAndLockSpendingRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckAndLockSpendingResponse) => void): grpc.ClientUnaryCall; + public checkAndLockSpending(request: api_keys_pb.CheckAndLockSpendingRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.CheckAndLockSpendingResponse) => void): grpc.ClientUnaryCall; public getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; public getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; public getSpendingSummary(request: api_keys_pb.GetSpendingSummaryRequest, metadata: grpc.Metadata, options: Partial, callback: (error: grpc.ServiceError | null, response: api_keys_pb.GetSpendingSummaryResponse) => void): grpc.ClientUnaryCall; diff --git a/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js index 250ef7e9e0..c81e5bd9c2 100644 --- a/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js +++ b/core/api/src/services/api-keys/proto/api_keys_grpc_pb.js @@ -4,6 +4,28 @@ var grpc = require('@grpc/grpc-js'); var api_keys_pb = require('./api_keys_pb.js'); +function serialize_services_api_keys_v1_CheckAndLockSpendingRequest(arg) { + if (!(arg instanceof api_keys_pb.CheckAndLockSpendingRequest)) { + throw new Error('Expected argument of type services.api_keys.v1.CheckAndLockSpendingRequest'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_CheckAndLockSpendingRequest(buffer_arg) { + return api_keys_pb.CheckAndLockSpendingRequest.deserializeBinary(new Uint8Array(buffer_arg)); +} + +function serialize_services_api_keys_v1_CheckAndLockSpendingResponse(arg) { + if (!(arg instanceof api_keys_pb.CheckAndLockSpendingResponse)) { + throw new Error('Expected argument of type services.api_keys.v1.CheckAndLockSpendingResponse'); + } + return Buffer.from(arg.serializeBinary()); +} + +function deserialize_services_api_keys_v1_CheckAndLockSpendingResponse(buffer_arg) { + return api_keys_pb.CheckAndLockSpendingResponse.deserializeBinary(new Uint8Array(buffer_arg)); +} + function serialize_services_api_keys_v1_CheckSpendingLimitRequest(arg) { if (!(arg instanceof api_keys_pb.CheckSpendingLimitRequest)) { throw new Error('Expected argument of type services.api_keys.v1.CheckSpendingLimitRequest'); @@ -105,6 +127,17 @@ var ApiKeysServiceService = exports.ApiKeysServiceService = { responseSerialize: serialize_services_api_keys_v1_CheckSpendingLimitResponse, responseDeserialize: deserialize_services_api_keys_v1_CheckSpendingLimitResponse, }, + checkAndLockSpending: { + path: '/services.api_keys.v1.ApiKeysService/CheckAndLockSpending', + requestStream: false, + responseStream: false, + requestType: api_keys_pb.CheckAndLockSpendingRequest, + responseType: api_keys_pb.CheckAndLockSpendingResponse, + requestSerialize: serialize_services_api_keys_v1_CheckAndLockSpendingRequest, + requestDeserialize: deserialize_services_api_keys_v1_CheckAndLockSpendingRequest, + responseSerialize: serialize_services_api_keys_v1_CheckAndLockSpendingResponse, + responseDeserialize: deserialize_services_api_keys_v1_CheckAndLockSpendingResponse, + }, getSpendingSummary: { path: '/services.api_keys.v1.ApiKeysService/GetSpendingSummary', requestStream: false, diff --git a/core/api/src/services/api-keys/proto/api_keys_pb.d.ts b/core/api/src/services/api-keys/proto/api_keys_pb.d.ts index 892a5dbaab..8f5ded87cf 100644 --- a/core/api/src/services/api-keys/proto/api_keys_pb.d.ts +++ b/core/api/src/services/api-keys/proto/api_keys_pb.d.ts @@ -109,6 +109,49 @@ export namespace CheckSpendingLimitResponse { } } +export class CheckAndLockSpendingRequest extends jspb.Message { + getApiKeyId(): string; + setApiKeyId(value: string): CheckAndLockSpendingRequest; + getAmountSats(): number; + setAmountSats(value: number): CheckAndLockSpendingRequest; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): CheckAndLockSpendingRequest.AsObject; + static toObject(includeInstance: boolean, msg: CheckAndLockSpendingRequest): CheckAndLockSpendingRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: CheckAndLockSpendingRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): CheckAndLockSpendingRequest; + static deserializeBinaryFromReader(message: CheckAndLockSpendingRequest, reader: jspb.BinaryReader): CheckAndLockSpendingRequest; +} + +export namespace CheckAndLockSpendingRequest { + export type AsObject = { + apiKeyId: string, + amountSats: number, + } +} + +export class CheckAndLockSpendingResponse extends jspb.Message { + getEphemeralId(): string; + setEphemeralId(value: string): CheckAndLockSpendingResponse; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): CheckAndLockSpendingResponse.AsObject; + static toObject(includeInstance: boolean, msg: CheckAndLockSpendingResponse): CheckAndLockSpendingResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: CheckAndLockSpendingResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): CheckAndLockSpendingResponse; + static deserializeBinaryFromReader(message: CheckAndLockSpendingResponse, reader: jspb.BinaryReader): CheckAndLockSpendingResponse; +} + +export namespace CheckAndLockSpendingResponse { + export type AsObject = { + ephemeralId: string, + } +} + export class GetSpendingSummaryRequest extends jspb.Message { getApiKeyId(): string; setApiKeyId(value: string): GetSpendingSummaryRequest; @@ -217,6 +260,11 @@ export class RecordSpendingRequest extends jspb.Message { getTransactionId(): string | undefined; setTransactionId(value: string): RecordSpendingRequest; + hasEphemeralId(): boolean; + clearEphemeralId(): void; + getEphemeralId(): string | undefined; + setEphemeralId(value: string): RecordSpendingRequest; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): RecordSpendingRequest.AsObject; static toObject(includeInstance: boolean, msg: RecordSpendingRequest): RecordSpendingRequest.AsObject; @@ -232,6 +280,7 @@ export namespace RecordSpendingRequest { apiKeyId: string, amountSats: number, transactionId?: string, + ephemeralId?: string, } } diff --git a/core/api/src/services/api-keys/proto/api_keys_pb.js b/core/api/src/services/api-keys/proto/api_keys_pb.js index 9222de34d0..d665051bd6 100644 --- a/core/api/src/services/api-keys/proto/api_keys_pb.js +++ b/core/api/src/services/api-keys/proto/api_keys_pb.js @@ -21,6 +21,8 @@ var global = (function () { return this; }).call(null) || Function('return this')(); +goog.exportSymbol('proto.services.api_keys.v1.CheckAndLockSpendingRequest', null, global); +goog.exportSymbol('proto.services.api_keys.v1.CheckAndLockSpendingResponse', null, global); goog.exportSymbol('proto.services.api_keys.v1.CheckSpendingLimitRequest', null, global); goog.exportSymbol('proto.services.api_keys.v1.CheckSpendingLimitResponse', null, global); goog.exportSymbol('proto.services.api_keys.v1.GetSpendingSummaryRequest', null, global); @@ -71,6 +73,48 @@ if (goog.DEBUG && !COMPILED) { */ proto.services.api_keys.v1.CheckSpendingLimitResponse.displayName = 'proto.services.api_keys.v1.CheckSpendingLimitResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.CheckAndLockSpendingRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.CheckAndLockSpendingRequest.displayName = 'proto.services.api_keys.v1.CheckAndLockSpendingRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.services.api_keys.v1.CheckAndLockSpendingResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.services.api_keys.v1.CheckAndLockSpendingResponse.displayName = 'proto.services.api_keys.v1.CheckAndLockSpendingResponse'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -994,6 +1038,296 @@ proto.services.api_keys.v1.CheckSpendingLimitResponse.prototype.hasRemainingAnnu +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.CheckAndLockSpendingRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.toObject = function(includeInstance, msg) { + var f, obj = { +apiKeyId: jspb.Message.getFieldWithDefault(msg, 1, ""), +amountSats: jspb.Message.getFieldWithDefault(msg, 2, 0) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.CheckAndLockSpendingRequest; + return proto.services.api_keys.v1.CheckAndLockSpendingRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setApiKeyId(value); + break; + case 2: + var value = /** @type {number} */ (reader.readInt64()); + msg.setAmountSats(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.CheckAndLockSpendingRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getApiKeyId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getAmountSats(); + if (f !== 0) { + writer.writeInt64( + 2, + f + ); + } +}; + + +/** + * optional string api_key_id = 1; + * @return {string} + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.prototype.getApiKeyId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} returns this + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.prototype.setApiKeyId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * optional int64 amount_sats = 2; + * @return {number} + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.prototype.getAmountSats = function() { + return /** @type {number} */ (jspb.Message.getFieldWithDefault(this, 2, 0)); +}; + + +/** + * @param {number} value + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingRequest} returns this + */ +proto.services.api_keys.v1.CheckAndLockSpendingRequest.prototype.setAmountSats = function(value) { + return jspb.Message.setProto3IntField(this, 2, value); +}; + + + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.prototype.toObject = function(opt_includeInstance) { + return proto.services.api_keys.v1.CheckAndLockSpendingResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.services.api_keys.v1.CheckAndLockSpendingResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.toObject = function(includeInstance, msg) { + var f, obj = { +ephemeralId: jspb.Message.getFieldWithDefault(msg, 1, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingResponse} + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.services.api_keys.v1.CheckAndLockSpendingResponse; + return proto.services.api_keys.v1.CheckAndLockSpendingResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.services.api_keys.v1.CheckAndLockSpendingResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingResponse} + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setEphemeralId(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.services.api_keys.v1.CheckAndLockSpendingResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.services.api_keys.v1.CheckAndLockSpendingResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getEphemeralId(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } +}; + + +/** + * optional string ephemeral_id = 1; + * @return {string} + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.prototype.getEphemeralId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.CheckAndLockSpendingResponse} returns this + */ +proto.services.api_keys.v1.CheckAndLockSpendingResponse.prototype.setEphemeralId = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + + + + if (jspb.Message.GENERATE_TO_OBJECT) { /** * Creates an object representation of this proto. @@ -1759,7 +2093,8 @@ proto.services.api_keys.v1.RecordSpendingRequest.toObject = function(includeInst var f, obj = { apiKeyId: jspb.Message.getFieldWithDefault(msg, 1, ""), amountSats: jspb.Message.getFieldWithDefault(msg, 2, 0), -transactionId: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f +transactionId: (f = jspb.Message.getField(msg, 3)) == null ? undefined : f, +ephemeralId: (f = jspb.Message.getField(msg, 4)) == null ? undefined : f }; if (includeInstance) { @@ -1808,6 +2143,10 @@ proto.services.api_keys.v1.RecordSpendingRequest.deserializeBinaryFromReader = f var value = /** @type {string} */ (reader.readString()); msg.setTransactionId(value); break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setEphemeralId(value); + break; default: reader.skipField(); break; @@ -1858,6 +2197,13 @@ proto.services.api_keys.v1.RecordSpendingRequest.serializeBinaryToWriter = funct f ); } + f = /** @type {string} */ (jspb.Message.getField(message, 4)); + if (f != null) { + writer.writeString( + 4, + f + ); + } }; @@ -1933,6 +2279,42 @@ proto.services.api_keys.v1.RecordSpendingRequest.prototype.hasTransactionId = fu }; +/** + * optional string ephemeral_id = 4; + * @return {string} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.getEphemeralId = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} returns this + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.setEphemeralId = function(value) { + return jspb.Message.setField(this, 4, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.services.api_keys.v1.RecordSpendingRequest} returns this + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.clearEphemeralId = function() { + return jspb.Message.setField(this, 4, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.services.api_keys.v1.RecordSpendingRequest.prototype.hasEphemeralId = function() { + return jspb.Message.getField(this, 4) != null; +}; + + From 367e452baae48b1992d15da42085c34483ad4de2 Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 23 Mar 2026 12:48:09 +0300 Subject: [PATCH 14/33] chore: fix lint and formatting --- apps/dashboard/app/api-keys/server-actions.ts | 24 +++++++++++++++---- core/api-keys/src/grpc/server/mod.rs | 10 +++++--- core/api-keys/src/limits/mod.rs | 8 +++++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index c0a005ac15..fb502d30b6 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -177,7 +177,11 @@ export const setDailyLimit = async ({ } try { - await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Daily, limitSats: dailyLimitSats }) + await setApiKeyLimit({ + id, + limitTimeWindow: LimitTimeWindow.Daily, + limitSats: dailyLimitSats, + }) } catch (err) { console.log("error in setApiKeyLimit (daily) ", err) throw new Error("Failed to set API key daily limit") @@ -208,7 +212,11 @@ export const setWeeklyLimit = async ({ } try { - await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Weekly, limitSats: weeklyLimitSats }) + await setApiKeyLimit({ + id, + limitTimeWindow: LimitTimeWindow.Weekly, + limitSats: weeklyLimitSats, + }) } catch (err) { console.log("error in setApiKeyLimit (weekly) ", err) throw new Error("Failed to set API key weekly limit") @@ -239,7 +247,11 @@ export const setMonthlyLimit = async ({ } try { - await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Monthly, limitSats: monthlyLimitSats }) + await setApiKeyLimit({ + id, + limitTimeWindow: LimitTimeWindow.Monthly, + limitSats: monthlyLimitSats, + }) } catch (err) { console.log("error in setApiKeyLimit (monthly) ", err) throw new Error("Failed to set API key monthly limit") @@ -270,7 +282,11 @@ export const setAnnualLimit = async ({ } try { - await setApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Annual, limitSats: annualLimitSats }) + await setApiKeyLimit({ + id, + limitTimeWindow: LimitTimeWindow.Annual, + limitSats: annualLimitSats, + }) } catch (err) { console.log("error in setApiKeyLimit (annual) ", err) throw new Error("Failed to set API key annual limit") diff --git a/core/api-keys/src/grpc/server/mod.rs b/core/api-keys/src/grpc/server/mod.rs index 4cbeed0e89..33d3b5ee03 100644 --- a/core/api-keys/src/grpc/server/mod.rs +++ b/core/api-keys/src/grpc/server/mod.rs @@ -11,7 +11,11 @@ use tracing::{grpc, instrument}; use self::proto::{api_keys_service_server::ApiKeysService, *}; use super::config::*; -use crate::{app::{ApiKeysApp, ApplicationError}, identity::IdentityApiKeyId, limits::LimitError}; +use crate::{ + app::{ApiKeysApp, ApplicationError}, + identity::IdentityApiKeyId, + limits::LimitError, +}; use std::sync::Arc; pub struct ApiKeys { @@ -175,8 +179,8 @@ impl ApiKeysService for ApiKeys { .record_spending(api_key_id, amount_sats, transaction_id, ephemeral_id) .await .map_err(|e| match &e { - ApplicationError::Limit(LimitError::InvalidLimitAmount) | - ApplicationError::Limit(LimitError::MissingTransactionId) => { + ApplicationError::Limit(LimitError::InvalidLimitAmount) + | ApplicationError::Limit(LimitError::MissingTransactionId) => { Status::invalid_argument(e.to_string()) } ApplicationError::Limit(LimitError::EphemeralNotFound(_)) => { diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index a15a5f1b34..74b7d77cc4 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -591,14 +591,18 @@ mod tests { #[tokio::test] async fn record_spending_rejects_zero_amount() { let limits = test_limits(); - let result = limits.record_spending(test_api_key_id(), 0, None, None).await; + let result = limits + .record_spending(test_api_key_id(), 0, None, None) + .await; assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); } #[tokio::test] async fn record_spending_rejects_negative_amount() { let limits = test_limits(); - let result = limits.record_spending(test_api_key_id(), -100, None, None).await; + let result = limits + .record_spending(test_api_key_id(), -100, None, None) + .await; assert!(matches!(result, Err(LimitError::InvalidLimitAmount))); } From c89e09712554dd80b4e401725f30d49c977aee60 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 21:49:06 -0500 Subject: [PATCH 15/33] fix(payments): centralize spending limit settlement and cancellation reversals Unifies account-limit and API-key spending orchestration in payment sends to reduce divergence across flows and ensure reservations are settled consistently. Also reverses API-key spending on Bria payout cancellations and adds focused unit coverage for spending-limit and Bria handler behavior. --- core/api/src/app/payments/api-key-spending.ts | 68 +++ core/api/src/app/payments/index.types.d.ts | 22 + core/api/src/app/payments/send-intraledger.ts | 151 +++--- core/api/src/app/payments/send-lightning.ts | 472 ++++++++---------- core/api/src/app/payments/send-on-chain.ts | 295 +++++------ core/api/src/app/payments/spending-limits.ts | 100 ++++ core/api/src/config/index.ts | 5 +- core/api/src/domain/api-keys/index.types.d.ts | 4 +- core/api/src/servers/event-handlers/bria.ts | 24 +- core/api/src/services/api-keys/index.ts | 2 +- .../app/payments/api-key-spending.spec.ts | 103 ++++ .../unit/app/payments/spending-limits.spec.ts | 301 +++++++++++ .../unit/servers/event-handlers/bria.spec.ts | 103 ++++ 13 files changed, 1101 insertions(+), 549 deletions(-) create mode 100644 core/api/src/app/payments/api-key-spending.ts create mode 100644 core/api/src/app/payments/spending-limits.ts create mode 100644 core/api/test/unit/app/payments/api-key-spending.spec.ts create mode 100644 core/api/test/unit/app/payments/spending-limits.spec.ts create mode 100644 core/api/test/unit/servers/event-handlers/bria.spec.ts diff --git a/core/api/src/app/payments/api-key-spending.ts b/core/api/src/app/payments/api-key-spending.ts new file mode 100644 index 0000000000..c32aafd60f --- /dev/null +++ b/core/api/src/app/payments/api-key-spending.ts @@ -0,0 +1,68 @@ +import { ApiKeysService } from "@/services/api-keys" +const apiKeys = ApiKeysService() + +export const ApiKeySpendingSettlementType = { + Record: "record", + Reverse: "reverse", +} as const + +export const recordApiKeySpendingSettlement = ( + transactionId: LedgerJournalId, +): ApiKeySpendingSettlement => ({ + type: ApiKeySpendingSettlementType.Record, + transactionId, +}) + +export const reverseApiKeySpendingSettlement = (): ApiKeySpendingSettlement => ({ + type: ApiKeySpendingSettlementType.Reverse, +}) + +export const lockApiKeySpending = async ({ + apiKeyId, + amount, +}: { + apiKeyId?: ApiKeyId + amount: BtcPaymentAmount +}): Promise => { + if (!apiKeyId) return undefined + + const ephemeralId = await apiKeys.checkAndLockSpending({ + apiKeyId, + amount, + }) + if (ephemeralId instanceof Error) return ephemeralId + + return { + apiKeyId, + amount, + ephemeralId, + } +} + +export const settleApiKeySpending = async ({ + lock, + settlement, +}: { + lock?: ApiKeySpendingLock + settlement: ApiKeySpendingSettlement +}): Promise => { + if (!lock) return true + + if (settlement.type === ApiKeySpendingSettlementType.Reverse) { + const reverseResult = await apiKeys.reverseSpending({ + transactionId: lock.ephemeralId, + }) + if (reverseResult instanceof Error) return reverseResult + return true + } + + const recordResult = await apiKeys.recordSpending({ + apiKeyId: lock.apiKeyId, + amount: lock.amount, + transactionId: settlement.transactionId, + ephemeralId: lock.ephemeralId, + }) + if (recordResult instanceof Error) return recordResult + + return true +} diff --git a/core/api/src/app/payments/index.types.d.ts b/core/api/src/app/payments/index.types.d.ts index e6ad22b814..71539f1539 100644 --- a/core/api/src/app/payments/index.types.d.ts +++ b/core/api/src/app/payments/index.types.d.ts @@ -3,6 +3,28 @@ type PaymentSendResult = { transaction: WalletTransaction } +type ApiKeySpendingLock = { + apiKeyId: ApiKeyId + amount: BtcPaymentAmount + ephemeralId: EphemeralId +} + +type ApiKeySpendingSettlementType = "record" | "reverse" + +type ApiKeySpendingSettlement = + | { + type: "record" + transactionId: LedgerJournalId + } + | { + type: "reverse" + } + +type SpendingLimitsExecutionResult = { + result: PaymentSendResult | ApplicationError + settlementTransactionId?: LedgerJournalId +} + type PaymentSendAttemptResultTypeObj = typeof import("./ln-send-result").PaymentSendAttemptResultType type PaymentSendAttemptResultType = diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 18310146d1..10c2df2720 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -1,12 +1,9 @@ import { getPriceRatioForLimits } from "./helpers" +import { withSpendingLimits } from "./spending-limits" import { getValuesToSkipProbe } from "@/config" -import { - checkIntraledgerLimits, - checkTradeIntraAccountLimits, - createIntraledgerContact, -} from "@/app/accounts" +import { createIntraledgerContact } from "@/app/accounts" import { btcFromUsdMidPriceFn, getCurrentPriceAsDisplayPriceRatio, @@ -44,10 +41,8 @@ import { WalletsRepository, } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" -import { ApiKeysService } from "@/services/api-keys" const dealer = DealerPriceService() -const apiKeys = ApiKeysService() const intraledgerPaymentSendWalletId = async ({ recipientWalletId: uncheckedRecipientWalletId, @@ -123,16 +118,6 @@ const intraledgerPaymentSendWalletId = async ({ "payment.finalRecipient": JSON.stringify(paymentFlow.recipientWalletDescriptor()), }) - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - const paymentSendResult = await executePaymentViaIntraledger({ paymentFlow, senderAccount, @@ -142,16 +127,8 @@ const intraledgerPaymentSendWalletId = async ({ senderUser, memo, apiKeyId, - ephemeralId, }) - if (paymentSendResult instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } - if (paymentSendResult instanceof Error) return paymentSendResult if (senderAccount.id !== recipientAccount.id) { @@ -255,7 +232,6 @@ const executePaymentViaIntraledger = async < senderUser, memo, apiKeyId, - ephemeralId, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -265,7 +241,6 @@ const executePaymentViaIntraledger = async < senderUser: User memo: string | null apiKeyId?: ApiKeyId - ephemeralId?: EphemeralId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -274,17 +249,6 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - const checkLimits = - senderAccount.id === recipientAccount.id - ? checkTradeIntraAccountLimits - : checkIntraledgerLimits - const limitCheck = await checkLimits({ - amount: paymentFlow.usdPaymentAmount, - accountId: senderAccount.id, - priceRatio: priceRatioForLimits, - }) - if (limitCheck instanceof Error) return limitCheck - const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() if (!recipientWalletDescriptor) { return new InvalidLightningPaymentFlowBuilderStateError( @@ -310,57 +274,68 @@ const executePaymentViaIntraledger = async < phoneNumber: senderUser.phone, } - const journalId = await LockService().lockWalletId(senderWalletId, async (signal) => - lockedPaymentViaIntraledgerSteps({ - signal, - - paymentFlow, - senderDisplayCurrency: senderAccount.displayCurrency, - senderUsername: senderAccount.username, - recipientDisplayCurrency: recipientAccount.displayCurrency, - recipientUsername: recipientAccount.username, - - memo, - }), - ) - if (journalId instanceof Error) return journalId - - const recipientWalletTransaction = await getTransactionForWalletByJournalId({ - walletId: recipientWalletDescriptor.id, - journalId, - }) - if (recipientWalletTransaction instanceof Error) return recipientWalletTransaction - NotificationsService().sendTransaction({ - recipient: recipientAsNotificationRecipient, - transaction: recipientWalletTransaction, - }) - - const senderWalletTransaction = await getTransactionForWalletByJournalId({ - walletId: senderWalletId, - journalId, - }) - if (senderWalletTransaction instanceof Error) return senderWalletTransaction - NotificationsService().sendTransaction({ - recipient: senderAsNotificationRecipient, - transaction: senderWalletTransaction, + const paymentSendResult = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, + accountId: senderAccount.id, + recipientAccountId: recipientAccount.id, + usdPaymentAmount: paymentFlow.usdPaymentAmount, + priceRatioForLimits, + apiKeyId, + btcPaymentAmount: paymentFlow.btcPaymentAmount, + execute: async () => { + const journalId = await LockService().lockWalletId(senderWalletId, async (signal) => + lockedPaymentViaIntraledgerSteps({ + signal, + + paymentFlow, + senderDisplayCurrency: senderAccount.displayCurrency, + senderUsername: senderAccount.username, + recipientDisplayCurrency: recipientAccount.displayCurrency, + recipientUsername: recipientAccount.username, + + memo, + }), + ) + if (journalId instanceof Error) { + return { result: journalId } + } + + const recipientWalletTransaction = await getTransactionForWalletByJournalId({ + walletId: recipientWalletDescriptor.id, + journalId, + }) + if (recipientWalletTransaction instanceof Error) { + return { result: recipientWalletTransaction } + } + + NotificationsService().sendTransaction({ + recipient: recipientAsNotificationRecipient, + transaction: recipientWalletTransaction, + }) + + const senderWalletTransaction = await getTransactionForWalletByJournalId({ + walletId: senderWalletId, + journalId, + }) + if (senderWalletTransaction instanceof Error) { + return { result: senderWalletTransaction } + } + + NotificationsService().sendTransaction({ + recipient: senderAsNotificationRecipient, + transaction: senderWalletTransaction, + }) + + return { + result: { + status: PaymentSendStatus.Success, + transaction: senderWalletTransaction, + }, + settlementTransactionId: journalId, + } + }, }) - - if (apiKeyId && ephemeralId) { - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - transactionId: journalId, - ephemeralId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) - } - } - - return { - status: PaymentSendStatus.Success, - transaction: senderWalletTransaction, - } + return paymentSendResult } const lockedPaymentViaIntraledgerSteps = async ({ diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index cbf84467a4..dcf70d8d06 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -5,7 +5,7 @@ import { LnSendAttemptResult, PaymentSendAttemptResultType, } from "./ln-send-result" - +import { withSpendingLimits } from "./spending-limits" import { reimburseFee } from "./reimburse-fee" import { AccountValidator } from "@/domain/accounts" @@ -54,7 +54,6 @@ import { DealerPriceService } from "@/services/dealer-price" import { LedgerService } from "@/services/ledger" import { LockService } from "@/services/lock" import { NotificationsService } from "@/services/notifications" -import { ApiKeysService } from "@/services/api-keys" import * as LedgerFacade from "@/services/ledger/facade" import { @@ -62,12 +61,7 @@ import { recordExceptionInCurrentSpan, } from "@/services/tracing" -import { - checkIntraledgerLimits, - checkTradeIntraAccountLimits, - checkWithdrawalLimits, - createIntraledgerContact, -} from "@/app/accounts" +import { createIntraledgerContact } from "@/app/accounts" import { getCurrentPriceAsDisplayPriceRatio } from "@/app/prices" import { getTransactionForWalletByJournalId, @@ -79,9 +73,52 @@ import { import { ResourceExpiredLockServiceError } from "@/domain/lock" const dealer = DealerPriceService() -const apiKeys = ApiKeysService() const paymentFlowRepo = PaymentFlowStateRepository(defaultTimeToExpiryInSeconds) +const getValidatedIntraledgerRecipientAccount = async < + S extends WalletCurrency, + R extends WalletCurrency, +>({ + paymentFlow, +}: { + paymentFlow: PaymentFlow +}): Promise => { + const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() + if (!recipientWalletDescriptor) { + return new InvalidLightningPaymentFlowBuilderStateError( + "Expected recipient details missing", + ) + } + + const recipientAccount = await AccountsRepository().findById( + recipientWalletDescriptor.accountId, + ) + if (recipientAccount instanceof Error) return recipientAccount + + const accountValidator = AccountValidator(recipientAccount) + if (accountValidator instanceof Error) return accountValidator + + return recipientAccount +} + +const addIntraledgerContactIfNeeded = async ({ + senderAccount, + recipientAccount, +}: { + senderAccount: Account + recipientAccount: Account +}): Promise => { + if (senderAccount.id === recipientAccount.id) return + + const addContactResult = await createIntraledgerContact({ + senderAccount, + recipientAccount, + }) + if (addContactResult instanceof Error) { + recordExceptionInCurrentSpan({ error: addContactResult, level: ErrorLevel.Warn }) + } +} + export const payInvoiceByWalletId = async ({ uncheckedPaymentRequest, memo, @@ -119,57 +156,18 @@ export const payInvoiceByWalletId = async ({ } = validatedPaymentInputs if (paymentFlow.settlementMethod !== SettlementMethod.IntraLedger) { - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - - const paymentSendResult = await executePaymentViaLn({ + return executePaymentViaLn({ decodedInvoice, paymentFlow, senderAccount, memo, apiKeyId, - ephemeralId, }) - if (paymentSendResult instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } - return paymentSendResult } - const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() - if (!recipientWalletDescriptor) { - return new InvalidLightningPaymentFlowBuilderStateError( - "Expected recipient details missing", - ) - } - const recipientAccount = await AccountsRepository().findById( - recipientWalletDescriptor.accountId, - ) + const recipientAccount = await getValidatedIntraledgerRecipientAccount({ paymentFlow }) if (recipientAccount instanceof Error) return recipientAccount - const accountValidator = AccountValidator(recipientAccount) - if (accountValidator instanceof Error) return accountValidator - - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - const paymentSendResult = await executePaymentViaIntraledger({ paymentFlow, senderWalletId, @@ -177,25 +175,10 @@ export const payInvoiceByWalletId = async ({ recipientAccount, memo, apiKeyId, - ephemeralId, }) - if (paymentSendResult instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } if (paymentSendResult instanceof Error) return paymentSendResult - if (senderAccount.id !== recipientAccount.id) { - const addContactResult = await createIntraledgerContact({ - senderAccount, - recipientAccount, - }) - if (addContactResult instanceof Error) { - recordExceptionInCurrentSpan({ error: addContactResult, level: ErrorLevel.Warn }) - } - } + await addIntraledgerContactIfNeeded({ senderAccount, recipientAccount }) return paymentSendResult } @@ -240,58 +223,18 @@ const payNoAmountInvoiceByWalletId = async ({ } = validatedNoAmountPaymentInputs if (paymentFlow.settlementMethod !== SettlementMethod.IntraLedger) { - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - - const paymentSendResult = await executePaymentViaLn({ + return executePaymentViaLn({ decodedInvoice, paymentFlow, senderAccount, memo, apiKeyId, - ephemeralId, }) - if (paymentSendResult instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } - return paymentSendResult } - const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() - if (!recipientWalletDescriptor) { - return new InvalidLightningPaymentFlowBuilderStateError( - "Expected recipient details missing", - ) - } - - const recipientAccount = await AccountsRepository().findById( - recipientWalletDescriptor.accountId, - ) + const recipientAccount = await getValidatedIntraledgerRecipientAccount({ paymentFlow }) if (recipientAccount instanceof Error) return recipientAccount - const accountValidator = AccountValidator(recipientAccount) - if (accountValidator instanceof Error) return accountValidator - - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - const paymentSendResult = await executePaymentViaIntraledger({ paymentFlow, senderWalletId, @@ -299,25 +242,10 @@ const payNoAmountInvoiceByWalletId = async ({ recipientAccount, memo, apiKeyId, - ephemeralId, }) - if (paymentSendResult instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } if (paymentSendResult instanceof Error) return paymentSendResult - if (senderAccount.id !== recipientAccount.id) { - const addContactResult = await createIntraledgerContact({ - senderAccount, - recipientAccount, - }) - if (addContactResult instanceof Error) { - recordExceptionInCurrentSpan({ error: addContactResult, level: ErrorLevel.Warn }) - } - } + await addIntraledgerContactIfNeeded({ senderAccount, recipientAccount }) return paymentSendResult } @@ -511,7 +439,6 @@ const executePaymentViaIntraledger = async < recipientAccount, memo, apiKeyId, - ephemeralId, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -519,7 +446,6 @@ const executePaymentViaIntraledger = async < recipientAccount: Account memo: string | null apiKeyId?: ApiKeyId - ephemeralId?: EphemeralId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -531,17 +457,6 @@ const executePaymentViaIntraledger = async < const paymentHash = paymentFlow.paymentHashForFlow() if (paymentHash instanceof Error) return paymentHash - const checkLimits = - senderAccount.id === recipientAccount.id - ? checkTradeIntraAccountLimits - : checkIntraledgerLimits - const limitCheck = await checkLimits({ - amount: paymentFlow.usdPaymentAmount, - accountId: senderAccount.id, - priceRatio: priceRatioForLimits, - }) - if (limitCheck instanceof Error) return limitCheck - const { walletDescriptor: recipientWalletDescriptor } = paymentFlow.recipientDetails() if (!recipientWalletDescriptor) { return new InvalidLightningPaymentFlowBuilderStateError( @@ -573,77 +488,86 @@ const executePaymentViaIntraledger = async < phoneNumber: senderUser.phone, } - const paymentSendAttemptResult = await LockService().lockWalletId( - senderWalletId, - async (signal) => - lockedPaymentViaIntraledgerSteps({ - signal, - - paymentHash, - paymentFlow, - senderDisplayCurrency: senderAccount.displayCurrency, - senderUsername: senderAccount.username, - recipientDisplayCurrency: recipientAccount.displayCurrency, - recipientUsername: recipientAccount.username, - - memo, - }), - ) - if (paymentSendAttemptResult instanceof Error) return paymentSendAttemptResult + const paymentSendResult = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, + accountId: senderAccount.id, + recipientAccountId: recipientAccount.id, + usdPaymentAmount: paymentFlow.usdPaymentAmount, + priceRatioForLimits, + apiKeyId, + btcPaymentAmount: paymentFlow.btcPaymentAmount, + execute: async () => { + const paymentSendAttemptResult = await LockService().lockWalletId( + senderWalletId, + async (signal) => + lockedPaymentViaIntraledgerSteps({ + signal, + + paymentHash, + paymentFlow, + senderDisplayCurrency: senderAccount.displayCurrency, + senderUsername: senderAccount.username, + recipientDisplayCurrency: recipientAccount.displayCurrency, + recipientUsername: recipientAccount.username, + + memo, + }), + ) + if (paymentSendAttemptResult instanceof Error) { + return { result: paymentSendAttemptResult } + } - switch (paymentSendAttemptResult.type) { - case PaymentSendAttemptResultType.Error: - return paymentSendAttemptResult.error + switch (paymentSendAttemptResult.type) { + case PaymentSendAttemptResultType.Error: + return { result: paymentSendAttemptResult.error } - case PaymentSendAttemptResultType.AlreadyPaid: - return getAlreadyPaidResponse({ - walletId: senderWalletId, - paymentHash, - }) - } + case PaymentSendAttemptResultType.AlreadyPaid: { + const result = await getAlreadyPaidResponse({ + walletId: senderWalletId, + paymentHash, + }) + if (result instanceof Error) return { result } - const { journalId } = paymentSendAttemptResult + return { result } + } + } - const recipientWalletTransaction = await getTransactionForWalletByJournalId({ - walletId: recipientWalletDescriptor.id, - journalId, - }) - if (recipientWalletTransaction instanceof Error) { - return recipientWalletTransaction - } - NotificationsService().sendTransaction({ - recipient: recipientAsNotificationRecipient, - transaction: recipientWalletTransaction, - }) + const { journalId } = paymentSendAttemptResult - const senderWalletTransaction = await getTransactionForWalletByJournalId({ - walletId: senderWalletId, - journalId, - }) - if (senderWalletTransaction instanceof Error) { - return senderWalletTransaction - } - NotificationsService().sendTransaction({ - recipient: senderAsNotificationRecipient, - transaction: senderWalletTransaction, - }) + const recipientWalletTransaction = await getTransactionForWalletByJournalId({ + walletId: recipientWalletDescriptor.id, + journalId, + }) + if (recipientWalletTransaction instanceof Error) { + return { result: recipientWalletTransaction } + } + NotificationsService().sendTransaction({ + recipient: recipientAsNotificationRecipient, + transaction: recipientWalletTransaction, + }) - if (apiKeyId && ephemeralId) { - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - transactionId: journalId, - ephemeralId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) - } - } + const senderWalletTransaction = await getTransactionForWalletByJournalId({ + walletId: senderWalletId, + journalId, + }) + if (senderWalletTransaction instanceof Error) { + return { result: senderWalletTransaction } + } + NotificationsService().sendTransaction({ + recipient: senderAsNotificationRecipient, + transaction: senderWalletTransaction, + }) - return { - status: PaymentSendStatus.Success, - transaction: senderWalletTransaction, - } + return { + result: { + status: PaymentSendStatus.Success, + transaction: senderWalletTransaction, + }, + settlementTransactionId: journalId, + } + }, + }) + return paymentSendResult } const lockedPaymentViaIntraledgerSteps = async ({ @@ -822,14 +746,12 @@ const executePaymentViaLn = async ({ senderAccount, memo, apiKeyId, - ephemeralId, }: { decodedInvoice: LnInvoice paymentFlow: PaymentFlow senderAccount: Account memo: string | null apiKeyId?: ApiKeyId - ephemeralId?: EphemeralId }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.Lightning, @@ -840,13 +762,6 @@ const executePaymentViaLn = async ({ const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - const limitCheck = await checkWithdrawalLimits({ - amount: paymentFlow.usdPaymentAmount, - accountId: senderAccount.id, - priceRatio: priceRatioForLimits, - }) - if (limitCheck instanceof Error) return limitCheck - const accountWalletDescriptors = await WalletsRepository().findAccountWalletsByAccountId(senderAccount.id) if (accountWalletDescriptors instanceof Error) return accountWalletDescriptors @@ -864,81 +779,88 @@ const executePaymentViaLn = async ({ phoneNumber: senderUser.phone, } - const paymentSendAttemptResult = await LockService().lockWalletId( - senderWalletId, - (signal) => - lockedPaymentViaLnSteps({ - signal, + const paymentSendResult = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: senderAccount.id, + usdPaymentAmount: paymentFlow.usdPaymentAmount, + priceRatioForLimits, + apiKeyId, + btcPaymentAmount: paymentFlow.btcPaymentAmount, + execute: async () => { + const paymentSendAttemptResult = await LockService().lockWalletId( + senderWalletId, + (signal) => + lockedPaymentViaLnSteps({ + signal, - decodedInvoice, - paymentFlow, - senderDisplayCurrency: senderAccount.displayCurrency, - memo, + decodedInvoice, + paymentFlow, + senderDisplayCurrency: senderAccount.displayCurrency, + memo, + + walletIds, + }), + ) + if (paymentSendAttemptResult instanceof Error) { + return { result: paymentSendAttemptResult } + } + if (paymentSendAttemptResult.type === PaymentSendAttemptResultType.Error) { + return { result: paymentSendAttemptResult.error } + } - walletIds, - }), - ) - if (paymentSendAttemptResult instanceof Error) return paymentSendAttemptResult - if (paymentSendAttemptResult.type === PaymentSendAttemptResultType.Error) { - return paymentSendAttemptResult.error - } + const walletTransaction = await getTransactionForWalletByJournalId({ + walletId: senderWalletId, + journalId: paymentSendAttemptResult.journalId, + }) + if (walletTransaction instanceof Error) return { result: walletTransaction } + NotificationsService().sendTransaction({ + recipient: notificationRecipient, + transaction: walletTransaction, + }) - const walletTransaction = await getTransactionForWalletByJournalId({ - walletId: senderWalletId, - journalId: paymentSendAttemptResult.journalId, - }) - if (walletTransaction instanceof Error) return walletTransaction - NotificationsService().sendTransaction({ - recipient: notificationRecipient, - transaction: walletTransaction, - }) + const { paymentHash } = decodedInvoice + switch (paymentSendAttemptResult.type) { + case PaymentSendAttemptResultType.ErrorWithJournal: + return { + result: paymentSendAttemptResult.error, + settlementTransactionId: paymentSendAttemptResult.journalId, + } + + case PaymentSendAttemptResultType.Pending: { + const result = await getPendingPaymentResponse({ + walletId: senderWalletId, + paymentHash, + }) + if (result instanceof Error) return { result } - const { paymentHash } = decodedInvoice - switch (paymentSendAttemptResult.type) { - case PaymentSendAttemptResultType.ErrorWithJournal: - return paymentSendAttemptResult.error - - case PaymentSendAttemptResultType.Pending: - if (apiKeyId && ephemeralId) { - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - transactionId: paymentSendAttemptResult.journalId, - ephemeralId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) + return { + result, + settlementTransactionId: paymentSendAttemptResult.journalId, + } } - } - return getPendingPaymentResponse({ - walletId: senderWalletId, - paymentHash, - }) - case PaymentSendAttemptResultType.AlreadyPaid: - return getAlreadyPaidResponse({ - walletId: senderWalletId, - paymentHash, - }) + case PaymentSendAttemptResultType.AlreadyPaid: { + const result = await getAlreadyPaidResponse({ + walletId: senderWalletId, + paymentHash, + }) + if (result instanceof Error) return { result } - default: - if (apiKeyId && ephemeralId) { - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - transactionId: paymentSendAttemptResult.journalId, - ephemeralId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) + return { result } } - } - return { - status: PaymentSendStatus.Success, - transaction: walletTransaction, + default: + return { + result: { + status: PaymentSendStatus.Success, + transaction: walletTransaction, + }, + settlementTransactionId: paymentSendAttemptResult.journalId, + } } - } + }, + }) + return paymentSendResult } const lockedPaymentViaLnSteps = async ({ diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index eab0cebfc6..118362bb29 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -1,12 +1,7 @@ import { getPriceRatioForLimits } from "./helpers" +import { withSpendingLimits } from "./spending-limits" import { NETWORK, getOnChainWalletConfig } from "@/config" - -import { - checkIntraledgerLimits, - checkTradeIntraAccountLimits, - checkWithdrawalLimits, -} from "@/app/accounts" import { btcFromUsdMidPriceFn, getCurrentPriceAsDisplayPriceRatio, @@ -51,15 +46,10 @@ import { WalletsRepository, } from "@/services/mongoose" import { NotificationsService } from "@/services/notifications" -import { - addAttributesToCurrentSpan, - recordExceptionInCurrentSpan, -} from "@/services/tracing" -import { ApiKeysService } from "@/services/api-keys" +import { addAttributesToCurrentSpan } from "@/services/tracing" const { dustThreshold } = getOnChainWalletConfig() const dealer = DealerPriceService() -const apiKeys = ApiKeysService() const payOnChainByWalletId = async ({ senderAccount, @@ -179,36 +169,13 @@ const payOnChainByWalletId = async ({ .withAmount(amount) .withConversion(withConversionArgs) - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const proposedAmounts = await builder.proposedAmounts() - if (proposedAmounts instanceof Error) return proposedAmounts - - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: proposedAmounts.btc, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - - const result = await executePaymentViaIntraledger({ + return executePaymentViaIntraledger({ builder, senderAccount, memo, sendAll, apiKeyId, - ephemeralId, }) - - if (result instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } - - return result } const builder = withSenderBuilder @@ -216,20 +183,7 @@ const payOnChainByWalletId = async ({ .withAmount(amount) .withConversion(withConversionArgs) - let ephemeralId: EphemeralId | undefined - if (apiKeyId) { - const proposedAmounts = await builder.proposedAmounts() - if (proposedAmounts instanceof Error) return proposedAmounts - - const lockResult = await apiKeys.checkAndLockSpending({ - apiKeyId, - amount: proposedAmounts.btc, - }) - if (lockResult instanceof Error) return lockResult - ephemeralId = lockResult - } - - const result = await executePaymentViaOnChain({ + return executePaymentViaOnChain({ builder, senderDisplayCurrency: senderAccount.displayCurrency, speed, @@ -237,17 +191,7 @@ const payOnChainByWalletId = async ({ sendAll, logger: onchainLogger, apiKeyId, - ephemeralId, }) - - if (result instanceof Error && ephemeralId) { - const reverseResult = await apiKeys.reverseSpending({ transactionId: ephemeralId }) - if (reverseResult instanceof Error) { - recordExceptionInCurrentSpan({ error: reverseResult }) - } - } - - return result } export const payOnChainByWalletIdForBtcWallet = async ( @@ -303,14 +247,12 @@ const executePaymentViaIntraledger = async < memo, sendAll, apiKeyId, - ephemeralId, }: { builder: OPFBWithConversion | OPFBWithError senderAccount: Account memo: string | null sendAll: boolean apiKeyId?: ApiKeyId - ephemeralId?: EphemeralId }): Promise => { const paymentFlow = await builder.withoutMinerFee() if (paymentFlow instanceof Error) return paymentFlow @@ -347,90 +289,88 @@ const executePaymentViaIntraledger = async < const priceRatioForLimits = await getPriceRatioForLimits(paymentFlow.paymentAmounts()) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - const checkLimits = - senderAccount.id === recipientAccount.id - ? checkTradeIntraAccountLimits - : checkIntraledgerLimits - const limitCheck = await checkLimits({ - amount: paymentFlow.usdPaymentAmount, + const paymentSendResult = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, accountId: senderAccount.id, - priceRatio: priceRatioForLimits, - }) - if (limitCheck instanceof Error) return limitCheck - - const journalId = await LockService().lockWalletId(senderWalletId, async (signal) => - lockedPaymentViaIntraledgerSteps({ - signal, + recipientAccountId: recipientAccount.id, + usdPaymentAmount: paymentFlow.usdPaymentAmount, + priceRatioForLimits, + apiKeyId, + btcPaymentAmount: paymentFlow.btcPaymentAmount, + execute: async () => { + const journalId = await LockService().lockWalletId(senderWalletId, async (signal) => + lockedPaymentViaIntraledgerSteps({ + signal, + + paymentFlow, + senderDisplayCurrency: senderAccount.displayCurrency, + senderUsername: senderAccount.username, + recipientDisplayCurrency: recipientAccount.displayCurrency, + recipientUsername: recipientAccount.username, + + memo, + sendAll, + }), + ) + if (journalId instanceof Error) { + return { result: journalId } + } - paymentFlow, - senderDisplayCurrency: senderAccount.displayCurrency, - senderUsername: senderAccount.username, - recipientDisplayCurrency: recipientAccount.displayCurrency, - recipientUsername: recipientAccount.username, + const recipientAsNotificationRecipient = { + accountId: recipientAccount.id, + walletId: recipientWalletDescriptor.id, + userId: recipientAccount.kratosUserId, + level: recipientAccount.level, + status: recipientAccount.status, + phoneNumber: recipientUser.phone, + } - memo, - sendAll, - }), - ) - if (journalId instanceof Error) return journalId - - const recipientAsNotificationRecipient = { - accountId: recipientAccount.id, - walletId: recipientWalletDescriptor.id, - userId: recipientAccount.kratosUserId, - level: recipientAccount.level, - status: recipientAccount.status, - phoneNumber: recipientUser.phone, - } + const recipientWalletTransaction = await getTransactionForWalletByJournalId({ + walletId: recipientWalletDescriptor.id, + journalId, + }) + if (recipientWalletTransaction instanceof Error) { + return { result: recipientWalletTransaction } + } - const recipientWalletTransaction = await getTransactionForWalletByJournalId({ - walletId: recipientWalletDescriptor.id, - journalId, - }) - if (recipientWalletTransaction instanceof Error) return recipientWalletTransaction + // Send 'received'-side intraledger notification + NotificationsService().sendTransaction({ + recipient: recipientAsNotificationRecipient, + transaction: recipientWalletTransaction, + }) - // Send 'received'-side intraledger notification - NotificationsService().sendTransaction({ - recipient: recipientAsNotificationRecipient, - transaction: recipientWalletTransaction, - }) + const senderAsNotificationRecipient = { + accountId: senderAccount.id, + walletId: senderWalletId, + userId: senderAccount.kratosUserId, + level: senderAccount.level, + status: senderAccount.status, + phoneNumber: senderUser.phone, + } - const senderAsNotificationRecipient = { - accountId: senderAccount.id, - walletId: senderWalletId, - userId: senderAccount.kratosUserId, - level: senderAccount.level, - status: senderAccount.status, - phoneNumber: senderUser.phone, - } + const senderWalletTransaction = await getTransactionForWalletByJournalId({ + walletId: senderWalletId, + journalId, + }) + if (senderWalletTransaction instanceof Error) { + return { result: senderWalletTransaction } + } - const senderWalletTransaction = await getTransactionForWalletByJournalId({ - walletId: senderWalletId, - journalId, - }) - if (senderWalletTransaction instanceof Error) return senderWalletTransaction + NotificationsService().sendTransaction({ + recipient: senderAsNotificationRecipient, + transaction: senderWalletTransaction, + }) - NotificationsService().sendTransaction({ - recipient: senderAsNotificationRecipient, - transaction: senderWalletTransaction, + return { + result: { + status: PaymentSendStatus.Success, + transaction: senderWalletTransaction, + }, + settlementTransactionId: journalId, + } + }, }) - - if (apiKeyId && ephemeralId) { - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amount: paymentFlow.btcPaymentAmount, - transactionId: journalId, - ephemeralId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) - } - } - - return { - status: PaymentSendStatus.Success, - transaction: senderWalletTransaction, - } + return paymentSendResult } const lockedPaymentViaIntraledgerSteps = async < @@ -594,7 +534,6 @@ const executePaymentViaOnChain = async < sendAll, logger, apiKeyId, - ephemeralId, }: { builder: OPFBWithConversion | OPFBWithError senderDisplayCurrency: DisplayCurrency @@ -603,7 +542,6 @@ const executePaymentViaOnChain = async < sendAll: boolean logger: Logger apiKeyId?: ApiKeyId - ephemeralId?: EphemeralId }): Promise => { const senderWalletDescriptor = await builder.senderWalletDescriptor() if (senderWalletDescriptor instanceof Error) return senderWalletDescriptor @@ -615,50 +553,49 @@ const executePaymentViaOnChain = async < const priceRatioForLimits = await getPriceRatioForLimits(proposedAmounts) if (priceRatioForLimits instanceof Error) return priceRatioForLimits - const limitCheck = await checkWithdrawalLimits({ - amount: proposedAmounts.usd, + const paymentSendResult = await withSpendingLimits({ + settlementMethod: SettlementMethod.OnChain, accountId: senderWalletDescriptor.accountId, - priceRatio: priceRatioForLimits, - }) - if (limitCheck instanceof Error) return limitCheck - - const journalId = await LockService().lockWalletId( - senderWalletDescriptor.id, - async (signal) => - lockedPaymentViaOnChainSteps({ - signal, - - builder, - speed, - - senderDisplayCurrency, - memo, - sendAll, + usdPaymentAmount: proposedAmounts.usd, + priceRatioForLimits, + apiKeyId, + btcPaymentAmount: proposedAmounts.btc, + execute: async () => { + const journalId = await LockService().lockWalletId( + senderWalletDescriptor.id, + async (signal) => + lockedPaymentViaOnChainSteps({ + signal, + + builder, + speed, + + senderDisplayCurrency, + memo, + sendAll, + + logger, + }), + ) + if (journalId instanceof Error) { + return { result: journalId } + } - logger, - }), - ) - if (journalId instanceof Error) return journalId + const walletTransaction = await getTransactionForWalletByJournalId({ + walletId: senderWalletDescriptor.id, + journalId, + }) + if (walletTransaction instanceof Error) { + return { result: walletTransaction } + } - const walletTransaction = await getTransactionForWalletByJournalId({ - walletId: senderWalletDescriptor.id, - journalId, + return { + result: { status: PaymentSendStatus.Success, transaction: walletTransaction }, + settlementTransactionId: journalId, + } + }, }) - if (walletTransaction instanceof Error) return walletTransaction - - if (apiKeyId && ephemeralId) { - const recordResult = await apiKeys.recordSpending({ - apiKeyId, - amount: proposedAmounts.btc, - transactionId: journalId, - ephemeralId, - }) - if (recordResult instanceof Error) { - recordExceptionInCurrentSpan({ error: recordResult }) - } - } - - return { status: PaymentSendStatus.Success, transaction: walletTransaction } + return paymentSendResult } const lockedPaymentViaOnChainSteps = async < diff --git a/core/api/src/app/payments/spending-limits.ts b/core/api/src/app/payments/spending-limits.ts new file mode 100644 index 0000000000..274476444f --- /dev/null +++ b/core/api/src/app/payments/spending-limits.ts @@ -0,0 +1,100 @@ +import { + lockApiKeySpending, + reverseApiKeySpendingSettlement, + recordApiKeySpendingSettlement, + settleApiKeySpending, +} from "./api-key-spending" + +import { + checkIntraledgerLimits, + checkTradeIntraAccountLimits, + checkWithdrawalLimits, +} from "@/app/accounts" + +import { PaymentSendStatus } from "@/domain/bitcoin/lightning" +import { SettlementMethod } from "@/domain/wallets" +import { recordExceptionInCurrentSpan } from "@/services/tracing" + +// Records spending when a journal entry exists (settlementTransactionId is present), +// reverses the lock otherwise. AlreadyPaid and Failure are reversed even with a journal +// since no net new spending occurred. +const settlementFor = ({ + result, + settlementTransactionId, +}: { + result: PaymentSendResult | ApplicationError + settlementTransactionId?: LedgerJournalId +}): ApiKeySpendingSettlement => { + if (!settlementTransactionId) return reverseApiKeySpendingSettlement() + + if (result instanceof Error) { + return recordApiKeySpendingSettlement(settlementTransactionId) + } + + if ( + result.status === PaymentSendStatus.AlreadyPaid || + result.status === PaymentSendStatus.Failure + ) { + return reverseApiKeySpendingSettlement() + } + + return recordApiKeySpendingSettlement(settlementTransactionId) +} + +const getLimitCheck = ({ + settlementMethod, + accountId, + recipientAccountId, +}: { + settlementMethod: SettlementMethod + accountId: AccountId + recipientAccountId?: AccountId +}) => { + if (settlementMethod !== SettlementMethod.IntraLedger) return checkWithdrawalLimits + if (accountId === recipientAccountId) return checkTradeIntraAccountLimits + return checkIntraledgerLimits +} + +export const withSpendingLimits = async ({ + settlementMethod, + accountId, + recipientAccountId, + usdPaymentAmount, + priceRatioForLimits, + apiKeyId, + btcPaymentAmount, + execute, +}: { + settlementMethod: SettlementMethod + accountId: AccountId + recipientAccountId?: AccountId + usdPaymentAmount: UsdPaymentAmount + priceRatioForLimits: WalletPriceRatio + apiKeyId?: ApiKeyId + btcPaymentAmount: BtcPaymentAmount + execute: () => Promise +}): Promise => { + const checkLimit = getLimitCheck({ settlementMethod, accountId, recipientAccountId }) + + const limitCheck = await checkLimit({ + amount: usdPaymentAmount, + accountId, + priceRatio: priceRatioForLimits, + }) + if (limitCheck instanceof Error) return limitCheck + + const lock = await lockApiKeySpending({ apiKeyId, amount: btcPaymentAmount }) + if (lock instanceof Error) return lock + + const { result, settlementTransactionId } = await execute() + + const settleResult = await settleApiKeySpending({ + lock, + settlement: settlementFor({ result, settlementTransactionId }), + }) + if (settleResult instanceof Error) { + recordExceptionInCurrentSpan({ error: settleResult }) + } + + return result +} diff --git a/core/api/src/config/index.ts b/core/api/src/config/index.ts index f81dab244c..2be58a6437 100644 --- a/core/api/src/config/index.ts +++ b/core/api/src/config/index.ts @@ -84,9 +84,6 @@ export const getCallbackServiceConfig = (): SvixConfig => { export const getBriaConfig = getBriaPartialConfigFromYaml -export const API_KEYS_HOST = env.API_KEYS_HOST -export const API_KEYS_PORT = env.API_KEYS_PORT - export const isTelegramPassportEnabled = () => !!env.TELEGRAM_BOT_API_TOKEN && !!env.TELEGRAM_PASSPORT_PRIVATE_KEY @@ -170,6 +167,8 @@ export const KRATOS_MASTER_USER_PASSWORD = env.KRATOS_MASTER_USER_PASSWORD export const BRIA_HOST = env.BRIA_HOST export const BRIA_PORT = env.BRIA_PORT export const BRIA_API_KEY = env.BRIA_API_KEY +export const API_KEYS_HOST = env.API_KEYS_HOST +export const API_KEYS_PORT = env.API_KEYS_PORT export const NOTIFICATIONS_HOST = env.NOTIFICATIONS_HOST export const NOTIFICATIONS_PORT = env.NOTIFICATIONS_PORT export const GEETEST_ID = env.GEETEST_ID diff --git a/core/api/src/domain/api-keys/index.types.d.ts b/core/api/src/domain/api-keys/index.types.d.ts index 1514420817..48961c1ecc 100644 --- a/core/api/src/domain/api-keys/index.types.d.ts +++ b/core/api/src/domain/api-keys/index.types.d.ts @@ -17,5 +17,7 @@ interface IApiKeysService { ephemeralId: EphemeralId }): Promise - reverseSpending(args: { transactionId: string }): Promise + reverseSpending(args: { + transactionId: LedgerJournalId | EphemeralId + }): Promise } diff --git a/core/api/src/servers/event-handlers/bria.ts b/core/api/src/servers/event-handlers/bria.ts index 457120bdfb..17b01bf5da 100644 --- a/core/api/src/servers/event-handlers/bria.ts +++ b/core/api/src/servers/event-handlers/bria.ts @@ -11,6 +11,7 @@ import { recordExceptionInCurrentSpan, addAttributesToCurrentSpan, } from "@/services/tracing" +import { ApiKeysService } from "@/services/api-keys" import { EventAugmentationMissingError, UnknownPayloadTypeReceivedError, @@ -24,6 +25,8 @@ import { NoTransactionToSettleError } from "@/services/ledger/domain/errors" const assertUnreachable = (payloadType: never): Error => new UnknownPayloadTypeReceivedError(payloadType) +const apiKeys = ApiKeysService() + const isBriaPayoutEvent = (payload: BriaPayload): payload is BriaPayoutPayload => { return (payload as BriaPayoutPayload).id !== undefined } @@ -180,15 +183,32 @@ export const payoutCancelledEventHandler = async ({ event: PayoutCancelled payoutInfo: PayoutAugmentation }): Promise => { + const journalId = payoutInfo.externalId as LedgerJournalId const res = await LedgerFacade.recordOnChainSendRevert({ - journalId: payoutInfo.externalId as LedgerJournalId, + journalId, payoutId: event.id, }) if (res instanceof NoTransactionToUpdateError) { return true } + if (res instanceof Error) { + return res + } - return res + const reverseResult = await apiKeys.reverseSpending({ + transactionId: journalId, + }) + if (reverseResult instanceof Error) { + recordExceptionInCurrentSpan({ + error: reverseResult, + attributes: { + "apiKeys.reverseSpending.failed": true, + "journalId": payoutInfo.externalId, + }, + }) + } + + return true } export const payoutBroadcastEventHandler = async ({ diff --git a/core/api/src/services/api-keys/index.ts b/core/api/src/services/api-keys/index.ts index ee51bf0271..23c27934d7 100644 --- a/core/api/src/services/api-keys/index.ts +++ b/core/api/src/services/api-keys/index.ts @@ -71,7 +71,7 @@ export const ApiKeysService = (): IApiKeysService => { const reverseSpending = async ({ transactionId, }: { - transactionId: string + transactionId: LedgerJournalId | EphemeralId }): Promise => { try { const request = new ReverseSpendingRequest() diff --git a/core/api/test/unit/app/payments/api-key-spending.spec.ts b/core/api/test/unit/app/payments/api-key-spending.spec.ts new file mode 100644 index 0000000000..49b17cba23 --- /dev/null +++ b/core/api/test/unit/app/payments/api-key-spending.spec.ts @@ -0,0 +1,103 @@ +jest.mock("@/services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@/services/api-keys", () => ({ + __mockApiKeys: { + checkAndLockSpending: jest.fn(), + recordSpending: jest.fn(), + reverseSpending: jest.fn(), + }, + ApiKeysService: () => ({ + checkAndLockSpending: + jest.requireMock("@/services/api-keys").__mockApiKeys.checkAndLockSpending, + recordSpending: jest.requireMock("@/services/api-keys").__mockApiKeys.recordSpending, + reverseSpending: + jest.requireMock("@/services/api-keys").__mockApiKeys.reverseSpending, + }), +})) + +import { + lockApiKeySpending, + recordApiKeySpendingSettlement, + reverseApiKeySpendingSettlement, + settleApiKeySpending, +} from "@/app/payments/api-key-spending" + +const mockApiKeys = jest.requireMock("@/services/api-keys").__mockApiKeys as { + checkAndLockSpending: jest.Mock + recordSpending: jest.Mock + reverseSpending: jest.Mock +} + +describe("api-key-spending", () => { + const apiKeyId = "api-key-id" as ApiKeyId + const amount = { amount: 1000n, currency: "BTC" } as BtcPaymentAmount + const ephemeralId = "ephemeral-id" as EphemeralId + const journalId = "journal-id" as LedgerJournalId + + const lock = { apiKeyId, amount, ephemeralId } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("lockApiKeySpending", () => { + it("returns undefined when apiKeyId is not provided", async () => { + const result = await lockApiKeySpending({ amount }) + expect(result).toBeUndefined() + expect(mockApiKeys.checkAndLockSpending).not.toHaveBeenCalled() + }) + + it("returns a lock when checkAndLockSpending succeeds", async () => { + mockApiKeys.checkAndLockSpending.mockResolvedValue(ephemeralId) + + const result = await lockApiKeySpending({ apiKeyId, amount }) + + expect(mockApiKeys.checkAndLockSpending).toHaveBeenCalledWith({ apiKeyId, amount }) + expect(result).toEqual({ apiKeyId, amount, ephemeralId }) + }) + + it("returns error when checkAndLockSpending fails", async () => { + const error = new Error("lock failed") + mockApiKeys.checkAndLockSpending.mockResolvedValue(error) + + const result = await lockApiKeySpending({ apiKeyId, amount }) + + expect(result).toBe(error) + }) + }) + + describe("settleApiKeySpending", () => { + it("records spending for record settlement", async () => { + mockApiKeys.recordSpending.mockResolvedValue(true) + + await settleApiKeySpending({ + lock, + settlement: recordApiKeySpendingSettlement(journalId), + }) + + expect(mockApiKeys.recordSpending).toHaveBeenCalledWith({ + apiKeyId, + amount, + transactionId: journalId, + ephemeralId, + }) + expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() + }) + + it("reverses spending for reverse settlement", async () => { + mockApiKeys.reverseSpending.mockResolvedValue(true) + + await settleApiKeySpending({ + lock, + settlement: reverseApiKeySpendingSettlement(), + }) + + expect(mockApiKeys.reverseSpending).toHaveBeenCalledWith({ + transactionId: ephemeralId, + }) + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + }) + }) +}) diff --git a/core/api/test/unit/app/payments/spending-limits.spec.ts b/core/api/test/unit/app/payments/spending-limits.spec.ts new file mode 100644 index 0000000000..e909d85364 --- /dev/null +++ b/core/api/test/unit/app/payments/spending-limits.spec.ts @@ -0,0 +1,301 @@ +jest.mock("@/services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@/services/api-keys", () => ({ + __mockApiKeys: { + checkAndLockSpending: jest.fn(), + recordSpending: jest.fn(), + reverseSpending: jest.fn(), + }, + ApiKeysService: () => ({ + checkAndLockSpending: + jest.requireMock("@/services/api-keys").__mockApiKeys.checkAndLockSpending, + recordSpending: jest.requireMock("@/services/api-keys").__mockApiKeys.recordSpending, + reverseSpending: + jest.requireMock("@/services/api-keys").__mockApiKeys.reverseSpending, + }), +})) + +jest.mock("@/app/accounts", () => ({ + checkIntraledgerLimits: jest.fn(), + checkTradeIntraAccountLimits: jest.fn(), + checkWithdrawalLimits: jest.fn(), +})) + +import { withSpendingLimits } from "@/app/payments/spending-limits" +import { PaymentSendStatus } from "@/domain/bitcoin/lightning" +import { ApiKeyLimitExceededError } from "@/domain/api-keys/errors" +import { SettlementMethod } from "@/domain/wallets" +import { recordExceptionInCurrentSpan } from "@/services/tracing" +import { + checkIntraledgerLimits, + checkTradeIntraAccountLimits, + checkWithdrawalLimits, +} from "@/app/accounts" + +const mockApiKeys = jest.requireMock("@/services/api-keys").__mockApiKeys as { + checkAndLockSpending: jest.Mock + recordSpending: jest.Mock + reverseSpending: jest.Mock +} + +const mockRecordExceptionInCurrentSpan = recordExceptionInCurrentSpan as jest.Mock +const mockCheckIntraledgerLimits = checkIntraledgerLimits as jest.Mock +const mockCheckTradeIntraAccountLimits = checkTradeIntraAccountLimits as jest.Mock +const mockCheckWithdrawalLimits = checkWithdrawalLimits as jest.Mock + +describe("withSpendingLimits", () => { + const apiKeyId = "api-key-id" as ApiKeyId + const btcPaymentAmount = { amount: 1000n, currency: "BTC" } as BtcPaymentAmount + const journalId = "journal-id" as LedgerJournalId + const walletId = "wallet-id" as WalletId + + const paymentSendSuccessResult: PaymentSendResult = { + status: PaymentSendStatus.Success, + transaction: { walletId } as WalletTransaction, + } + + const paymentSendAlreadyPaidResult: PaymentSendResult = { + status: PaymentSendStatus.AlreadyPaid, + transaction: { walletId } as WalletTransaction, + } + + const paymentSendFailureResult: PaymentSendResult = { + status: PaymentSendStatus.Failure, + transaction: { walletId } as WalletTransaction, + } + + beforeEach(() => { + jest.clearAllMocks() + mockApiKeys.checkAndLockSpending.mockResolvedValue("ephemeral-id" as EphemeralId) + mockApiKeys.recordSpending.mockResolvedValue(true) + mockApiKeys.reverseSpending.mockResolvedValue(true) + mockCheckIntraledgerLimits.mockResolvedValue(true) + mockCheckTradeIntraAccountLimits.mockResolvedValue(true) + mockCheckWithdrawalLimits.mockResolvedValue(true) + }) + + it("returns account limit error without attempting api key lock", async () => { + const accountLimitError = new ApiKeyLimitExceededError() + mockCheckWithdrawalLimits.mockResolvedValue(accountLimitError) + + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toBe(accountLimitError) + expect(mockApiKeys.checkAndLockSpending).not.toHaveBeenCalled() + }) + + it("records settlement when execution succeeds with settlement transaction id", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockApiKeys.checkAndLockSpending).toHaveBeenCalledWith({ + apiKeyId, + amount: btcPaymentAmount, + }) + expect(mockApiKeys.recordSpending).toHaveBeenCalled() + expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() + }) + + it("reverses settlement when success result is already paid", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendAlreadyPaidResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendAlreadyPaidResult) + expect(mockApiKeys.reverseSpending).toHaveBeenCalled() + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + }) + + it("reverses settlement when already-paid result has no settlement transaction id", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendAlreadyPaidResult, + }), + }) + + expect(result).toEqual(paymentSendAlreadyPaidResult) + expect(mockApiKeys.reverseSpending).toHaveBeenCalled() + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + }) + + it("reverses settlement when failure result has settlement transaction id", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendFailureResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendFailureResult) + expect(mockApiKeys.reverseSpending).toHaveBeenCalled() + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + }) + + it("reverses settlement when execution fails without settlement transaction id", async () => { + const executionError = new ApiKeyLimitExceededError() + + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: executionError, + }), + }) + + expect(result).toBe(executionError) + expect(mockApiKeys.reverseSpending).toHaveBeenCalled() + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + }) + + it("records settlement when execution fails with settlement transaction id", async () => { + const executionError = new ApiKeyLimitExceededError() + + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: executionError, + settlementTransactionId: journalId, + }), + }) + + expect(result).toBe(executionError) + expect(mockApiKeys.recordSpending).toHaveBeenCalled() + expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() + }) + + it("records exception when settlement fails", async () => { + const settlementError = new ApiKeyLimitExceededError() + mockApiKeys.recordSpending.mockResolvedValue(settlementError) + + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockRecordExceptionInCurrentSpan).toHaveBeenCalledWith({ + error: settlementError, + }) + }) + + it("checks trade intra-account limits for intraledger self transfer", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, + accountId: "same-account-id" as AccountId, + recipientAccountId: "same-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockCheckTradeIntraAccountLimits).toHaveBeenCalledTimes(1) + expect(mockCheckIntraledgerLimits).not.toHaveBeenCalled() + expect(mockCheckWithdrawalLimits).not.toHaveBeenCalled() + }) + + it("checks intraledger limits for intraledger transfer to other account", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, + accountId: "sender-account-id" as AccountId, + recipientAccountId: "recipient-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockCheckIntraledgerLimits).toHaveBeenCalledTimes(1) + expect(mockCheckTradeIntraAccountLimits).not.toHaveBeenCalled() + }) + + it("checks withdrawal limits for non-intraledger settlement method", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.OnChain, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => ({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockCheckWithdrawalLimits).toHaveBeenCalledTimes(1) + }) +}) diff --git a/core/api/test/unit/servers/event-handlers/bria.spec.ts b/core/api/test/unit/servers/event-handlers/bria.spec.ts new file mode 100644 index 0000000000..09856e764a --- /dev/null +++ b/core/api/test/unit/servers/event-handlers/bria.spec.ts @@ -0,0 +1,103 @@ +const mockRecordOnChainSendRevert = jest.fn() +const mockReverseSpending = jest.fn() +const mockRecordExceptionInCurrentSpan = jest.fn() + +jest.mock("@/app", () => ({ + Wallets: {}, + OnChain: {}, +})) + +jest.mock("@/services/ledger/facade", () => ({ + recordOnChainSendRevert: (...args: unknown[]) => mockRecordOnChainSendRevert(...args), +})) + +jest.mock("@/services/api-keys", () => ({ + ApiKeysService: () => ({ + reverseSpending: (...args: unknown[]) => mockReverseSpending(...args), + }), +})) + +jest.mock("@/services/bria", () => ({ + BriaPayloadType: { + UtxoDetected: "utxo_detected", + UtxoDropped: "utxo_dropped", + UtxoSettled: "utxo_settled", + PayoutSubmitted: "payout_submitted", + PayoutCommitted: "payout_committed", + PayoutCancelled: "payout_cancelled", + PayoutBroadcast: "payout_broadcast", + PayoutSettled: "payout_settled", + }, +})) + +jest.mock("@/services/tracing", () => ({ + recordExceptionInCurrentSpan: (...args: unknown[]) => + mockRecordExceptionInCurrentSpan(...args), + addAttributesToCurrentSpan: jest.fn(), +})) + +import { payoutCancelledEventHandler } from "@/servers/event-handlers/bria" + +import { NoTransactionToUpdateError } from "@/domain/errors" +import { ApiKeySpendingRecordError } from "@/domain/api-keys/errors" + +describe("payoutCancelledEventHandler", () => { + const payoutInfo = { + id: "payout-id", + externalId: "journal-id", + } as PayoutAugmentation + + const event = { + type: "payout_cancelled", + id: "payout-id", + satoshis: { amount: 1000n, currency: "BTC" }, + address: "bcrt1qtestaddress", + } as PayoutCancelled + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("reverses api key spending after payout cancellation", async () => { + mockRecordOnChainSendRevert.mockResolvedValue(true) + mockReverseSpending.mockResolvedValue(true) + + const result = await payoutCancelledEventHandler({ event, payoutInfo }) + + expect(result).toBe(true) + expect(mockRecordOnChainSendRevert).toHaveBeenCalledWith({ + journalId: payoutInfo.externalId, + payoutId: event.id, + }) + expect(mockReverseSpending).toHaveBeenCalledWith({ + transactionId: payoutInfo.externalId, + }) + }) + + it("records trace exception when api key reversal fails", async () => { + const reverseError = new ApiKeySpendingRecordError() + + mockRecordOnChainSendRevert.mockResolvedValue(true) + mockReverseSpending.mockResolvedValue(reverseError) + + const result = await payoutCancelledEventHandler({ event, payoutInfo }) + + expect(result).toBe(true) + expect(mockRecordExceptionInCurrentSpan).toHaveBeenCalledWith({ + error: reverseError, + attributes: { + "apiKeys.reverseSpending.failed": true, + "journalId": payoutInfo.externalId, + }, + }) + }) + + it("keeps idempotent behavior when ledger transaction is not found", async () => { + mockRecordOnChainSendRevert.mockResolvedValue(new NoTransactionToUpdateError()) + + const result = await payoutCancelledEventHandler({ event, payoutInfo }) + + expect(result).toBe(true) + expect(mockReverseSpending).not.toHaveBeenCalled() + }) +}) From fa630bc896097a4e478063320e50c4954708d407 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 21:58:43 -0500 Subject: [PATCH 16/33] refactor(api-keys): add tracing to check_spending_limit and remove redundant rollback --- core/api-keys/src/limits/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index 74b7d77cc4..255e7e5d8a 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -57,6 +57,7 @@ impl Limits { Self { pool } } + #[tracing::instrument(name = "limits.check_spending_limit", skip(self))] pub async fn check_spending_limit( &self, api_key_id: IdentityApiKeyId, @@ -154,7 +155,6 @@ impl Limits { for (period, limit, spent) in &checks { if let Some(limit) = limit { if spent.saturating_add(amount_sats) > *limit { - tx.rollback().await?; return Err(LimitError::LimitExceeded(period.to_string())); } } From aea930bcce885a247c358a1f5d16d28a886e23f4 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 22:03:16 -0500 Subject: [PATCH 17/33] chore: revert apps/dashboard changes (kept in separate branch) --- apps/dashboard/app/api-keys/server-actions.ts | 263 +----------------- apps/dashboard/components/api-keys/form.tsx | 84 ------ apps/dashboard/components/api-keys/list.tsx | 112 +------- apps/dashboard/services/graphql/generated.ts | 118 +------- .../services/graphql/mutations/api-keys.ts | 95 ------- 5 files changed, 11 insertions(+), 661 deletions(-) diff --git a/apps/dashboard/app/api-keys/server-actions.ts b/apps/dashboard/app/api-keys/server-actions.ts index fb502d30b6..f27072ceea 100644 --- a/apps/dashboard/app/api-keys/server-actions.ts +++ b/apps/dashboard/app/api-keys/server-actions.ts @@ -5,13 +5,8 @@ import { revalidatePath } from "next/cache" import { ApiKeyResponse } from "./api-key.types" import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { - createApiKey, - revokeApiKey, - setApiKeyLimit, - removeApiKeyLimit, -} from "@/services/graphql/mutations/api-keys" -import { LimitTimeWindow, Scope } from "@/services/graphql/generated" +import { createApiKey, revokeApiKey } from "@/services/graphql/mutations/api-keys" +import { Scope } from "@/services/graphql/generated" export const revokeApiKeyServerAction = async (id: string) => { if (!id || typeof id !== "string") { @@ -118,263 +113,9 @@ export const createApiKeyServerAction = async ( } } - if (data?.apiKeyCreate.apiKey.id) { - const apiKeyId = data.apiKeyCreate.apiKey.id - try { - const limitFields: Array<{ formField: string; timeWindow: LimitTimeWindow }> = [ - { formField: "dailyLimitSats", timeWindow: LimitTimeWindow.Daily }, - { formField: "weeklyLimitSats", timeWindow: LimitTimeWindow.Weekly }, - { formField: "monthlyLimitSats", timeWindow: LimitTimeWindow.Monthly }, - { formField: "annualLimitSats", timeWindow: LimitTimeWindow.Annual }, - ] - - for (const { formField, timeWindow } of limitFields) { - const value = form.get(formField) - if (value && value !== "") { - const limit = parseInt(value as string, 10) - if (limit > 0) { - await setApiKeyLimit({ - id: apiKeyId, - limitTimeWindow: timeWindow, - limitSats: limit, - }) - } - } - } - } catch (err) { - console.log("error in setting API key limits ", err) - // Don't fail the entire operation if limits fail to set - // The API key was created successfully - } - } - return { error: false, message: "API Key created successfully", responsePayload: { apiKeySecret: data?.apiKeyCreate.apiKeySecret }, } } - -export const setDailyLimit = async ({ - id, - dailyLimitSats, -}: { - id: string - dailyLimitSats: number -}) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - if (!dailyLimitSats || dailyLimitSats <= 0) { - throw new Error("Daily limit must be greater than 0") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await setApiKeyLimit({ - id, - limitTimeWindow: LimitTimeWindow.Daily, - limitSats: dailyLimitSats, - }) - } catch (err) { - console.log("error in setApiKeyLimit (daily) ", err) - throw new Error("Failed to set API key daily limit") - } - - revalidatePath("/api-keys") -} - -export const setWeeklyLimit = async ({ - id, - weeklyLimitSats, -}: { - id: string - weeklyLimitSats: number -}) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - if (!weeklyLimitSats || weeklyLimitSats <= 0) { - throw new Error("Weekly limit must be greater than 0") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await setApiKeyLimit({ - id, - limitTimeWindow: LimitTimeWindow.Weekly, - limitSats: weeklyLimitSats, - }) - } catch (err) { - console.log("error in setApiKeyLimit (weekly) ", err) - throw new Error("Failed to set API key weekly limit") - } - - revalidatePath("/api-keys") -} - -export const setMonthlyLimit = async ({ - id, - monthlyLimitSats, -}: { - id: string - monthlyLimitSats: number -}) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - if (!monthlyLimitSats || monthlyLimitSats <= 0) { - throw new Error("Monthly limit must be greater than 0") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await setApiKeyLimit({ - id, - limitTimeWindow: LimitTimeWindow.Monthly, - limitSats: monthlyLimitSats, - }) - } catch (err) { - console.log("error in setApiKeyLimit (monthly) ", err) - throw new Error("Failed to set API key monthly limit") - } - - revalidatePath("/api-keys") -} - -export const setAnnualLimit = async ({ - id, - annualLimitSats, -}: { - id: string - annualLimitSats: number -}) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - if (!annualLimitSats || annualLimitSats <= 0) { - throw new Error("Annual limit must be greater than 0") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await setApiKeyLimit({ - id, - limitTimeWindow: LimitTimeWindow.Annual, - limitSats: annualLimitSats, - }) - } catch (err) { - console.log("error in setApiKeyLimit (annual) ", err) - throw new Error("Failed to set API key annual limit") - } - - revalidatePath("/api-keys") -} - -export const removeLimit = async ({ id }: { id: string }) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Daily }) - } catch (err) { - console.log("error in removeApiKeyLimit (daily) ", err) - throw new Error("Failed to remove API key limit") - } - - revalidatePath("/api-keys") -} - -export const removeWeeklyLimit = async ({ id }: { id: string }) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Weekly }) - } catch (err) { - console.log("error in removeApiKeyLimit (weekly) ", err) - throw new Error("Failed to remove API key weekly limit") - } - - revalidatePath("/api-keys") -} - -export const removeMonthlyLimit = async ({ id }: { id: string }) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Monthly }) - } catch (err) { - console.log("error in removeApiKeyLimit (monthly) ", err) - throw new Error("Failed to remove API key monthly limit") - } - - revalidatePath("/api-keys") -} - -export const removeAnnualLimit = async ({ id }: { id: string }) => { - if (!id || typeof id !== "string") { - throw new Error("API Key ID is not present") - } - - const session = await getServerSession(authOptions) - const token = session?.accessToken - if (!token || typeof token !== "string") { - throw new Error("Token is not present") - } - - try { - await removeApiKeyLimit({ id, limitTimeWindow: LimitTimeWindow.Annual }) - } catch (err) { - console.log("error in removeApiKeyLimit (annual) ", err) - throw new Error("Failed to remove API key annual limit") - } - - revalidatePath("/api-keys") -} diff --git a/apps/dashboard/components/api-keys/form.tsx b/apps/dashboard/components/api-keys/form.tsx index ecdec3f881..9d71c925f4 100644 --- a/apps/dashboard/components/api-keys/form.tsx +++ b/apps/dashboard/components/api-keys/form.tsx @@ -23,7 +23,6 @@ type ApiKeyFormProps = { const ApiKeyForm = ({ state, formAction }: ApiKeyFormProps) => { const [enableCustomExpiresInDays, setEnableCustomExpiresInDays] = useState(false) const [expiresInDays, setExpiresInDays] = useState(null) - const [showSpendingLimits, setShowSpendingLimits] = useState(false) const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() @@ -54,11 +53,6 @@ const ApiKeyForm = ({ state, formAction }: ApiKeyFormProps) => { )} {state.error && } - - {showSpendingLimits && } @@ -185,84 +179,6 @@ const ScopeCheckboxes = () => ( ) -const SpendingLimitsToggle = ({ - showSpendingLimits, - setShowSpendingLimits, -}: { - showSpendingLimits: boolean - setShowSpendingLimits: (value: boolean) => void -}) => ( - - setShowSpendingLimits(e.target.checked)} - label="Set budget limits" - /> - -) - -const SpendingLimitsInputs = () => ( - - - Limits (in satoshis) - - - Daily Limit - - Rolling 24-hour window - - - Weekly Limit - - Rolling 7-day window - - - Monthly Limit - - Rolling 30-day window - - - Annual Limit - - Rolling 365-day window - - -) - const SubmitButton = () => ( = ({
NameAPI Key IDScopeExpires AtLast UsedActionNameAPI Key IDScopeBudget LimitsExpires AtLast UsedActions
{name} {id} {getScopeText(scopes)} + {hasAnyLimit ? ( + + {dailyLimitSats && ( +
+ + Daily: {dailyLimitSats.toLocaleString()} sats + + + Spent: {spentLast24HSats.toLocaleString()} / Remaining:{" "} + {remainingDailyLimitSats?.toLocaleString() || 0} + +
+ )} + {weeklyLimitSats && ( +
+ + Weekly: {weeklyLimitSats.toLocaleString()}{" "} + sats + + + Spent: {spentLast7DSats.toLocaleString()} / Remaining:{" "} + {(weeklyLimitSats - spentLast7DSats).toLocaleString()} + +
+ )} + {monthlyLimitSats && ( +
+ + Monthly: {monthlyLimitSats.toLocaleString()}{" "} + sats + + + Spent: {spentLast30DSats.toLocaleString()} / Remaining:{" "} + {(monthlyLimitSats - spentLast30DSats).toLocaleString()} + +
+ )} + {annualLimitSats && ( +
+ + Annual: {annualLimitSats.toLocaleString()}{" "} + sats + + + Spent: {spentLast365DSats.toLocaleString()} / Remaining:{" "} + {(annualLimitSats - spentLast365DSats).toLocaleString()} + +
+ )} +
+ ) : ( + + Unlimited + + )} +
{expiresAt ? formatDate(expiresAt) : "Never"} {lastUsedAt ? formatDate(lastUsedAt) : "Never"} - + + + +
- - - - - - - + + + + + + - {activeKeys.map(({ id, name, expiresAt, lastUsedAt, scopes, limits }) => { - const dailyLimitSats = limits?.dailyLimitSats - const weeklyLimitSats = limits?.weeklyLimitSats - const monthlyLimitSats = limits?.monthlyLimitSats - const annualLimitSats = limits?.annualLimitSats - const dailySpentSats = limits?.dailySpentSats ?? 0 - const weeklySpentSats = limits?.weeklySpentSats ?? 0 - const monthlySpentSats = limits?.monthlySpentSats ?? 0 - const annualSpentSats = limits?.annualSpentSats ?? 0 - - const remainingDailyLimitSats = - dailyLimitSats !== null && dailyLimitSats !== undefined - ? dailyLimitSats - dailySpentSats - : null - - const hasAnyLimit = - dailyLimitSats || weeklyLimitSats || monthlyLimitSats || annualLimitSats - + {activeKeys.map(({ id, name, expiresAt, lastUsedAt, scopes }) => { return ( - ) diff --git a/apps/dashboard/services/graphql/generated.ts b/apps/dashboard/services/graphql/generated.ts index 86451398d9..b4535f35c9 100644 --- a/apps/dashboard/services/graphql/generated.ts +++ b/apps/dashboard/services/graphql/generated.ts @@ -2536,7 +2536,7 @@ export type ApiKeyCreateMutationVariables = Exact<{ }>; -export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly dailySpentSats: number, readonly weeklySpentSats: number, readonly monthlySpentSats: number, readonly annualSpentSats: number } } } }; +export type ApiKeyCreateMutation = { readonly __typename: 'Mutation', readonly apiKeyCreate: { readonly __typename: 'ApiKeyCreatePayload', readonly apiKeySecret: string, readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; export type ApiKeyRevokeMutationVariables = Exact<{ input: ApiKeyRevokeInput; @@ -2545,20 +2545,6 @@ export type ApiKeyRevokeMutationVariables = Exact<{ export type ApiKeyRevokeMutation = { readonly __typename: 'Mutation', readonly apiKeyRevoke: { readonly __typename: 'ApiKeyRevokePayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly createdAt: number, readonly revoked: boolean, readonly expired: boolean, readonly lastUsedAt?: number | null, readonly expiresAt?: number | null, readonly scopes: ReadonlyArray } } }; -export type ApiKeySetLimitMutationVariables = Exact<{ - input: ApiKeySetLimitInput; -}>; - - -export type ApiKeySetLimitMutation = { readonly __typename: 'Mutation', readonly apiKeySetLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly dailySpentSats: number, readonly weeklySpentSats: number, readonly monthlySpentSats: number, readonly annualSpentSats: number } } } }; - -export type ApiKeyRemoveLimitMutationVariables = Exact<{ - input: ApiKeyRemoveLimitInput; -}>; - - -export type ApiKeyRemoveLimitMutation = { readonly __typename: 'Mutation', readonly apiKeyRemoveLimit: { readonly __typename: 'ApiKeySetLimitPayload', readonly apiKey: { readonly __typename: 'ApiKey', readonly id: string, readonly name: string, readonly limits: { readonly __typename: 'ApiKeyLimits', readonly dailyLimitSats?: number | null, readonly weeklyLimitSats?: number | null, readonly monthlyLimitSats?: number | null, readonly annualLimitSats?: number | null, readonly dailySpentSats: number, readonly weeklySpentSats: number, readonly monthlySpentSats: number, readonly annualSpentSats: number } } } }; - export type CallbackEndpointAddMutationVariables = Exact<{ input: CallbackEndpointAddInput; }>; @@ -2681,16 +2667,6 @@ export const ApiKeyCreateDocument = gql` lastUsedAt expiresAt scopes - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - dailySpentSats - weeklySpentSats - monthlySpentSats - annualSpentSats - } } apiKeySecret } @@ -2764,98 +2740,6 @@ export function useApiKeyRevokeMutation(baseOptions?: Apollo.MutationHookOptions export type ApiKeyRevokeMutationHookResult = ReturnType; export type ApiKeyRevokeMutationResult = Apollo.MutationResult; export type ApiKeyRevokeMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeySetLimitDocument = gql` - mutation ApiKeySetLimit($input: ApiKeySetLimitInput!) { - apiKeySetLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - dailySpentSats - weeklySpentSats - monthlySpentSats - annualSpentSats - } - } - } -} - `; -export type ApiKeySetLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeySetLimitMutation__ - * - * To run a mutation, you first call `useApiKeySetLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeySetLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeySetLimitMutation, { data, loading, error }] = useApiKeySetLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeySetLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeySetLimitDocument, options); - } -export type ApiKeySetLimitMutationHookResult = ReturnType; -export type ApiKeySetLimitMutationResult = Apollo.MutationResult; -export type ApiKeySetLimitMutationOptions = Apollo.BaseMutationOptions; -export const ApiKeyRemoveLimitDocument = gql` - mutation ApiKeyRemoveLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - dailySpentSats - weeklySpentSats - monthlySpentSats - annualSpentSats - } - } - } -} - `; -export type ApiKeyRemoveLimitMutationFn = Apollo.MutationFunction; - -/** - * __useApiKeyRemoveLimitMutation__ - * - * To run a mutation, you first call `useApiKeyRemoveLimitMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useApiKeyRemoveLimitMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [apiKeyRemoveLimitMutation, { data, loading, error }] = useApiKeyRemoveLimitMutation({ - * variables: { - * input: // value for 'input' - * }, - * }); - */ -export function useApiKeyRemoveLimitMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(ApiKeyRemoveLimitDocument, options); - } -export type ApiKeyRemoveLimitMutationHookResult = ReturnType; -export type ApiKeyRemoveLimitMutationResult = Apollo.MutationResult; -export type ApiKeyRemoveLimitMutationOptions = Apollo.BaseMutationOptions; export const CallbackEndpointAddDocument = gql` mutation CallbackEndpointAdd($input: CallbackEndpointAddInput!) { callbackEndpointAdd(input: $input) { diff --git a/apps/dashboard/services/graphql/mutations/api-keys.ts b/apps/dashboard/services/graphql/mutations/api-keys.ts index 6326c85b6e..a0a0a414d8 100644 --- a/apps/dashboard/services/graphql/mutations/api-keys.ts +++ b/apps/dashboard/services/graphql/mutations/api-keys.ts @@ -6,11 +6,6 @@ import { ApiKeyCreateMutation, ApiKeyRevokeDocument, ApiKeyRevokeMutation, - ApiKeySetLimitDocument, - ApiKeySetLimitMutation, - ApiKeyRemoveLimitDocument, - ApiKeyRemoveLimitMutation, - LimitTimeWindow, Scope, } from "../generated" @@ -26,16 +21,6 @@ gql` lastUsedAt expiresAt scopes - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - dailySpentSats - weeklySpentSats - monthlySpentSats - annualSpentSats - } } apiKeySecret } @@ -55,44 +40,6 @@ gql` } } } - - mutation ApiKeySetLimit($input: ApiKeySetLimitInput!) { - apiKeySetLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - dailySpentSats - weeklySpentSats - monthlySpentSats - annualSpentSats - } - } - } - } - - mutation ApiKeyRemoveLimit($input: ApiKeyRemoveLimitInput!) { - apiKeyRemoveLimit(input: $input) { - apiKey { - id - name - limits { - dailyLimitSats - weeklyLimitSats - monthlyLimitSats - annualLimitSats - dailySpentSats - weeklySpentSats - monthlySpentSats - annualSpentSats - } - } - } - } ` export async function createApiKey({ @@ -130,45 +77,3 @@ export async function revokeApiKey({ id }: { id: string }) { throw new Error("Error in apiKeyRevoke") } } - -export async function setApiKeyLimit({ - id, - limitTimeWindow, - limitSats, -}: { - id: string - limitTimeWindow: LimitTimeWindow - limitSats: number -}) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeySetLimitDocument, - variables: { input: { id, limitTimeWindow, limitSats } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeySetLimit ==> ", error) - throw new Error("Error in apiKeySetLimit") - } -} - -export async function removeApiKeyLimit({ - id, - limitTimeWindow, -}: { - id: string - limitTimeWindow: LimitTimeWindow -}) { - const client = await apolloClient.authenticated() - try { - const { data } = await client.mutate({ - mutation: ApiKeyRemoveLimitDocument, - variables: { input: { id, limitTimeWindow } }, - }) - return data - } catch (error) { - console.error("Error executing mutation: apiKeyRemoveLimit ==> ", error) - throw new Error("Error in apiKeyRemoveLimit") - } -} From ab81c423a46175394c8371a7f1237206930e3257 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 22:03:54 -0500 Subject: [PATCH 18/33] chore: remove new dashboard limit.tsx (kept in separate branch) --- apps/dashboard/components/api-keys/limit.tsx | 289 ------------------- 1 file changed, 289 deletions(-) delete mode 100644 apps/dashboard/components/api-keys/limit.tsx diff --git a/apps/dashboard/components/api-keys/limit.tsx b/apps/dashboard/components/api-keys/limit.tsx deleted file mode 100644 index 7ff7d2b5b1..0000000000 --- a/apps/dashboard/components/api-keys/limit.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import React, { useState } from "react" -import { - Button, - Modal, - ModalDialog, - Typography, - Input, - FormControl, - FormLabel, - Stack, - Tabs, - TabList, - Tab, - TabPanel, - Box, -} from "@mui/joy" - -import { - setDailyLimit, - setWeeklyLimit, - setMonthlyLimit, - setAnnualLimit, - removeLimit, - removeWeeklyLimit, - removeMonthlyLimit, - removeAnnualLimit, -} from "@/app/api-keys/server-actions" - -type LimitPeriod = "daily" | "weekly" | "monthly" | "annual" - -interface LimitProps { - id: string - limits: { - daily: number | null - weekly: number | null - monthly: number | null - annual: number | null - } - spent: { - last24h: number - last7d: number - last30d: number - last365d: number - } -} - -const Limit: React.FC = ({ id, limits, spent }) => { - const [open, setOpen] = useState(false) - const [selectedPeriod, setSelectedPeriod] = useState("daily") - const [limitValues, setLimitValues] = useState({ - daily: limits.daily?.toString() || "", - weekly: limits.weekly?.toString() || "", - monthly: limits.monthly?.toString() || "", - annual: limits.annual?.toString() || "", - }) - const [loading, setLoading] = useState(false) - - const periodConfig = { - daily: { - label: "Daily (24h)", - description: "Set a rolling 24-hour spending limit", - currentLimit: limits.daily, - spent: spent.last24h, - setValue: (val: string) => setLimitValues({ ...limitValues, daily: val }), - getValue: () => limitValues.daily, - }, - weekly: { - label: "Weekly (7 days)", - description: "Set a rolling 7-day spending limit", - currentLimit: limits.weekly, - spent: spent.last7d, - setValue: (val: string) => setLimitValues({ ...limitValues, weekly: val }), - getValue: () => limitValues.weekly, - }, - monthly: { - label: "Monthly (30 days)", - description: "Set a rolling 30-day spending limit", - currentLimit: limits.monthly, - spent: spent.last30d, - setValue: (val: string) => setLimitValues({ ...limitValues, monthly: val }), - getValue: () => limitValues.monthly, - }, - annual: { - label: "Annual (365 days)", - description: "Set a rolling 365-day spending limit", - currentLimit: limits.annual, - spent: spent.last365d, - setValue: (val: string) => setLimitValues({ ...limitValues, annual: val }), - getValue: () => limitValues.annual, - }, - } - - const handleSetLimit = async (period: LimitPeriod) => { - const config = periodConfig[period] - const limitValue = config.getValue() - - if (!limitValue || parseInt(limitValue) <= 0) { - alert("Please enter a valid limit in satoshis") - return - } - - setLoading(true) - try { - const satsValue = parseInt(limitValue) - switch (period) { - case "daily": - await setDailyLimit({ id, dailyLimitSats: satsValue }) - break - case "weekly": - await setWeeklyLimit({ id, weeklyLimitSats: satsValue }) - break - case "monthly": - await setMonthlyLimit({ id, monthlyLimitSats: satsValue }) - break - case "annual": - await setAnnualLimit({ id, annualLimitSats: satsValue }) - break - } - setOpen(false) - window.location.reload() - } catch (error) { - console.error("Error setting limit:", error) - alert("Failed to set limit. Please try again.") - } finally { - setLoading(false) - } - } - - const handleRemoveLimit = async (period: LimitPeriod) => { - const periodLabels = { - daily: "daily", - weekly: "weekly", - monthly: "monthly", - annual: "annual", - } - - if ( - !confirm( - `Are you sure you want to remove the ${periodLabels[period]} spending limit?`, - ) - ) { - return - } - - setLoading(true) - try { - switch (period) { - case "daily": - await removeLimit({ id }) - break - case "weekly": - await removeWeeklyLimit({ id }) - break - case "monthly": - await removeMonthlyLimit({ id }) - break - case "annual": - await removeAnnualLimit({ id }) - break - } - setOpen(false) - window.location.reload() - } catch (error) { - console.error("Error removing limit:", error) - alert("Failed to remove limit. Please try again.") - } finally { - setLoading(false) - } - } - - const formatSats = (sats: number | null) => { - if (sats === null) return "Unlimited" - return `${sats.toLocaleString()} sats` - } - - const hasAnyLimit = limits.daily || limits.weekly || limits.monthly || limits.annual - - return ( - <> - - - setOpen(false)}> - - Budget Limits - - Configure rolling budget limits for different time periods - - - setSelectedPeriod(value as LimitPeriod)} - > - - Daily - Weekly - Monthly - Annual - - - {(Object.keys(periodConfig) as LimitPeriod[]).map((period) => { - const config = periodConfig[period] - const remaining = config.currentLimit - ? config.currentLimit - config.spent - : null - - return ( - - - {config.description} - - {config.currentLimit && ( - - - - Current Limit:{" "} - {formatSats(config.currentLimit)} - - - Spent: {formatSats(config.spent)} - - - Remaining: {formatSats(remaining)} - - - - )} - - - {config.label} Limit (satoshis) - config.setValue(e.target.value)} - placeholder="Enter limit in sats (e.g., 100000)" - disabled={loading} - /> - - - - - {config.currentLimit && ( - - )} - - - - ) - })} - - - - - - - - - ) -} - -export default Limit From 21842564960f794a6e4010a1c077045a66a32ce8 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 22:12:47 -0500 Subject: [PATCH 19/33] fix: correct Writeable typo to Writable --- .../src/domain/bitcoin/lightning/payments/index.types.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/api/src/domain/bitcoin/lightning/payments/index.types.d.ts b/core/api/src/domain/bitcoin/lightning/payments/index.types.d.ts index c61a0e8d0f..ddcf17f9b3 100644 --- a/core/api/src/domain/bitcoin/lightning/payments/index.types.d.ts +++ b/core/api/src/domain/bitcoin/lightning/payments/index.types.d.ts @@ -5,11 +5,11 @@ type LnPaymentPartial = { } // Makes all properties non-readonly except the properties passed in as K -type Writeable = Pick & { +type Writable = Pick & { -readonly [P in keyof T as Exclude]: T[P] } -type PersistedLnPaymentLookup = Writeable & { +type PersistedLnPaymentLookup = Writable & { readonly sentFromPubkey: Pubkey isCompleteRecord: boolean } From 8de200d4e35fc4047747d6eaf82e7bf5440d7d92 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 22:15:22 -0500 Subject: [PATCH 20/33] fix: bats lint issues --- bats/core/api-keys/api-keys-hold-invoice-reversal.bats | 2 +- bats/core/api-keys/api-keys-limits.bats | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats index 9255a25c55..49498fb604 100644 --- a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats +++ b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats @@ -145,4 +145,4 @@ setup_file() { [[ "${spent_24h}" = "0" ]] || exit 1 echo "✅ Spending reversed: ${spent_24h} sats" -} \ No newline at end of file +} diff --git a/bats/core/api-keys/api-keys-limits.bats b/bats/core/api-keys/api-keys-limits.bats index 02f8c70b38..02c4858f7c 100644 --- a/bats/core/api-keys/api-keys-limits.bats +++ b/bats/core/api-keys/api-keys-limits.bats @@ -365,7 +365,7 @@ setup_file() { variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" exec_graphql 'alice' 'api-key-create' "$variables" - + key="$(graphql_output '.data.apiKeyCreate.apiKey')" secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" key_id=$(echo "$key" | jq -r '.id') @@ -428,7 +428,7 @@ setup_file() { variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" exec_graphql 'alice' 'api-key-create' "$variables" - + key="$(graphql_output '.data.apiKeyCreate.apiKey')" secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" key_id=$(echo "$key" | jq -r '.id') @@ -485,7 +485,7 @@ setup_file() { @test "api-keys-limits: mixed payment flows tracked separately per key" { # Verify that each API key tracks its own spending independently - + # Check intraledger key spending (original key from earlier tests) exec_graphql 'alice' 'api-keys' intraledger_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.dailySpentSats')" @@ -512,7 +512,7 @@ setup_file() { variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" exec_graphql 'alice' 'api-key-create' "$variables" - + key="$(graphql_output '.data.apiKeyCreate.apiKey')" secret="$(graphql_output '.data.apiKeyCreate.apiKeySecret')" key_id=$(echo "$key" | jq -r '.id') @@ -706,4 +706,4 @@ setup_file() { # Verify error message contains limit information error_msg="$(graphql_output '.data.lnurlPaymentSend.errors[0].message')" [[ "${error_msg}" == *"daily"* ]] || exit 1 -} \ No newline at end of file +} From 316fced6c2cd07370245eed402265267f5e1cff5 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Tue, 7 Apr 2026 23:46:49 -0500 Subject: [PATCH 21/33] test(api-keys): add spending limits and hold invoice reversal BATS tests --- .../api-keys-hold-invoice-reversal.bats | 80 +++-------- bats/core/api-keys/api-keys-limits.bats | 124 ++++++------------ bats/gql/intraledger-payment-send.gql | 1 + bats/gql/intraledger-usd-payment-send.gql | 1 + bats/gql/ln-invoice-payment-send.gql | 1 + .../gql/ln-no-amount-invoice-payment-send.gql | 1 + .../ln-no-amount-usd-invoice-payment-send.gql | 1 + bats/gql/lnurl-payment-send.gql | 1 + bats/gql/on-chain-payment-send.gql | 1 + package.json | 3 +- pnpm-lock.yaml | 94 ++++++------- 11 files changed, 103 insertions(+), 205 deletions(-) diff --git a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats index 49498fb604..2bbd95b35b 100644 --- a/bats/core/api-keys/api-keys-hold-invoice-reversal.bats +++ b/bats/core/api-keys/api-keys-hold-invoice-reversal.bats @@ -2,26 +2,11 @@ # Tests for API key spending reversal when hold invoices are canceled/timeout -load "../../helpers/_common.bash" -load "../../helpers/cli.bash" load "../../helpers/user.bash" load "../../helpers/onchain.bash" load "../../helpers/ln.bash" -random_uuid() { - if [[ -e /proc/sys/kernel/random/uuid ]]; then - cat /proc/sys/kernel/random/uuid - else - uuidgen - fi -} - -new_key_name() { - random_uuid -} - ALICE='alice' -BOB='bob' setup_file() { clear_cache @@ -38,7 +23,7 @@ setup_file() { } @test "hold-invoice-reversal: create api key with daily limit" { - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'hold_invoice_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -55,7 +40,6 @@ setup_file() { key_id=$(echo "$key" | jq -r '.id') cache_value "hold-invoice-api-key-id" "$key_id" - # Set daily limit to 10000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":10000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" @@ -67,20 +51,17 @@ setup_file() { } @test "hold-invoice-reversal: pay hold invoice and check spending recorded" { - # Generate a random preimage and hash for the hold invoice secret=$(xxd -l 32 -c 256 -p /dev/urandom) payment_hash=$(echo -n $secret | xxd -r -p | sha256sum | cut -d ' ' -f1) cache_value "hold-invoice-preimage" "$secret" cache_value "hold-invoice-payment-hash" "$payment_hash" - # Create a hold invoice on external LND with explicit hash invoice_response="$(lnd_outside_cli addholdinvoice "$payment_hash" --amt 5000 --memo 'Test hold invoice')" - payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_request="$(echo "$invoice_response" | jq -r '.payment_request')" cache_value "hold-invoice-payment-request" "$payment_request" - # Pay the invoice with API key variables=$( jq -n \ --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ @@ -90,59 +71,28 @@ setup_file() { exec_graphql 'api-key-hold-invoice-secret' 'ln-invoice-payment-send' "$variables" send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" - - # Payment should be pending (held) [[ "${send_status}" = "PENDING" || "${send_status}" = "SUCCESS" ]] || exit 1 - # Check that spending was recorded - exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.dailySpentSats')" - - # Should have recorded 5000 sats spending - [[ "${spent_24h}" -ge "5000" ]] || exit 1 - - echo "✅ Spending recorded: ${spent_24h} sats" -} - -@test "hold-invoice-reversal: verify payment is held on external LND" { - payment_hash="$(read_value 'hold-invoice-payment-hash')" - - # Check invoice status on external LND invoice_info="$(lnd_outside_cli lookupinvoice "$payment_hash")" - state="$(echo $invoice_info | jq -r '.state')" - - # Invoice should be in ACCEPTED state (held, not settled) + state="$(echo "$invoice_info" | jq -r '.state')" [[ "${state}" = "ACCEPTED" ]] || exit 1 - echo "✅ Invoice is held (ACCEPTED state)" - echo "Payment hash: $payment_hash" -} - -@test "hold-invoice-reversal: cancel hold invoice" { - payment_hash="$(read_value 'hold-invoice-payment-hash')" - - # Cancel the held invoice on external LND - cancel_result="$(lnd_outside_cli cancelinvoice "$payment_hash" 2>&1 || true)" - - echo "Cancel result: $cancel_result" - - # Give trigger server time to process the cancellation - sleep 10 + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.dailySpentSats')" + [[ "${spent_24h}" -ge "5000" ]] || exit 1 } @test "hold-invoice-reversal: verify spending was reversed after cancellation" { - # Trigger the pending payment update to process the cancellation - # This simulates what the trigger server does every 5 minutes + payment_hash="$(read_value 'hold-invoice-payment-hash')" - # Wait a bit more for the system to process - sleep 5 + lnd_outside_cli cancelinvoice "$payment_hash" - # Check that spending was reversed - exec_graphql 'alice' 'api-keys' - spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.dailySpentSats')" - - # Spending should be back to 0 after reversal - [[ "${spent_24h}" = "0" ]] || exit 1 + check_spending_reversed() { + exec_graphql 'alice' 'api-keys' + spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'hold_invoice_key_name')'") | .limits.dailySpentSats')" + [[ "${spent_24h}" = "0" ]] || exit 1 + } - echo "✅ Spending reversed: ${spent_24h} sats" + # Poll until the trigger server processes the cancellation and reverses spending + retry 30 1 check_spending_reversed } diff --git a/bats/core/api-keys/api-keys-limits.bats b/bats/core/api-keys/api-keys-limits.bats index 02c4858f7c..c5a40eff1c 100644 --- a/bats/core/api-keys/api-keys-limits.bats +++ b/bats/core/api-keys/api-keys-limits.bats @@ -1,23 +1,9 @@ #!/usr/bin/env bats -load "../../helpers/_common.bash" -load "../../helpers/cli.bash" load "../../helpers/user.bash" load "../../helpers/onchain.bash" load "../../helpers/ln.bash" -random_uuid() { - if [[ -e /proc/sys/kernel/random/uuid ]]; then - cat /proc/sys/kernel/random/uuid - else - uuidgen - fi -} - -new_key_name() { - random_uuid -} - ALICE='alice' BOB='bob' @@ -37,11 +23,12 @@ setup_file() { create_user "$BOB" user_update_username "$BOB" + # xyz_zap_receiver is a user whose lnurl address is hardcoded in the lnurlPaymentSend tests below. ensure_username_is_present "xyz_zap_receiver" } @test "api-keys-limits: create key and set daily limit" { - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'limit_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -58,7 +45,6 @@ setup_file() { key_id=$(echo "$key" | jq -r '.id') cache_value "limit-api-key-id" "$key_id" - # Set daily limit to 10000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":10000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" @@ -86,7 +72,6 @@ setup_file() { send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Check spending was recorded exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "$amount" ]] || exit 1 @@ -107,14 +92,14 @@ setup_file() { exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" - - # Should fail due to limit [[ "${send_status}" = "FAILURE" ]] || exit 1 errors="$(graphql_output '.data.intraLedgerPaymentSend.errors | length')" [[ "${errors}" -ge "1" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a daily spending limit restriction + error_code="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" [[ "${error_msg}" == *"daily"* ]] || exit 1 } @@ -122,7 +107,6 @@ setup_file() { @test "api-keys-limits: set weekly limit" { key_id=$(read_value "limit-api-key-id") - # Set weekly limit to 50000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"WEEKLY\",\"limitSats\":50000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" @@ -157,18 +141,14 @@ setup_file() { send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Check total spending across all time periods exec_graphql 'alice' 'api-keys' spent_7d="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.weeklySpentSats')" - - # Should have accumulated spending from previous tests [[ "${spent_7d}" -ge "8000" ]] || exit 1 } @test "api-keys-limits: set monthly and annual limits" { key_id=$(read_value "limit-api-key-id") - # Set monthly limit to 100000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"MONTHLY\",\"limitSats\":100000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" monthly_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.monthlyLimitSats')" @@ -177,7 +157,6 @@ setup_file() { spent_30d="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.monthlySpentSats')" [[ "${spent_30d}" -ge "8000" ]] || exit 1 - # Set annual limit to 500000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"ANNUAL\",\"limitSats\":500000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" annual_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.annualLimitSats')" @@ -193,8 +172,6 @@ setup_file() { # - Weekly: 50000 sats (spent: ~8000) # - Monthly: 100000 sats (spent: ~8000) # - Annual: 500000 sats (spent: ~8000) - - # Try to send 45000 sats - this would exceed weekly limit local from_wallet_name="$ALICE.btc_wallet_id" local to_wallet_name="$BOB.btc_wallet_id" local amount=45000 @@ -209,11 +186,11 @@ setup_file() { exec_graphql 'api-key-limit-secret' 'intraledger-payment-send' "$variables" send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" - - # Should fail due to weekly limit [[ "${send_status}" = "FAILURE" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a weekly spending limit restriction + error_code="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" [[ "${error_msg}" == *"weekly"* ]] || exit 1 } @@ -236,7 +213,6 @@ setup_file() { send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending updated across all time windows exec_graphql 'alice' 'api-keys' key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" @@ -255,7 +231,6 @@ setup_file() { exec_graphql 'alice' 'api-keys' key_data="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'")')" - # Verify all limits are still set daily_limit=$(echo "$key_data" | jq -r '.limits.dailyLimitSats') weekly_limit=$(echo "$key_data" | jq -r '.limits.weeklyLimitSats') monthly_limit=$(echo "$key_data" | jq -r '.limits.monthlyLimitSats') @@ -266,7 +241,7 @@ setup_file() { [[ "${monthly_limit}" = "100000" ]] || exit 1 [[ "${annual_limit}" = "500000" ]] || exit 1 - # Verify spending is consistent across all time windows (since all payments are within last 24h) + # All payments occurred within the last 24h so all windows should show the same total spent_24h=$(echo "$key_data" | jq -r '.limits.dailySpentSats') spent_7d=$(echo "$key_data" | jq -r '.limits.weeklySpentSats') spent_30d=$(echo "$key_data" | jq -r '.limits.monthlySpentSats') @@ -286,7 +261,7 @@ setup_file() { weekly_limit="$(graphql_output '.data.apiKeySetLimit.apiKey.limits.weeklyLimitSats')" [[ "${weekly_limit}" = "40000" ]] || exit 1 - # Try to send 3000 - should fail as it would exceed updated limit + # 3000 sats would exceed the newly lowered limit local from_wallet_name="$ALICE.btc_wallet_id" local to_wallet_name="$BOB.btc_wallet_id" local amount=3000 @@ -303,7 +278,9 @@ setup_file() { send_status="$(graphql_output '.data.intraLedgerPaymentSend.status')" [[ "${send_status}" = "FAILURE" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a weekly spending limit restriction + error_code="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.intraLedgerPaymentSend.errors[0].message')" [[ "${error_msg}" == *"weekly"* ]] || exit 1 } @@ -311,19 +288,16 @@ setup_file() { @test "api-keys-limits: remove all limits" { key_id=$(read_value "limit-api-key-id") - # Remove weekly limit variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"WEEKLY\"}}" exec_graphql 'alice' 'api-key-remove-limit' "$variables" weekly_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.weeklyLimitSats')" [[ "${weekly_limit}" = "null" ]] || exit 1 - # Remove monthly limit variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"MONTHLY\"}}" exec_graphql 'alice' 'api-key-remove-limit' "$variables" monthly_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.monthlyLimitSats')" [[ "${monthly_limit}" = "null" ]] || exit 1 - # Remove annual limit variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"ANNUAL\"}}" exec_graphql 'alice' 'api-key-remove-limit' "$variables" annual_limit="$(graphql_output '.data.apiKeyRemoveLimit.apiKey.limits.annualLimitSats')" @@ -331,7 +305,6 @@ setup_file() { } @test "api-keys-limits: can send large amount with no limits" { - # With all limits removed, should be able to send larger amounts local from_wallet_name="$ALICE.btc_wallet_id" local to_wallet_name="$BOB.btc_wallet_id" local amount=100000 @@ -359,8 +332,7 @@ setup_file() { # ============================================================================ @test "api-keys-limits: lightning payment respects limits" { - # Create new API key with daily limit for lightning tests - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'ln_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -373,14 +345,12 @@ setup_file() { cache_value "api-key-ln-secret" "$secret" cache_value "ln-api-key-id" "$key_id" - # Set daily limit to 5000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":5000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" - # Create invoice for 3000 sats invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" - payment_request="$(echo $invoice_response | jq -r '.payment_request')" - payment_hash=$(echo $invoice_response | jq -r '.r_hash') + payment_request="$(echo "$invoice_response" | jq -r '.payment_request')" + payment_hash=$(echo "$invoice_response" | jq -r '.r_hash') variables=$( jq -n \ @@ -389,12 +359,10 @@ setup_file() { '{input: {walletId: $wallet_id, paymentRequest: $payment_request}}' ) - # Send lightning payment with API key exec_graphql 'api-key-ln-secret' 'ln-invoice-payment-send' "$variables" send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending was recorded exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "3000" ]] || exit 1 @@ -403,7 +371,7 @@ setup_file() { @test "api-keys-limits: lightning payment exceeding limit fails" { # Try to send 3000 more sats (would exceed 5000 limit) invoice_response="$(lnd_outside_cli addinvoice --amt 3000)" - payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_request="$(echo "$invoice_response" | jq -r '.payment_request')" variables=$( jq -n \ @@ -416,14 +384,15 @@ setup_file() { send_status="$(graphql_output '.data.lnInvoicePaymentSend.status')" [[ "${send_status}" = "FAILURE" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a daily spending limit restriction + error_code="$(graphql_output '.data.lnInvoicePaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.lnInvoicePaymentSend.errors[0].message')" [[ "${error_msg}" == *"daily"* ]] || exit 1 } @test "api-keys-limits: onchain payment respects limits" { - # Create new API key with daily limit for onchain tests - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'onchain_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -436,14 +405,11 @@ setup_file() { cache_value "api-key-onchain-secret" "$secret" cache_value "onchain-api-key-id" "$key_id" - # Set daily limit to 10000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":10000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" - # Create onchain address onchain_address=$(bitcoin_cli getnewaddress) - # Send onchain payment for 5000 sats with API key variables=$( jq -n \ --arg wallet_id "$(read_value $ALICE.btc_wallet_id)" \ @@ -456,7 +422,6 @@ setup_file() { send_status="$(graphql_output '.data.onChainPaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending was recorded exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "5000" ]] || exit 1 @@ -478,37 +443,34 @@ setup_file() { send_status="$(graphql_output '.data.onChainPaymentSend.status')" [[ "${send_status}" = "FAILURE" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a daily spending limit restriction + error_code="$(graphql_output '.data.onChainPaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.onChainPaymentSend.errors[0].message')" [[ "${error_msg}" == *"daily"* ]] || exit 1 } @test "api-keys-limits: mixed payment flows tracked separately per key" { - # Verify that each API key tracks its own spending independently + exec_graphql 'alice' 'api-keys' # Check intraledger key spending (original key from earlier tests) - exec_graphql 'alice' 'api-keys' intraledger_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'limit_key_name')'") | .limits.dailySpentSats')" [[ "${intraledger_spent}" -ge "130000" ]] || exit 1 - # Check lightning key spending (separate key) ln_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_key_name')'") | .limits.dailySpentSats')" [[ "${ln_spent}" -ge "3000" ]] || exit 1 [[ "${ln_spent}" -lt "10000" ]] || exit 1 - # Check onchain key spending (separate key) onchain_spent="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'onchain_key_name')'") | .limits.dailySpentSats')" [[ "${onchain_spent}" -ge "5000" ]] || exit 1 [[ "${onchain_spent}" -lt "10000" ]] || exit 1 - # Each key should have independent spending totals [[ "${intraledger_spent}" != "${ln_spent}" ]] || exit 1 [[ "${intraledger_spent}" != "${onchain_spent}" ]] || exit 1 } @test "api-keys-limits: USD wallet payments also respect limits" { - # Create API key with daily limit for USD wallet tests - key_name="$(new_key_name)" + key_name="$(random_uuid)" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" exec_graphql 'alice' 'api-key-create' "$variables" @@ -519,7 +481,6 @@ setup_file() { cache_value "api-key-usd-secret" "$secret" - # Set daily limit to 50000 sats (in satoshi equivalent) variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":50000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" @@ -536,7 +497,6 @@ setup_file() { send_status="$(graphql_output '.data.intraLedgerUsdPaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending was recorded (converted to sats) exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$key_name'") | .limits.dailySpentSats')" # USD amount converted to sats should be tracked @@ -544,8 +504,7 @@ setup_file() { } @test "api-keys-limits: lnNoAmountInvoicePaymentSend respects limits" { - # Create new API key with daily limit - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'ln_noamount_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -558,13 +517,11 @@ setup_file() { cache_value "api-key-ln-noamount-secret" "$secret" cache_value "ln-noamount-api-key-id" "$key_id" - # Set daily limit to 8000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":8000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" - # Create no-amount invoice invoice_response="$(lnd_outside_cli addinvoice)" - payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_request="$(echo "$invoice_response" | jq -r '.payment_request')" # Pay 4000 sats to the no-amount invoice variables=$( @@ -579,7 +536,6 @@ setup_file() { send_status="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending was recorded exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "4000" ]] || exit 1 @@ -588,7 +544,7 @@ setup_file() { @test "api-keys-limits: lnNoAmountInvoicePaymentSend exceeding limit fails" { # Try to pay 5000 more sats (would exceed 8000 limit) invoice_response="$(lnd_outside_cli addinvoice)" - payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_request="$(echo "$invoice_response" | jq -r '.payment_request')" variables=$( jq -n \ @@ -602,14 +558,15 @@ setup_file() { send_status="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.status')" [[ "${send_status}" = "FAILURE" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a daily spending limit restriction + error_code="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.lnNoAmountInvoicePaymentSend.errors[0].message')" [[ "${error_msg}" == *"daily"* ]] || exit 1 } @test "api-keys-limits: lnNoAmountUsdInvoicePaymentSend respects limits" { - # Create new API key with daily limit - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'ln_noamount_usd_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -621,13 +578,11 @@ setup_file() { cache_value "api-key-ln-noamount-usd-secret" "$secret" - # Set daily limit to 8000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":8000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" - # Create no-amount invoice invoice_response="$(lnd_outside_cli addinvoice)" - payment_request="$(echo $invoice_response | jq -r '.payment_request')" + payment_request="$(echo "$invoice_response" | jq -r '.payment_request')" # Pay 30 cents (USD) to the no-amount invoice from USD wallet variables=$( @@ -642,15 +597,14 @@ setup_file() { send_status="$(graphql_output '.data.lnNoAmountUsdInvoicePaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending was recorded (converted to sats) exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'ln_noamount_usd_key_name')'") | .limits.dailySpentSats')" + # USD amount is converted to sats for tracking [[ "${spent_24h}" -gt "0" ]] || exit 1 } @test "api-keys-limits: lnurlPaymentSend respects limits" { - # Create new API key with daily limit - key_name="$(new_key_name)" + key_name="$(random_uuid)" cache_value 'lnurl_key_name' "$key_name" variables="{\"input\":{\"name\":\"${key_name}\",\"scopes\":[\"READ\",\"WRITE\"]}}" @@ -662,7 +616,6 @@ setup_file() { cache_value "api-key-lnurl-secret" "$secret" - # Set daily limit to 5000 sats variables="{\"input\":{\"id\":\"${key_id}\",\"limitTimeWindow\":\"DAILY\",\"limitSats\":5000}}" exec_graphql 'alice' 'api-key-set-limit' "$variables" @@ -681,7 +634,6 @@ setup_file() { send_status="$(graphql_output '.data.lnurlPaymentSend.status')" [[ "${send_status}" = "SUCCESS" ]] || exit 1 - # Verify spending was recorded exec_graphql 'alice' 'api-keys' spent_24h="$(graphql_output '.data.me.apiKeys[] | select(.name == "'$(read_value 'lnurl_key_name')'") | .limits.dailySpentSats')" [[ "${spent_24h}" -ge "2000" ]] || exit 1 @@ -703,7 +655,9 @@ setup_file() { send_status="$(graphql_output '.data.lnurlPaymentSend.status')" [[ "${send_status}" = "FAILURE" ]] || exit 1 - # Verify error message contains limit information + # Verify error code and message both indicate a daily spending limit restriction + error_code="$(graphql_output '.data.lnurlPaymentSend.errors[0].code')" + [[ "${error_code}" == "TRANSACTION_RESTRICTED" ]] || exit 1 error_msg="$(graphql_output '.data.lnurlPaymentSend.errors[0].message')" [[ "${error_msg}" == *"daily"* ]] || exit 1 } diff --git a/bats/gql/intraledger-payment-send.gql b/bats/gql/intraledger-payment-send.gql index c0e6486715..09ed38d9ca 100644 --- a/bats/gql/intraledger-payment-send.gql +++ b/bats/gql/intraledger-payment-send.gql @@ -2,6 +2,7 @@ mutation intraLedgerPaymentSend($input: IntraLedgerPaymentSendInput!) { intraLedgerPaymentSend(input: $input) { status errors { + code message path } diff --git a/bats/gql/intraledger-usd-payment-send.gql b/bats/gql/intraledger-usd-payment-send.gql index 5e72e9d70a..423d1ef8cd 100644 --- a/bats/gql/intraledger-usd-payment-send.gql +++ b/bats/gql/intraledger-usd-payment-send.gql @@ -2,6 +2,7 @@ mutation intraLedgerUsdPaymentSend($input: IntraLedgerUsdPaymentSendInput!) { intraLedgerUsdPaymentSend(input: $input) { status errors { + code message path } diff --git a/bats/gql/ln-invoice-payment-send.gql b/bats/gql/ln-invoice-payment-send.gql index 8232fb51de..2d08222f30 100644 --- a/bats/gql/ln-invoice-payment-send.gql +++ b/bats/gql/ln-invoice-payment-send.gql @@ -1,6 +1,7 @@ mutation lnInvoicePaymentSend($input: LnInvoicePaymentInput!) { lnInvoicePaymentSend(input: $input) { errors { + code message } status diff --git a/bats/gql/ln-no-amount-invoice-payment-send.gql b/bats/gql/ln-no-amount-invoice-payment-send.gql index 5d647f6b22..4e5abd20e2 100644 --- a/bats/gql/ln-no-amount-invoice-payment-send.gql +++ b/bats/gql/ln-no-amount-invoice-payment-send.gql @@ -1,6 +1,7 @@ mutation lnNoAmountInvoicePaymentSend($input: LnNoAmountInvoicePaymentInput!) { lnNoAmountInvoicePaymentSend(input: $input) { errors { + code message } status diff --git a/bats/gql/ln-no-amount-usd-invoice-payment-send.gql b/bats/gql/ln-no-amount-usd-invoice-payment-send.gql index c75aef1ba8..e01473b89d 100644 --- a/bats/gql/ln-no-amount-usd-invoice-payment-send.gql +++ b/bats/gql/ln-no-amount-usd-invoice-payment-send.gql @@ -3,6 +3,7 @@ mutation lnNoAmountUsdInvoicePaymentSend( ) { lnNoAmountUsdInvoicePaymentSend(input: $input) { errors { + code message } status diff --git a/bats/gql/lnurl-payment-send.gql b/bats/gql/lnurl-payment-send.gql index f92ecbf898..ef3292cf70 100644 --- a/bats/gql/lnurl-payment-send.gql +++ b/bats/gql/lnurl-payment-send.gql @@ -1,6 +1,7 @@ mutation lnurlPaymentSend($input: LnurlPaymentSendInput!) { lnurlPaymentSend(input: $input) { errors { + code message path } diff --git a/bats/gql/on-chain-payment-send.gql b/bats/gql/on-chain-payment-send.gql index 097cb6fa4b..818eccdbe3 100644 --- a/bats/gql/on-chain-payment-send.gql +++ b/bats/gql/on-chain-payment-send.gql @@ -1,6 +1,7 @@ mutation onChainPaymentSend($input: OnChainPaymentSendInput!) { onChainPaymentSend(input: $input) { errors { + code message } status diff --git a/package.json b/package.json index 5709a4ef8d..1d79790f80 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "protobufjs": "7.2.5", "http-cache-semantics": "4.1.1", "elliptic": "^6.6.1", - "form-data": "^4.0.4" + "form-data": "^4.0.4", + "handlebars": "^4.7.9" }, "packageManager": "pnpm@8.7.6" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f1699b698..bc5385de2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,7 @@ overrides: http-cache-semantics: 4.1.1 elliptic: ^6.6.1 form-data: ^4.0.4 + handlebars: ^4.7.9 importers: @@ -2910,7 +2911,7 @@ packages: '@babel/traverse': 7.27.7 '@babel/types': 7.27.7 convert-source-map: 1.9.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -2933,7 +2934,7 @@ packages: '@babel/traverse': 7.27.7 '@babel/types': 7.27.7 convert-source-map: 2.0.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5455,7 +5456,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -5471,7 +5472,7 @@ packages: engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} dependencies: ajv: 6.12.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -7154,7 +7155,7 @@ packages: '@types/js-yaml': 4.0.9 '@whatwg-node/fetch': 0.10.8 chalk: 4.1.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) dotenv: 16.6.1 graphql: 16.11.0 graphql-request: 6.1.0(graphql@16.11.0) @@ -7453,7 +7454,7 @@ packages: deprecated: Use @eslint/config-array instead dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -8328,7 +8329,7 @@ packages: dependencies: '@types/node': 22.16.0 async-exit-hook: 2.0.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) uuid: 11.1.0 transitivePeerDependencies: - supports-color @@ -11997,7 +11998,7 @@ packages: find-up: 5.0.0 fs-extra: 11.3.0 glob: 10.4.5 - handlebars: 4.7.8 + handlebars: 4.7.9 lazy-universal-dotenv: 4.0.0 node-fetch: 2.7.0 picomatch: 2.3.1 @@ -13956,7 +13957,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.2 @@ -13984,7 +13985,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.2 @@ -14100,7 +14101,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.8.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.40.0 typescript: 5.8.3 transitivePeerDependencies: @@ -14120,7 +14121,7 @@ packages: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.6.3 transitivePeerDependencies: @@ -14141,7 +14142,7 @@ packages: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.2.2) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.2.2 transitivePeerDependencies: @@ -14162,7 +14163,7 @@ packages: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.3.3 transitivePeerDependencies: @@ -14183,7 +14184,7 @@ packages: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.6.3 transitivePeerDependencies: @@ -14204,7 +14205,7 @@ packages: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.8.3 transitivePeerDependencies: @@ -14225,7 +14226,7 @@ packages: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.2.2) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.2.2 transitivePeerDependencies: @@ -14246,7 +14247,7 @@ packages: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.6.3 transitivePeerDependencies: @@ -14267,7 +14268,7 @@ packages: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 typescript: 5.8.3 transitivePeerDependencies: @@ -14285,7 +14286,7 @@ packages: '@typescript-eslint/types': 8.35.1 '@typescript-eslint/typescript-estree': 8.35.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.35.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 9.30.1 typescript: 5.8.3 transitivePeerDependencies: @@ -14404,7 +14405,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.3) '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.6.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 tsutils: 3.21.0(typescript@5.6.3) typescript: 5.6.3 @@ -14424,7 +14425,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.2.2) '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.2.2) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 ts-api-utils: 1.4.3(typescript@5.2.2) typescript: 5.2.2 @@ -14444,7 +14445,7 @@ packages: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.8.3) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 ts-api-utils: 1.4.3(typescript@5.8.3) typescript: 5.8.3 @@ -14676,7 +14677,7 @@ packages: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -14698,7 +14699,7 @@ packages: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -14720,7 +14721,7 @@ packages: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -18208,7 +18209,6 @@ packages: dependencies: ms: 2.1.3 supports-color: 5.5.0 - dev: true /debug@4.4.1(supports-color@8.1.1): resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} @@ -18221,6 +18221,7 @@ packages: dependencies: ms: 2.1.3 supports-color: 8.1.1 + dev: true /debug@4.4.3(supports-color@8.1.1): resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -19445,7 +19446,7 @@ packages: optional: true dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.40.0 eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.10.1)(eslint@8.40.0) get-tsconfig: 4.10.1 @@ -19471,7 +19472,7 @@ packages: optional: true dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.0 eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.0) get-tsconfig: 4.10.1 @@ -20184,7 +20185,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -20235,7 +20236,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -20292,7 +20293,7 @@ packages: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -21976,7 +21977,7 @@ packages: hasBin: true dependencies: google-protobuf: 3.15.8 - handlebars: 4.7.7 + handlebars: 4.7.9 dev: true /grunt-cli@1.4.3: @@ -22176,21 +22177,8 @@ packages: duplexer: 0.1.2 dev: true - /handlebars@4.7.7: - resolution: {integrity: sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==} - engines: {node: '>=0.4.7'} - hasBin: true - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.19.3 - dev: true - - /handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + /handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true dependencies: @@ -22216,7 +22204,6 @@ packages: /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} - dev: true /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -22791,7 +22778,7 @@ packages: dependencies: '@ioredis/commands': 1.2.0 cluster-key-slot: 1.1.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -24238,7 +24225,7 @@ packages: dependencies: '@types/express': 4.17.23 '@types/jsonwebtoken': 9.0.10 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) jose: 4.15.9 limiter: 1.1.5 lru-memoizer: 2.3.0 @@ -24808,7 +24795,7 @@ packages: chalk: 4.1.2 commander: 7.2.0 commondir: 1.0.1 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1(supports-color@5.5.0) dependency-tree: 9.0.0 detective-amd: 4.2.0 detective-cjs: 4.1.0 @@ -29045,7 +29032,7 @@ packages: grunt-contrib-uglify: 5.2.2 grunt-contrib-watch: 1.1.0 grunt-sass: 3.1.0(grunt@1.5.3) - handlebars: 4.7.8 + handlebars: 4.7.9 highlight.js: 11.11.1 htmlparser2: 9.0.0 js-beautify: 1.14.11 @@ -29539,7 +29526,6 @@ packages: engines: {node: '>=4'} dependencies: has-flag: 3.0.0 - dev: true /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} From 12ea22cf6254429d1cfbe169f1aa4f9924d7fd7c Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Wed, 8 Apr 2026 13:49:21 -0500 Subject: [PATCH 22/33] fix(api-keys): guard against NULL transaction_id in record_spending --- core/api-keys/src/limits/mod.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index 255e7e5d8a..fc9819456a 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -230,6 +230,8 @@ impl Limits { } } None => { + let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; + sqlx::query!( r#" INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at) @@ -238,7 +240,7 @@ impl Limits { "#, api_key_id as IdentityApiKeyId, amount_sats, - transaction_id, + txn_id, ) .execute(&self.pool) .await?; @@ -689,4 +691,13 @@ mod tests { .await; assert!(matches!(result, Err(LimitError::MissingTransactionId))); } + + #[tokio::test] + async fn record_spending_without_ephemeral_id_requires_transaction_id() { + let limits = test_limits(); + let result = limits + .record_spending(test_api_key_id(), 1000, None, None) + .await; + assert!(matches!(result, Err(LimitError::MissingTransactionId))); + } } From 95f1a6533a36d87a83cd7ac2d3c86e0dca20bca3 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Wed, 8 Apr 2026 21:26:27 -0500 Subject: [PATCH 23/33] fix(api-keys): harden spending finalization and consistency checks Validate amount consistency during ephemeral spending finalization and strengthen idempotent retry handling for transaction conflicts. Align API integration scripts/config and error mapping so failures surface predictably across services. --- ...8dbb6735ade28d49d26ee0f4163db2cd5988f.json | 29 +++++++ ...421be0dd5b0c10a3e34f7cc28ac315d327368.json | 16 ++++ ...dc3b109364bfa6f0b54a00aa3cce03a488540.json | 23 ++++++ ...53f9cf2e2299c9a4dd5a9e111b2cb1400d283.json | 23 ------ ...14d9958cb372c32088c0bbf7dbc9b4c6a897b.json | 16 ---- core/api-keys/src/limits/error.rs | 3 + core/api-keys/src/limits/mod.rs | 82 +++++++++++++++---- core/api/package.json | 5 +- core/api/src/app/payments/send-lightning.ts | 2 - core/api/src/app/payments/spending-limits.ts | 13 +-- core/api/src/servers/middlewares/session.ts | 5 +- core/api/src/services/api-keys/errors.ts | 4 + .../src/services/api-keys/proto/buf.gen.yaml | 5 +- .../unit/app/payments/spending-limits.spec.ts | 6 +- 14 files changed, 156 insertions(+), 76 deletions(-) create mode 100644 core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json create mode 100644 core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json create mode 100644 core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json delete mode 100644 core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json delete mode 100644 core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json diff --git a/core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json b/core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json new file mode 100644 index 0000000000..f34e11495b --- /dev/null +++ b/core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT amount_sats, transaction_id\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount_sats", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "transaction_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f" +} diff --git a/core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json b/core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json new file mode 100644 index 0000000000..f4a50e4e75 --- /dev/null +++ b/core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_key_transactions\n SET transaction_id = $1\n WHERE transaction_id = $2\n AND api_key_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368" +} diff --git a/core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json b/core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json new file mode 100644 index 0000000000..e043262754 --- /dev/null +++ b/core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT amount_sats\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount_sats", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540" +} diff --git a/core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json b/core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json deleted file mode 100644 index 3217e80dd9..0000000000 --- a/core/api-keys/.sqlx/query-87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT 1 as \"exists!\"\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists!", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "87c9634aa38f95eb1f850b1901153f9cf2e2299c9a4dd5a9e111b2cb1400d283" -} diff --git a/core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json b/core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json deleted file mode 100644 index 399e702bd4..0000000000 --- a/core/api-keys/.sqlx/query-9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE api_key_transactions\n SET transaction_id = $1\n WHERE transaction_id = $2\n AND api_key_id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "9c69eb1f61d4e4832bbe570fc3214d9958cb372c32088c0bbf7dbc9b4c6a897b" -} diff --git a/core/api-keys/src/limits/error.rs b/core/api-keys/src/limits/error.rs index 027b0be721..75e8461dc5 100644 --- a/core/api-keys/src/limits/error.rs +++ b/core/api-keys/src/limits/error.rs @@ -11,6 +11,9 @@ pub enum LimitError { #[error("Missing transaction id for ephemeral finalization")] MissingTransactionId, + #[error("Spending amount mismatch for transaction reference")] + AmountMismatch, + #[error("{0} spending limit exceeded")] LimitExceeded(String), diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index fc9819456a..abc0fc6bfb 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -195,39 +195,87 @@ impl Limits { Some(eid) => { let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; - let result = sqlx::query!( + let mut tx = self.pool.begin().await?; + + let duplicate_txn = sqlx::query!( r#" - UPDATE api_key_transactions - SET transaction_id = $1 - WHERE transaction_id = $2 - AND api_key_id = $3 + SELECT amount_sats + FROM api_key_transactions + WHERE transaction_id = $1 + AND api_key_id = $2 + FOR UPDATE "#, &txn_id, + api_key_id as IdentityApiKeyId, + ) + .fetch_optional(&mut *tx) + .await?; + + if let Some(duplicate_txn) = duplicate_txn { + if duplicate_txn.amount_sats != amount_sats { + return Err(LimitError::AmountMismatch); + } + } + + let ephemeral_txn = sqlx::query!( + r#" + SELECT amount_sats, transaction_id + FROM api_key_transactions + WHERE transaction_id = $1 + AND api_key_id = $2 + FOR UPDATE + "#, &eid, api_key_id as IdentityApiKeyId, ) - .execute(&self.pool) + .fetch_optional(&mut *tx) .await?; - if result.rows_affected() == 0 { - // Check if already finalized (idempotent retry) - let already_recorded = sqlx::query_scalar!( + if let Some(ephemeral_txn) = ephemeral_txn { + if ephemeral_txn.amount_sats != amount_sats { + return Err(LimitError::AmountMismatch); + } + + sqlx::query!( r#" - SELECT 1 as "exists!" - FROM api_key_transactions - WHERE transaction_id = $1 - AND api_key_id = $2 + UPDATE api_key_transactions + SET transaction_id = $1 + WHERE transaction_id = $2 + AND api_key_id = $3 "#, &txn_id, + &eid, api_key_id as IdentityApiKeyId, ) - .fetch_optional(&self.pool) + .execute(&mut *tx) .await?; - if already_recorded.is_none() { - return Err(LimitError::EphemeralNotFound(eid)); - } + tx.commit().await?; + return Ok(()); + } + + // Idempotent retry: ephemeral row already finalized, check canonical txn row + let finalized_txn = sqlx::query!( + r#" + SELECT amount_sats + FROM api_key_transactions + WHERE transaction_id = $1 + AND api_key_id = $2 + FOR UPDATE + "#, + &txn_id, + api_key_id as IdentityApiKeyId, + ) + .fetch_optional(&mut *tx) + .await?; + + let finalized_txn = finalized_txn.ok_or(LimitError::EphemeralNotFound(eid))?; + + if finalized_txn.amount_sats != amount_sats { + return Err(LimitError::AmountMismatch); } + + tx.commit().await?; } None => { let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; diff --git a/core/api/package.json b/core/api/package.json index 48f5303806..5d074e1598 100644 --- a/core/api/package.json +++ b/core/api/package.json @@ -5,7 +5,7 @@ "eslint-check": "eslint src test --ext .ts", "eslint-fix": "eslint src test --ext .ts --fix", "circular-deps-check": "madge --circular --extensions ts src", - "build": "tsc -p tsconfig-build.json && cp -R src/services/price/protos dist/services/price/ && cp -R src/services/dealer-price/proto dist/services/dealer-price/ && cp -R src/services/bria/proto dist/services/bria/ && cp -R src/services/notifications/proto dist/services/notifications/ && tscpaths --silent -p tsconfig.json -s ./src -o ./dist", + "build": "tsc -p tsconfig-build.json && cp -R src/services/price/protos dist/services/price/ && cp -R src/services/dealer-price/proto dist/services/dealer-price/ && cp -R src/services/bria/proto dist/services/bria/ && cp -R src/services/notifications/proto dist/services/notifications/ && cp -R src/services/api-keys/proto dist/services/api-keys/ && tscpaths --silent -p tsconfig.json -s ./src -o ./dist", "trigger": "pnpm run build && node dist/servers/trigger.js | pino-pretty -c -l", "ws": "pnpm run build && node dist/servers/ws-server.js | pino-pretty -c -l", "watch": "nodemon -V -e ts,graphql -w ./src -x pnpm run start", @@ -32,7 +32,8 @@ "migrate:up": "migrate-mongo up -f src/migrations/migrate-mongo-config.js", "migrate:down": "migrate-mongo down -f src/migrations/migrate-mongo-config.js", "mongodb-migrate": "pnpm run migrate:status && pnpm run migrate:up && pnpm run migrate:status", - "codegen:notifications": "cd ./src/services/notifications/proto && buf generate" + "codegen:notifications": "cd ./src/services/notifications/proto && buf generate", + "codegen:api-keys": "cd ./src/services/api-keys/proto && buf generate" }, "engines": { "node": "20" diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index dcf70d8d06..aec4c1895d 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -823,7 +823,6 @@ const executePaymentViaLn = async ({ case PaymentSendAttemptResultType.ErrorWithJournal: return { result: paymentSendAttemptResult.error, - settlementTransactionId: paymentSendAttemptResult.journalId, } case PaymentSendAttemptResultType.Pending: { @@ -844,7 +843,6 @@ const executePaymentViaLn = async ({ walletId: senderWalletId, paymentHash, }) - if (result instanceof Error) return { result } return { result } } diff --git a/core/api/src/app/payments/spending-limits.ts b/core/api/src/app/payments/spending-limits.ts index 274476444f..c30a619a2e 100644 --- a/core/api/src/app/payments/spending-limits.ts +++ b/core/api/src/app/payments/spending-limits.ts @@ -15,9 +15,8 @@ import { PaymentSendStatus } from "@/domain/bitcoin/lightning" import { SettlementMethod } from "@/domain/wallets" import { recordExceptionInCurrentSpan } from "@/services/tracing" -// Records spending when a journal entry exists (settlementTransactionId is present), -// reverses the lock otherwise. AlreadyPaid and Failure are reversed even with a journal -// since no net new spending occurred. +// Records spending only for successful/pending outcomes when a settlement transaction exists. +// Reverses lock otherwise. const settlementFor = ({ result, settlementTransactionId, @@ -25,13 +24,9 @@ const settlementFor = ({ result: PaymentSendResult | ApplicationError settlementTransactionId?: LedgerJournalId }): ApiKeySpendingSettlement => { - if (!settlementTransactionId) return reverseApiKeySpendingSettlement() - - if (result instanceof Error) { - return recordApiKeySpendingSettlement(settlementTransactionId) - } - if ( + !settlementTransactionId || + result instanceof Error || result.status === PaymentSendStatus.AlreadyPaid || result.status === PaymentSendStatus.Failure ) { diff --git a/core/api/src/servers/middlewares/session.ts b/core/api/src/servers/middlewares/session.ts index ed3b4c7df4..7320341a2e 100644 --- a/core/api/src/servers/middlewares/session.ts +++ b/core/api/src/servers/middlewares/session.ts @@ -30,7 +30,10 @@ export const sessionPublicContext = async ({ ) const sub = tokenPayload?.sub const appId = tokenPayload?.client_id - const apiKeyId = tokenPayload?.api_key_id as ApiKeyId | undefined + let apiKeyId: ApiKeyId | undefined + if (typeof tokenPayload?.api_key_id === "string") { + apiKeyId = tokenPayload.api_key_id as ApiKeyId + } // note: value should match (ie: "anon") if not an accountId // settings from dev/ory/oathkeeper.yml/authenticator/anonymous/config/subjet diff --git a/core/api/src/services/api-keys/errors.ts b/core/api/src/services/api-keys/errors.ts index 109db199e5..d586803408 100644 --- a/core/api/src/services/api-keys/errors.ts +++ b/core/api/src/services/api-keys/errors.ts @@ -22,6 +22,9 @@ export const handleCommonApiKeysErrors = (err: Error | string | unknown) => { case match(KnownApiKeysErrorMessages.InvalidAmountError): return new ApiKeySpendingRecordError(errMsg) + case match(KnownApiKeysErrorMessages.AmountMismatchError): + return new ApiKeySpendingRecordError(errMsg) + case match(KnownApiKeysErrorMessages.InvalidLimitError): return new ApiKeyInvalidLimitError(errMsg) @@ -35,6 +38,7 @@ export const KnownApiKeysErrorMessages = { InvalidApiKeyId: /Invalid API key ID/, InvalidAmountError: /Negative amount not allowed|Amount must be positive|Invalid limit amount \(must be positive\)/, + AmountMismatchError: /Spending amount mismatch for transaction reference/, InvalidLimitError: /Invalid limit value/, DatabaseError: /Database/, } as const diff --git a/core/api/src/services/api-keys/proto/buf.gen.yaml b/core/api/src/services/api-keys/proto/buf.gen.yaml index addcc3c669..4afda94f7f 100644 --- a/core/api/src/services/api-keys/proto/buf.gen.yaml +++ b/core/api/src/services/api-keys/proto/buf.gen.yaml @@ -5,12 +5,11 @@ plugins: - name: js out: . opt: import_style=commonjs,binary - path: ../../../../node_modules/.bin/protoc-gen-js - name: grpc out: . opt: grpc_js - path: ../../../../node_modules/.bin/grpc_tools_node_protoc_plugin + path: grpc_tools_node_protoc_plugin - name: ts out: . opt: grpc_js - path: ../../../../node_modules/.bin/protoc-gen-ts + path: protoc-gen-ts diff --git a/core/api/test/unit/app/payments/spending-limits.spec.ts b/core/api/test/unit/app/payments/spending-limits.spec.ts index e909d85364..f09cc33d54 100644 --- a/core/api/test/unit/app/payments/spending-limits.spec.ts +++ b/core/api/test/unit/app/payments/spending-limits.spec.ts @@ -196,7 +196,7 @@ describe("withSpendingLimits", () => { expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() }) - it("records settlement when execution fails with settlement transaction id", async () => { + it("reverses settlement when execution fails with settlement transaction id", async () => { const executionError = new ApiKeyLimitExceededError() const result = await withSpendingLimits({ @@ -213,8 +213,8 @@ describe("withSpendingLimits", () => { }) expect(result).toBe(executionError) - expect(mockApiKeys.recordSpending).toHaveBeenCalled() - expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() + expect(mockApiKeys.reverseSpending).toHaveBeenCalled() + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() }) it("records exception when settlement fails", async () => { From 60e8834ff39d1e37af9e632637981fe02c2549ed Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 10:12:52 -0500 Subject: [PATCH 24/33] fix(core-api): map api-keys spending finalize errors Handle missing transaction id and missing ephemeral reservation as spending record errors, and collapse equivalent mappings into grouped switch cases for clarity. --- core/api/src/services/api-keys/errors.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/core/api/src/services/api-keys/errors.ts b/core/api/src/services/api-keys/errors.ts index d586803408..f453907bf5 100644 --- a/core/api/src/services/api-keys/errors.ts +++ b/core/api/src/services/api-keys/errors.ts @@ -20,9 +20,9 @@ export const handleCommonApiKeysErrors = (err: Error | string | unknown) => { return new InvalidApiKeyIdError(errMsg) case match(KnownApiKeysErrorMessages.InvalidAmountError): - return new ApiKeySpendingRecordError(errMsg) - case match(KnownApiKeysErrorMessages.AmountMismatchError): + case match(KnownApiKeysErrorMessages.MissingTransactionIdError): + case match(KnownApiKeysErrorMessages.EphemeralNotFoundError): return new ApiKeySpendingRecordError(errMsg) case match(KnownApiKeysErrorMessages.InvalidLimitError): @@ -39,6 +39,7 @@ export const KnownApiKeysErrorMessages = { InvalidAmountError: /Negative amount not allowed|Amount must be positive|Invalid limit amount \(must be positive\)/, AmountMismatchError: /Spending amount mismatch for transaction reference/, + MissingTransactionIdError: /Missing transaction id for ephemeral finalization/, + EphemeralNotFoundError: /Ephemeral reservation not found:/, InvalidLimitError: /Invalid limit value/, - DatabaseError: /Database/, } as const From aeacbe895c7549d1f67e930fd1300e451de069d1 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 12:04:33 -0500 Subject: [PATCH 25/33] fix(core-api): explicit spending settlement intent --- core/api/src/app/payments/index.types.d.ts | 21 ++- core/api/src/app/payments/send-intraledger.ts | 22 ++- core/api/src/app/payments/send-lightning.ts | 79 ++++++---- core/api/src/app/payments/send-on-chain.ts | 33 ++-- core/api/src/app/payments/spending-limits.ts | 65 ++++++-- .../unit/app/payments/spending-limits.spec.ts | 147 +++++++++++------- 6 files changed, 247 insertions(+), 120 deletions(-) diff --git a/core/api/src/app/payments/index.types.d.ts b/core/api/src/app/payments/index.types.d.ts index 71539f1539..e2ea9b9137 100644 --- a/core/api/src/app/payments/index.types.d.ts +++ b/core/api/src/app/payments/index.types.d.ts @@ -20,10 +20,23 @@ type ApiKeySpendingSettlement = type: "reverse" } -type SpendingLimitsExecutionResult = { - result: PaymentSendResult | ApplicationError - settlementTransactionId?: LedgerJournalId -} +// NOTE: api-key settlement behavior must be explicit and must not be inferred +// from result shape/status (e.g. result instanceof Error). +type SpendingLimitsSettlementObj = + typeof import("./spending-limits").SpendingLimitsSettlement +type SpendingLimitsSettlement = + SpendingLimitsSettlementObj[keyof SpendingLimitsSettlementObj] + +type SpendingLimitsExecutionResult = + | { + apiKeySettlement: SpendingLimitsSettlementObj["Record"] + settlementTransactionId: LedgerJournalId + result: PaymentSendResult | ApplicationError + } + | { + apiKeySettlement: SpendingLimitsSettlementObj["Reverse"] + result: PaymentSendResult | ApplicationError + } type PaymentSendAttemptResultTypeObj = typeof import("./ln-send-result").PaymentSendAttemptResultType diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 10c2df2720..d4736b1dbc 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -1,5 +1,9 @@ import { getPriceRatioForLimits } from "./helpers" -import { withSpendingLimits } from "./spending-limits" +import { + recordSettlement, + reverseSettlement, + withSpendingLimits, +} from "./spending-limits" import { getValuesToSkipProbe } from "@/config" @@ -297,7 +301,7 @@ const executePaymentViaIntraledger = async < }), ) if (journalId instanceof Error) { - return { result: journalId } + return reverseSettlement({ result: journalId }) } const recipientWalletTransaction = await getTransactionForWalletByJournalId({ @@ -305,7 +309,10 @@ const executePaymentViaIntraledger = async < journalId, }) if (recipientWalletTransaction instanceof Error) { - return { result: recipientWalletTransaction } + return recordSettlement({ + result: recipientWalletTransaction, + settlementTransactionId: journalId, + }) } NotificationsService().sendTransaction({ @@ -318,7 +325,10 @@ const executePaymentViaIntraledger = async < journalId, }) if (senderWalletTransaction instanceof Error) { - return { result: senderWalletTransaction } + return recordSettlement({ + result: senderWalletTransaction, + settlementTransactionId: journalId, + }) } NotificationsService().sendTransaction({ @@ -326,13 +336,13 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - return { + return recordSettlement({ result: { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, }, settlementTransactionId: journalId, - } + }) }, }) return paymentSendResult diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index aec4c1895d..0d69ff531c 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -5,7 +5,11 @@ import { LnSendAttemptResult, PaymentSendAttemptResultType, } from "./ln-send-result" -import { withSpendingLimits } from "./spending-limits" +import { + recordSettlement, + reverseSettlement, + withSpendingLimits, +} from "./spending-limits" import { reimburseFee } from "./reimburse-fee" import { AccountValidator } from "@/domain/accounts" @@ -514,21 +518,19 @@ const executePaymentViaIntraledger = async < }), ) if (paymentSendAttemptResult instanceof Error) { - return { result: paymentSendAttemptResult } + return reverseSettlement({ result: paymentSendAttemptResult }) } switch (paymentSendAttemptResult.type) { case PaymentSendAttemptResultType.Error: - return { result: paymentSendAttemptResult.error } + return reverseSettlement({ result: paymentSendAttemptResult.error }) case PaymentSendAttemptResultType.AlreadyPaid: { const result = await getAlreadyPaidResponse({ walletId: senderWalletId, paymentHash, }) - if (result instanceof Error) return { result } - - return { result } + return reverseSettlement({ result }) } } @@ -539,7 +541,10 @@ const executePaymentViaIntraledger = async < journalId, }) if (recipientWalletTransaction instanceof Error) { - return { result: recipientWalletTransaction } + return recordSettlement({ + result: recipientWalletTransaction, + settlementTransactionId: journalId, + }) } NotificationsService().sendTransaction({ recipient: recipientAsNotificationRecipient, @@ -551,20 +556,23 @@ const executePaymentViaIntraledger = async < journalId, }) if (senderWalletTransaction instanceof Error) { - return { result: senderWalletTransaction } + return recordSettlement({ + result: senderWalletTransaction, + settlementTransactionId: journalId, + }) } NotificationsService().sendTransaction({ recipient: senderAsNotificationRecipient, transaction: senderWalletTransaction, }) - return { + return recordSettlement({ result: { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, }, settlementTransactionId: journalId, - } + }) }, }) return paymentSendResult @@ -802,17 +810,36 @@ const executePaymentViaLn = async ({ }), ) if (paymentSendAttemptResult instanceof Error) { - return { result: paymentSendAttemptResult } + return reverseSettlement({ result: paymentSendAttemptResult }) } if (paymentSendAttemptResult.type === PaymentSendAttemptResultType.Error) { - return { result: paymentSendAttemptResult.error } + return reverseSettlement({ result: paymentSendAttemptResult.error }) + } + + if (paymentSendAttemptResult.type === PaymentSendAttemptResultType.AlreadyPaid) { + const { paymentHash } = decodedInvoice + const result = await getAlreadyPaidResponse({ + walletId: senderWalletId, + paymentHash, + }) + return reverseSettlement({ result }) } const walletTransaction = await getTransactionForWalletByJournalId({ walletId: senderWalletId, journalId: paymentSendAttemptResult.journalId, }) - if (walletTransaction instanceof Error) return { result: walletTransaction } + if (walletTransaction instanceof Error) { + if ( + paymentSendAttemptResult.type === PaymentSendAttemptResultType.ErrorWithJournal + ) { + return reverseSettlement({ result: walletTransaction }) + } + return recordSettlement({ + result: walletTransaction, + settlementTransactionId: paymentSendAttemptResult.journalId, + }) + } NotificationsService().sendTransaction({ recipient: notificationRecipient, transaction: walletTransaction, @@ -821,40 +848,34 @@ const executePaymentViaLn = async ({ const { paymentHash } = decodedInvoice switch (paymentSendAttemptResult.type) { case PaymentSendAttemptResultType.ErrorWithJournal: - return { - result: paymentSendAttemptResult.error, - } + return reverseSettlement({ result: paymentSendAttemptResult.error }) case PaymentSendAttemptResultType.Pending: { const result = await getPendingPaymentResponse({ walletId: senderWalletId, paymentHash, }) - if (result instanceof Error) return { result } + if (result instanceof Error) { + return recordSettlement({ + result, + settlementTransactionId: paymentSendAttemptResult.journalId, + }) + } - return { + return recordSettlement({ result, settlementTransactionId: paymentSendAttemptResult.journalId, - } - } - - case PaymentSendAttemptResultType.AlreadyPaid: { - const result = await getAlreadyPaidResponse({ - walletId: senderWalletId, - paymentHash, }) - - return { result } } default: - return { + return recordSettlement({ result: { status: PaymentSendStatus.Success, transaction: walletTransaction, }, settlementTransactionId: paymentSendAttemptResult.journalId, - } + }) } }, }) diff --git a/core/api/src/app/payments/send-on-chain.ts b/core/api/src/app/payments/send-on-chain.ts index 118362bb29..9bd6c35e45 100644 --- a/core/api/src/app/payments/send-on-chain.ts +++ b/core/api/src/app/payments/send-on-chain.ts @@ -1,5 +1,9 @@ import { getPriceRatioForLimits } from "./helpers" -import { withSpendingLimits } from "./spending-limits" +import { + recordSettlement, + reverseSettlement, + withSpendingLimits, +} from "./spending-limits" import { NETWORK, getOnChainWalletConfig } from "@/config" import { @@ -313,7 +317,7 @@ const executePaymentViaIntraledger = async < }), ) if (journalId instanceof Error) { - return { result: journalId } + return reverseSettlement({ result: journalId }) } const recipientAsNotificationRecipient = { @@ -330,7 +334,10 @@ const executePaymentViaIntraledger = async < journalId, }) if (recipientWalletTransaction instanceof Error) { - return { result: recipientWalletTransaction } + return recordSettlement({ + result: recipientWalletTransaction, + settlementTransactionId: journalId, + }) } // Send 'received'-side intraledger notification @@ -353,7 +360,10 @@ const executePaymentViaIntraledger = async < journalId, }) if (senderWalletTransaction instanceof Error) { - return { result: senderWalletTransaction } + return recordSettlement({ + result: senderWalletTransaction, + settlementTransactionId: journalId, + }) } NotificationsService().sendTransaction({ @@ -361,13 +371,13 @@ const executePaymentViaIntraledger = async < transaction: senderWalletTransaction, }) - return { + return recordSettlement({ result: { status: PaymentSendStatus.Success, transaction: senderWalletTransaction, }, settlementTransactionId: journalId, - } + }) }, }) return paymentSendResult @@ -578,7 +588,7 @@ const executePaymentViaOnChain = async < }), ) if (journalId instanceof Error) { - return { result: journalId } + return reverseSettlement({ result: journalId }) } const walletTransaction = await getTransactionForWalletByJournalId({ @@ -586,13 +596,16 @@ const executePaymentViaOnChain = async < journalId, }) if (walletTransaction instanceof Error) { - return { result: walletTransaction } + return recordSettlement({ + result: walletTransaction, + settlementTransactionId: journalId, + }) } - return { + return recordSettlement({ result: { status: PaymentSendStatus.Success, transaction: walletTransaction }, settlementTransactionId: journalId, - } + }) }, }) return paymentSendResult diff --git a/core/api/src/app/payments/spending-limits.ts b/core/api/src/app/payments/spending-limits.ts index c30a619a2e..dda19b0ba1 100644 --- a/core/api/src/app/payments/spending-limits.ts +++ b/core/api/src/app/payments/spending-limits.ts @@ -11,29 +11,66 @@ import { checkWithdrawalLimits, } from "@/app/accounts" -import { PaymentSendStatus } from "@/domain/bitcoin/lightning" +import { ErrorLevel } from "@/domain/shared" import { SettlementMethod } from "@/domain/wallets" import { recordExceptionInCurrentSpan } from "@/services/tracing" -// Records spending only for successful/pending outcomes when a settlement transaction exists. -// Reverses lock otherwise. -const settlementFor = ({ +export const SpendingLimitsSettlement = { + Record: "record", + Reverse: "reverse", +} as const + +export const recordSettlement = ({ result, settlementTransactionId, }: { result: PaymentSendResult | ApplicationError - settlementTransactionId?: LedgerJournalId -}): ApiKeySpendingSettlement => { + settlementTransactionId: LedgerJournalId +}): SpendingLimitsExecutionResult => ({ + apiKeySettlement: SpendingLimitsSettlement.Record, + settlementTransactionId, + result, +}) + +export const reverseSettlement = ({ + result, +}: { + result: PaymentSendResult | ApplicationError +}): SpendingLimitsExecutionResult => ({ + apiKeySettlement: SpendingLimitsSettlement.Reverse, + result, +}) + +const settlementFor = ( + executionResult: SpendingLimitsExecutionResult, +): ApiKeySpendingSettlement => { + const { apiKeySettlement } = executionResult + if ( - !settlementTransactionId || - result instanceof Error || - result.status === PaymentSendStatus.AlreadyPaid || - result.status === PaymentSendStatus.Failure + apiKeySettlement === SpendingLimitsSettlement.Record && + !executionResult.settlementTransactionId ) { + recordExceptionInCurrentSpan({ + error: new Error( + "Invalid spending settlement result: record settlement without transaction id", + ), + level: ErrorLevel.Critical, + }) return reverseApiKeySpendingSettlement() } - return recordApiKeySpendingSettlement(settlementTransactionId) + switch (apiKeySettlement) { + case SpendingLimitsSettlement.Record: + return recordApiKeySpendingSettlement(executionResult.settlementTransactionId) + + case SpendingLimitsSettlement.Reverse: + return reverseApiKeySpendingSettlement() + + default: { + const exhaustiveCheck: never = apiKeySettlement + return exhaustiveCheck + } + } } const getLimitCheck = ({ @@ -81,15 +118,15 @@ export const withSpendingLimits = async ({ const lock = await lockApiKeySpending({ apiKeyId, amount: btcPaymentAmount }) if (lock instanceof Error) return lock - const { result, settlementTransactionId } = await execute() + const executionResult = await execute() const settleResult = await settleApiKeySpending({ lock, - settlement: settlementFor({ result, settlementTransactionId }), + settlement: settlementFor(executionResult), }) if (settleResult instanceof Error) { recordExceptionInCurrentSpan({ error: settleResult }) } - return result + return executionResult.result } diff --git a/core/api/test/unit/app/payments/spending-limits.spec.ts b/core/api/test/unit/app/payments/spending-limits.spec.ts index f09cc33d54..4922718638 100644 --- a/core/api/test/unit/app/payments/spending-limits.spec.ts +++ b/core/api/test/unit/app/payments/spending-limits.spec.ts @@ -23,9 +23,16 @@ jest.mock("@/app/accounts", () => ({ checkWithdrawalLimits: jest.fn(), })) -import { withSpendingLimits } from "@/app/payments/spending-limits" +import { + recordSettlement, + reverseSettlement, + SpendingLimitsSettlement, + withSpendingLimits, +} from "@/app/payments/spending-limits" import { PaymentSendStatus } from "@/domain/bitcoin/lightning" import { ApiKeyLimitExceededError } from "@/domain/api-keys/errors" +import { CouldNotFindError } from "@/domain/errors" +import { ErrorLevel } from "@/domain/shared" import { SettlementMethod } from "@/domain/wallets" import { recordExceptionInCurrentSpan } from "@/services/tracing" import { @@ -87,10 +94,11 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendSuccessResult, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), }) expect(result).toBe(accountLimitError) @@ -105,10 +113,11 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendSuccessResult, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), }) expect(result).toEqual(paymentSendSuccessResult) @@ -120,7 +129,7 @@ describe("withSpendingLimits", () => { expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() }) - it("reverses settlement when success result is already paid", async () => { + it("reverses settlement when execution returns reverse intent for already-paid result", async () => { const result = await withSpendingLimits({ settlementMethod: SettlementMethod.Lightning, accountId: "sender-account-id" as AccountId, @@ -128,10 +137,7 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendAlreadyPaidResult, - settlementTransactionId: journalId, - }), + execute: async () => reverseSettlement({ result: paymentSendAlreadyPaidResult }), }) expect(result).toEqual(paymentSendAlreadyPaidResult) @@ -139,7 +145,7 @@ describe("withSpendingLimits", () => { expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() }) - it("reverses settlement when already-paid result has no settlement transaction id", async () => { + it("reverses settlement when execution returns reverse intent for failure result", async () => { const result = await withSpendingLimits({ settlementMethod: SettlementMethod.Lightning, accountId: "sender-account-id" as AccountId, @@ -147,17 +153,17 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendAlreadyPaidResult, - }), + execute: async () => reverseSettlement({ result: paymentSendFailureResult }), }) - expect(result).toEqual(paymentSendAlreadyPaidResult) + expect(result).toEqual(paymentSendFailureResult) expect(mockApiKeys.reverseSpending).toHaveBeenCalled() expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() }) - it("reverses settlement when failure result has settlement transaction id", async () => { + it("reverses settlement when execution error is returned with reverse intent", async () => { + const executionError = new ApiKeyLimitExceededError() + const result = await withSpendingLimits({ settlementMethod: SettlementMethod.Lightning, accountId: "sender-account-id" as AccountId, @@ -165,19 +171,16 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendFailureResult, - settlementTransactionId: journalId, - }), + execute: async () => reverseSettlement({ result: executionError }), }) - expect(result).toEqual(paymentSendFailureResult) + expect(result).toBe(executionError) expect(mockApiKeys.reverseSpending).toHaveBeenCalled() expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() }) - it("reverses settlement when execution fails without settlement transaction id", async () => { - const executionError = new ApiKeyLimitExceededError() + it("records settlement for lightning-style post-journal lookup errors", async () => { + const executionError = new CouldNotFindError("wallet transaction") const result = await withSpendingLimits({ settlementMethod: SettlementMethod.Lightning, @@ -186,35 +189,38 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: executionError, - }), + execute: async () => + recordSettlement({ + result: executionError, + settlementTransactionId: journalId, + }), }) expect(result).toBe(executionError) - expect(mockApiKeys.reverseSpending).toHaveBeenCalled() - expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + expect(mockApiKeys.recordSpending).toHaveBeenCalled() + expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() }) - it("reverses settlement when execution fails with settlement transaction id", async () => { - const executionError = new ApiKeyLimitExceededError() + it("records settlement for on-chain-style post-journal lookup errors", async () => { + const executionError = new CouldNotFindError("wallet transaction") const result = await withSpendingLimits({ - settlementMethod: SettlementMethod.Lightning, + settlementMethod: SettlementMethod.OnChain, accountId: "sender-account-id" as AccountId, usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: executionError, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: executionError, + settlementTransactionId: journalId, + }), }) expect(result).toBe(executionError) - expect(mockApiKeys.reverseSpending).toHaveBeenCalled() - expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + expect(mockApiKeys.recordSpending).toHaveBeenCalled() + expect(mockApiKeys.reverseSpending).not.toHaveBeenCalled() }) it("records exception when settlement fails", async () => { @@ -228,10 +234,11 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendSuccessResult, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), }) expect(result).toEqual(paymentSendSuccessResult) @@ -249,10 +256,11 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendSuccessResult, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), }) expect(result).toEqual(paymentSendSuccessResult) @@ -270,10 +278,11 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendSuccessResult, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), }) expect(result).toEqual(paymentSendSuccessResult) @@ -289,13 +298,37 @@ describe("withSpendingLimits", () => { priceRatioForLimits: {} as WalletPriceRatio, apiKeyId, btcPaymentAmount, - execute: async () => ({ - result: paymentSendSuccessResult, - settlementTransactionId: journalId, - }), + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), }) expect(result).toEqual(paymentSendSuccessResult) expect(mockCheckWithdrawalLimits).toHaveBeenCalledTimes(1) }) + + it("reverses and records error-level trace on invalid record settlement payload", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.Lightning, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + execute: async () => + ({ + apiKeySettlement: SpendingLimitsSettlement.Record, + result: paymentSendSuccessResult, + }) as SpendingLimitsExecutionResult, + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockApiKeys.reverseSpending).toHaveBeenCalled() + expect(mockApiKeys.recordSpending).not.toHaveBeenCalled() + expect(mockRecordExceptionInCurrentSpan).toHaveBeenCalledWith( + expect.objectContaining({ level: ErrorLevel.Critical }), + ) + }) }) From 3f4b3c151bce65a31984ef3902a133d67c710933 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 12:58:51 -0500 Subject: [PATCH 26/33] fix(api-keys): make ephemeral finalization conflict-safe and improve error mapping - Refactor record_spending into finalize_ephemeral and record_canonical with exhaustive match on (ephemeral, canonical) row states - Fix UNIQUE constraint violation when canonical txn_id row already exists during ephemeral finalization by deleting the orphaned ephemeral row - Map AmountMismatch and LimitExceeded to gRPC failed_precondition instead of internal error in record_spending handler - Extract reusable helpers: fetch_transaction, delete_transaction, rename_transaction, ensure_amount_matches - Add unit tests for ensure_amount_matches --- ...8dbb6735ade28d49d26ee0f4163db2cd5988f.json | 29 -- ...421be0dd5b0c10a3e34f7cc28ac315d327368.json | 16 -- ...dc3b109364bfa6f0b54a00aa3cce03a488540.json | 23 -- ...efd4c656219fd7545909263600c3bfdae06bf.json | 23 ++ ...84bebe11d59fd343c083b745f9ce4ee09ed0d.json | 16 -- ...0915ad3673025f5bacd07b056a66efcb85b06.json | 16 ++ ...bd80036f5c786d0234d451c485f1e12e5794b.json | 15 ++ ...ac1a47188a5c8b8048a16d51a6e8a4bdc275a.json | 16 ++ core/api-keys/src/grpc/server/mod.rs | 4 + core/api-keys/src/limits/mod.rs | 248 +++++++++++------- 10 files changed, 227 insertions(+), 179 deletions(-) delete mode 100644 core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json delete mode 100644 core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json delete mode 100644 core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json create mode 100644 core/api-keys/.sqlx/query-54fed150bc898abeb0521a03a99efd4c656219fd7545909263600c3bfdae06bf.json delete mode 100644 core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json create mode 100644 core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json create mode 100644 core/api-keys/.sqlx/query-e347e89036d70b33d0a1aed6f51bd80036f5c786d0234d451c485f1e12e5794b.json create mode 100644 core/api-keys/.sqlx/query-e66c9622a58cb179025b47328b9ac1a47188a5c8b8048a16d51a6e8a4bdc275a.json diff --git a/core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json b/core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json deleted file mode 100644 index f34e11495b..0000000000 --- a/core/api-keys/.sqlx/query-2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT amount_sats, transaction_id\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n FOR UPDATE\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "amount_sats", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "transaction_id", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "2f68d83a9706d685c37190a1fe48dbb6735ade28d49d26ee0f4163db2cd5988f" -} diff --git a/core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json b/core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json deleted file mode 100644 index f4a50e4e75..0000000000 --- a/core/api-keys/.sqlx/query-3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE api_key_transactions\n SET transaction_id = $1\n WHERE transaction_id = $2\n AND api_key_id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "3dfccb253bd325b5b387477116b421be0dd5b0c10a3e34f7cc28ac315d327368" -} diff --git a/core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json b/core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json deleted file mode 100644 index e043262754..0000000000 --- a/core/api-keys/.sqlx/query-51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT amount_sats\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n FOR UPDATE\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "amount_sats", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "51351754fbad093aa266a1694c6dc3b109364bfa6f0b54a00aa3cce03a488540" -} diff --git a/core/api-keys/.sqlx/query-54fed150bc898abeb0521a03a99efd4c656219fd7545909263600c3bfdae06bf.json b/core/api-keys/.sqlx/query-54fed150bc898abeb0521a03a99efd4c656219fd7545909263600c3bfdae06bf.json new file mode 100644 index 0000000000..5acc3e4ac4 --- /dev/null +++ b/core/api-keys/.sqlx/query-54fed150bc898abeb0521a03a99efd4c656219fd7545909263600c3bfdae06bf.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT amount_sats\n FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount_sats", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "54fed150bc898abeb0521a03a99efd4c656219fd7545909263600c3bfdae06bf" +} diff --git a/core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json b/core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json deleted file mode 100644 index 6439ef21bf..0000000000 --- a/core/api-keys/.sqlx/query-747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at)\n VALUES ($1, $2, $3, NOW())\n ON CONFLICT (transaction_id) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Int8", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "747d3d564d63f5aeeac3db15cff84bebe11d59fd343c083b745f9ce4ee09ed0d" -} diff --git a/core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json b/core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json new file mode 100644 index 0000000000..a12fbb75a3 --- /dev/null +++ b/core/api-keys/.sqlx/query-ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at)\n VALUES ($1, $2, $3, NOW())\n ON CONFLICT (transaction_id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ce601eb27ddb4b76e7da627a1400915ad3673025f5bacd07b056a66efcb85b06" +} diff --git a/core/api-keys/.sqlx/query-e347e89036d70b33d0a1aed6f51bd80036f5c786d0234d451c485f1e12e5794b.json b/core/api-keys/.sqlx/query-e347e89036d70b33d0a1aed6f51bd80036f5c786d0234d451c485f1e12e5794b.json new file mode 100644 index 0000000000..7d5cd41e1a --- /dev/null +++ b/core/api-keys/.sqlx/query-e347e89036d70b33d0a1aed6f51bd80036f5c786d0234d451c485f1e12e5794b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM api_key_transactions\n WHERE transaction_id = $1\n AND api_key_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e347e89036d70b33d0a1aed6f51bd80036f5c786d0234d451c485f1e12e5794b" +} diff --git a/core/api-keys/.sqlx/query-e66c9622a58cb179025b47328b9ac1a47188a5c8b8048a16d51a6e8a4bdc275a.json b/core/api-keys/.sqlx/query-e66c9622a58cb179025b47328b9ac1a47188a5c8b8048a16d51a6e8a4bdc275a.json new file mode 100644 index 0000000000..75d9cf379e --- /dev/null +++ b/core/api-keys/.sqlx/query-e66c9622a58cb179025b47328b9ac1a47188a5c8b8048a16d51a6e8a4bdc275a.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_key_transactions\n SET transaction_id = $1\n WHERE transaction_id = $2\n AND api_key_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e66c9622a58cb179025b47328b9ac1a47188a5c8b8048a16d51a6e8a4bdc275a" +} diff --git a/core/api-keys/src/grpc/server/mod.rs b/core/api-keys/src/grpc/server/mod.rs index 33d3b5ee03..14ff62b20f 100644 --- a/core/api-keys/src/grpc/server/mod.rs +++ b/core/api-keys/src/grpc/server/mod.rs @@ -186,6 +186,10 @@ impl ApiKeysService for ApiKeys { ApplicationError::Limit(LimitError::EphemeralNotFound(_)) => { Status::not_found(e.to_string()) } + ApplicationError::Limit(LimitError::AmountMismatch) + | ApplicationError::Limit(LimitError::LimitExceeded(_)) => { + Status::failed_precondition(e.to_string()) + } _ => Status::internal(e.to_string()), })?; diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index abc0fc6bfb..1ac17d146a 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -191,110 +191,74 @@ impl Limits { return Err(LimitError::InvalidLimitAmount); } + let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; + match ephemeral_id { Some(eid) => { - let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; - - let mut tx = self.pool.begin().await?; - - let duplicate_txn = sqlx::query!( - r#" - SELECT amount_sats - FROM api_key_transactions - WHERE transaction_id = $1 - AND api_key_id = $2 - FOR UPDATE - "#, - &txn_id, - api_key_id as IdentityApiKeyId, - ) - .fetch_optional(&mut *tx) - .await?; - - if let Some(duplicate_txn) = duplicate_txn { - if duplicate_txn.amount_sats != amount_sats { - return Err(LimitError::AmountMismatch); - } - } + self.finalize_ephemeral(api_key_id, amount_sats, txn_id, eid) + .await + } + None => self.record_canonical(api_key_id, amount_sats, txn_id).await, + } + } - let ephemeral_txn = sqlx::query!( - r#" - SELECT amount_sats, transaction_id - FROM api_key_transactions - WHERE transaction_id = $1 - AND api_key_id = $2 - FOR UPDATE - "#, - &eid, - api_key_id as IdentityApiKeyId, - ) - .fetch_optional(&mut *tx) - .await?; - - if let Some(ephemeral_txn) = ephemeral_txn { - if ephemeral_txn.amount_sats != amount_sats { - return Err(LimitError::AmountMismatch); - } - - sqlx::query!( - r#" - UPDATE api_key_transactions - SET transaction_id = $1 - WHERE transaction_id = $2 - AND api_key_id = $3 - "#, - &txn_id, - &eid, - api_key_id as IdentityApiKeyId, - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - return Ok(()); - } + async fn finalize_ephemeral( + &self, + api_key_id: IdentityApiKeyId, + amount_sats: i64, + txn_id: String, + eid: String, + ) -> Result<(), LimitError> { + let mut tx = self.pool.begin().await?; - // Idempotent retry: ephemeral row already finalized, check canonical txn row - let finalized_txn = sqlx::query!( - r#" - SELECT amount_sats - FROM api_key_transactions - WHERE transaction_id = $1 - AND api_key_id = $2 - FOR UPDATE - "#, - &txn_id, - api_key_id as IdentityApiKeyId, - ) - .fetch_optional(&mut *tx) - .await?; - - let finalized_txn = finalized_txn.ok_or(LimitError::EphemeralNotFound(eid))?; - - if finalized_txn.amount_sats != amount_sats { - return Err(LimitError::AmountMismatch); - } + let canonical = fetch_transaction(&mut tx, api_key_id, &txn_id).await?; + let ephemeral = fetch_transaction(&mut tx, api_key_id, &eid).await?; - tx.commit().await?; + match (ephemeral, canonical) { + (Some(eph_amount), Some(can_amount)) => { + // Both rows exist: canonical was inserted by a concurrent/prior non-ephemeral call. + // Validate both amounts, then delete the ephemeral row to avoid a UNIQUE violation. + ensure_amount_matches(eph_amount, amount_sats)?; + ensure_amount_matches(can_amount, amount_sats)?; + delete_transaction(&mut tx, api_key_id, &eid).await?; + } + (Some(eph_amount), None) => { + // Happy path: rename the ephemeral row to the final transaction_id. + ensure_amount_matches(eph_amount, amount_sats)?; + rename_transaction(&mut tx, api_key_id, &eid, &txn_id).await?; } - None => { - let txn_id = transaction_id.ok_or(LimitError::MissingTransactionId)?; - - sqlx::query!( - r#" - INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at) - VALUES ($1, $2, $3, NOW()) - ON CONFLICT (transaction_id) DO NOTHING - "#, - api_key_id as IdentityApiKeyId, - amount_sats, - txn_id, - ) - .execute(&self.pool) - .await?; + (None, Some(can_amount)) => { + // Ephemeral row already gone: idempotent retry of a completed finalization. + ensure_amount_matches(can_amount, amount_sats)?; + } + (None, None) => { + return Err(LimitError::EphemeralNotFound(eid)); } } + tx.commit().await?; + Ok(()) + } + + async fn record_canonical( + &self, + api_key_id: IdentityApiKeyId, + amount_sats: i64, + txn_id: String, + ) -> Result<(), LimitError> { + sqlx::query!( + r#" + INSERT INTO api_key_transactions (api_key_id, amount_sats, transaction_id, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (transaction_id) DO NOTHING + "#, + api_key_id as IdentityApiKeyId, + amount_sats, + txn_id, + ) + .execute(&self.pool) + .await?; + Ok(()) } @@ -608,6 +572,78 @@ impl Limits { } } +fn ensure_amount_matches(stored: i64, expected: i64) -> Result<(), LimitError> { + if stored != expected { + return Err(LimitError::AmountMismatch); + } + Ok(()) +} + +async fn fetch_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + api_key_id: IdentityApiKeyId, + transaction_id: &str, +) -> Result, LimitError> { + let row = sqlx::query!( + r#" + SELECT amount_sats + FROM api_key_transactions + WHERE transaction_id = $1 + AND api_key_id = $2 + FOR UPDATE + "#, + transaction_id, + api_key_id as IdentityApiKeyId, + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(row.map(|r| r.amount_sats)) +} + +async fn delete_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + api_key_id: IdentityApiKeyId, + transaction_id: &str, +) -> Result<(), LimitError> { + sqlx::query!( + r#" + DELETE FROM api_key_transactions + WHERE transaction_id = $1 + AND api_key_id = $2 + "#, + transaction_id, + api_key_id as IdentityApiKeyId, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn rename_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + api_key_id: IdentityApiKeyId, + from_id: &str, + to_id: &str, +) -> Result<(), LimitError> { + sqlx::query!( + r#" + UPDATE api_key_transactions + SET transaction_id = $1 + WHERE transaction_id = $2 + AND api_key_id = $3 + "#, + to_id, + from_id, + api_key_id as IdentityApiKeyId, + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -748,4 +784,26 @@ mod tests { .await; assert!(matches!(result, Err(LimitError::MissingTransactionId))); } + + #[test] + fn ensure_amount_matches_returns_ok_when_amounts_match() { + assert!(ensure_amount_matches(1000, 1000).is_ok()); + } + + #[test] + fn ensure_amount_matches_returns_ok_for_zero() { + assert!(ensure_amount_matches(0, 0).is_ok()); + } + + #[test] + fn ensure_amount_matches_returns_error_when_stored_is_less() { + let result = ensure_amount_matches(500, 1000); + assert!(matches!(result, Err(LimitError::AmountMismatch))); + } + + #[test] + fn ensure_amount_matches_returns_error_when_stored_is_more() { + let result = ensure_amount_matches(1000, 500); + assert!(matches!(result, Err(LimitError::AmountMismatch))); + } } From 5dabf4369c00841b9c371efaa40f24ff80db73a6 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 13:50:26 -0500 Subject: [PATCH 27/33] fix: solve audit issues related to axios --- apps/consent/package.json | 2 +- core/api/package.json | 2 +- lib/gt3-server-node-express-sdk/package.json | 2 +- pnpm-lock.yaml | 72 ++++++++------------ 4 files changed, 32 insertions(+), 46 deletions(-) diff --git a/apps/consent/package.json b/apps/consent/package.json index 192a6f7731..96a2b3048a 100644 --- a/apps/consent/package.json +++ b/apps/consent/package.json @@ -28,7 +28,7 @@ "@opentelemetry/semantic-conventions": "1.27.0", "@ory/hydra-client": "^2.2.0", "@t3-oss/env-nextjs": "^0.7.1", - "axios": "^1.11.0", + "axios": "^1.15.0", "dotenv": "^16.3.1", "edge-csrf": "^1.0.6", "graphql": "^16.8.1", diff --git a/core/api/package.json b/core/api/package.json index 5d074e1598..4620a7039d 100644 --- a/core/api/package.json +++ b/core/api/package.json @@ -65,7 +65,7 @@ "@prelude.so/sdk": "^0.7.0", "@t3-oss/env-core": "^0.7.3", "ajv": "^8.17.1", - "axios": "^1.11.0", + "axios": "^1.15.0", "axios-retry": "^4.5.0", "basic-auth": "^2.0.1", "bignumber.js": "^9.3.1", diff --git a/lib/gt3-server-node-express-sdk/package.json b/lib/gt3-server-node-express-sdk/package.json index 4fd517d214..59e12b0d61 100644 --- a/lib/gt3-server-node-express-sdk/package.json +++ b/lib/gt3-server-node-express-sdk/package.json @@ -8,7 +8,7 @@ "preinstall": "npx only-allow pnpm" }, "dependencies": { - "axios": "^1.11.0", + "axios": "^1.15.0", "qs": "^6.11.2", "string-random": "^0.1.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc5385de2b..43306628b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,8 +198,8 @@ importers: specifier: ^0.7.1 version: 0.7.3(typescript@5.3.3)(zod@3.25.67) axios: - specifier: ^1.11.0 - version: 1.11.0 + specifier: ^1.15.0 + version: 1.15.0 dotenv: specifier: ^16.3.1 version: 16.6.1 @@ -1175,11 +1175,11 @@ importers: specifier: ^8.17.1 version: 8.17.1 axios: - specifier: ^1.11.0 - version: 1.11.0 + specifier: ^1.15.0 + version: 1.15.0 axios-retry: specifier: ^4.5.0 - version: 4.5.0(axios@1.11.0) + version: 4.5.0(axios@1.15.0) basic-auth: specifier: ^2.0.1 version: 2.0.1 @@ -1480,7 +1480,7 @@ importers: version: 0.1.4 axios-mock-adapter: specifier: ^2.1.0 - version: 2.1.0(axios@1.11.0) + version: 2.1.0(axios@1.15.0) eslint: specifier: ^9.30.1 version: 9.30.1 @@ -1714,8 +1714,8 @@ importers: lib/gt3-server-node-express-sdk: dependencies: axios: - specifier: ^1.11.0 - version: 1.11.0 + specifier: ^1.15.0 + version: 1.15.0 qs: specifier: ^6.11.2 version: 6.14.0 @@ -4677,7 +4677,7 @@ packages: combined-stream: 1.0.8 extend: 3.0.2 forever-agent: 0.6.1 - form-data: 4.0.4 + form-data: 4.0.5 http-signature: 1.4.0 is-typedarray: 1.0.0 isstream: 0.1.2 @@ -8908,7 +8908,7 @@ packages: /@ory/client@1.20.22: resolution: {integrity: sha512-N0UixgrJ7Xi0O5bttaFbJrtQhZAi61z69d9p/3tn71/nfBPOVTb8mOJIOlPhQIECj/11A65ttzI2R0QhEIeQEw==} dependencies: - axios: 1.11.0 + axios: 1.15.0 transitivePeerDependencies: - debug dev: false @@ -8916,7 +8916,7 @@ packages: /@ory/hydra-client@2.2.1: resolution: {integrity: sha512-Hb6GQuRwPyxt44Cvd3AMFKw/UtvGCHeXO7kIDfv95C/8kErWukCA3WBDWeVfZuIjY+HVPeZFZkBRVC+Ub8UEVg==} dependencies: - axios: 1.11.0 + axios: 1.15.0 transitivePeerDependencies: - debug dev: false @@ -13585,7 +13585,7 @@ packages: resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} dependencies: '@types/node': 20.11.24 - form-data: 4.0.4 + form-data: 4.0.5 /@types/node-jose@1.1.13: resolution: {integrity: sha512-QjMd4yhwy1EvSToQn0YI3cD29YhyfxFwj7NecuymjLys2/P0FwxWnkgBlFxCai6Y3aBCe7rbwmqwJJawxlgcXw==} @@ -16096,43 +16096,33 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} - /axios-mock-adapter@2.1.0(axios@1.11.0): + /axios-mock-adapter@2.1.0(axios@1.15.0): resolution: {integrity: sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==} peerDependencies: axios: '>= 0.17.0' dependencies: - axios: 1.11.0 + axios: 1.15.0 fast-deep-equal: 3.1.3 is-buffer: 2.0.5 dev: true - /axios-retry@4.5.0(axios@1.11.0): + /axios-retry@4.5.0(axios@1.15.0): resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} peerDependencies: axios: 0.x || 1.x dependencies: - axios: 1.11.0 + axios: 1.15.0 is-retry-allowed: 2.2.0 dev: false - /axios@1.11.0: - resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} - dependencies: - follow-redirects: 1.15.9 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - - /axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + /axios@1.15.0: + resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 - proxy-from-env: 1.1.0 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - dev: true /axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -17180,7 +17170,7 @@ packages: requiresBuild: true dependencies: '@testim/chrome-version': 1.1.4 - axios: 1.13.5 + axios: 1.15.0 compare-versions: 6.1.1 extract-zip: 2.0.1(supports-color@8.1.1) proxy-agent: 6.5.0 @@ -20991,16 +20981,6 @@ packages: peerDependenciesMeta: debug: optional: true - dev: true - - /follow-redirects@1.15.9: - resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true /for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -21099,6 +21079,7 @@ packages: es-set-tostringtag: 2.1.0 hasown: 2.0.2 mime-types: 2.1.35 + dev: true /form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} @@ -22464,7 +22445,7 @@ packages: engines: {node: '>=8.0.0'} dependencies: eventemitter3: 4.0.7 - follow-redirects: 1.15.9 + follow-redirects: 1.15.11 requires-port: 1.0.0 transitivePeerDependencies: - debug @@ -24506,7 +24487,7 @@ packages: '@aws-crypto/sha256-js': 5.2.0 aes-js: 3.1.2 assert: 2.1.0 - axios: 1.11.0 + axios: 1.15.0 base64-js: 1.5.1 bech32: 2.0.0 bolt11: 1.4.1 @@ -27226,11 +27207,16 @@ packages: /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: true /proxy-from-env@2.0.0: resolution: {integrity: sha512-h2lD3OfRraP3R51rNFKIE8nX+qoLr1mE74X91YhVxtDbt+OD6ntoNZv56+JgI4RCdtwQ5eexsOk1KdOQDfvPCQ==} dev: true + /proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + /prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} requiresBuild: true @@ -30580,7 +30566,7 @@ packages: resolution: {integrity: sha512-aJLBvI7ODLmFHI7ZYLBiMZKIdHuF9PrPeRM/GBMDg/AAzGXs4V8gEnNPHyTVThK0/8J48YHSqXMlQ+WJR5nxoQ==} engines: {node: '>=14.0'} dependencies: - axios: 1.11.0 + axios: 1.15.0 dayjs: 1.11.13 https-proxy-agent: 5.0.1 jsonwebtoken: 9.0.2 From 57550bfef98839dc4b72f3d0376d755576310ad0 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 17:58:26 -0500 Subject: [PATCH 28/33] fix: spending limit bypass, race condition, and BigInt truncation --- ...8bcedbd4f1ec7b667462cf73e7f22fc023a70.json | 22 +++++++++++++++++++ core/api-keys/src/limits/mod.rs | 12 ++++++++++ core/api/src/app/payments/send-lightning.ts | 10 ++++----- core/api/src/domain/shared/errors.ts | 1 + core/api/src/domain/shared/safe.ts | 17 +++++++++++++- core/api/src/graphql/error-map.ts | 4 ++++ core/api/src/services/api-keys/index.ts | 7 ++++-- 7 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json diff --git a/core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json b/core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json new file mode 100644 index 0000000000..97437309d6 --- /dev/null +++ b/core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(hashtext($1)::bigint)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70" +} diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index 1ac17d146a..6f37ba743c 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -107,6 +107,18 @@ impl Limits { let mut tx = self.pool.begin().await?; + // Acquire a transaction-scoped advisory lock keyed on a stable hash of the + // api_key_id. This serializes concurrent check_and_lock_spending calls for the + // same key, preventing two calls from both reading the spending aggregate before + // either inserts its ephemeral row (the FOR UPDATE on api_key_limits alone cannot + // block concurrent inserts into api_key_transactions when no limits row exists). + sqlx::query!( + "SELECT pg_advisory_xact_lock(hashtext($1)::bigint)", + api_key_id.to_string(), + ) + .execute(&mut *tx) + .await?; + let limits = sqlx::query!( r#" SELECT daily_limit_sats, weekly_limit_sats, monthly_limit_sats, annual_limit_sats diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 0d69ff531c..9c1dfcc375 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -830,11 +830,6 @@ const executePaymentViaLn = async ({ journalId: paymentSendAttemptResult.journalId, }) if (walletTransaction instanceof Error) { - if ( - paymentSendAttemptResult.type === PaymentSendAttemptResultType.ErrorWithJournal - ) { - return reverseSettlement({ result: walletTransaction }) - } return recordSettlement({ result: walletTransaction, settlementTransactionId: paymentSendAttemptResult.journalId, @@ -848,7 +843,10 @@ const executePaymentViaLn = async ({ const { paymentHash } = decodedInvoice switch (paymentSendAttemptResult.type) { case PaymentSendAttemptResultType.ErrorWithJournal: - return reverseSettlement({ result: paymentSendAttemptResult.error }) + return recordSettlement({ + result: paymentSendAttemptResult.error, + settlementTransactionId: paymentSendAttemptResult.journalId, + }) case PaymentSendAttemptResultType.Pending: { const result = await getPendingPaymentResponse({ diff --git a/core/api/src/domain/shared/errors.ts b/core/api/src/domain/shared/errors.ts index 6290707aeb..4dc5a55842 100644 --- a/core/api/src/domain/shared/errors.ts +++ b/core/api/src/domain/shared/errors.ts @@ -31,6 +31,7 @@ export class BigIntFloatConversionError extends BigIntConversionError {} export class UnknownBigIntConversionError extends BigIntConversionError { level = ErrorLevel.Critical } +export class BigIntToNumberConversionError extends BigIntConversionError {} export class BtcAmountTooLargeError extends ValidationError {} export class UsdAmountTooLargeError extends ValidationError {} diff --git a/core/api/src/domain/shared/safe.ts b/core/api/src/domain/shared/safe.ts index 8d7c73445e..68a48402db 100644 --- a/core/api/src/domain/shared/safe.ts +++ b/core/api/src/domain/shared/safe.ts @@ -1,4 +1,8 @@ -import { BigIntFloatConversionError, UnknownBigIntConversionError } from "./errors" +import { + BigIntFloatConversionError, + BigIntToNumberConversionError, + UnknownBigIntConversionError, +} from "./errors" export const safeBigInt = (num: number | string): bigint | BigIntConversionError => { try { @@ -14,3 +18,14 @@ export const safeBigInt = (num: number | string): bigint | BigIntConversionError export const roundToBigInt = (num: number): bigint => { return BigInt(Math.round(num)) } + +export const safeIntFromBigInt = ( + value: bigint, +): number | BigIntToNumberConversionError => { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + return new BigIntToNumberConversionError( + `BigInt value ${value} exceeds Number.MAX_SAFE_INTEGER and cannot be safely converted to number`, + ) + } + return Number(value) +} diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 0d7385efb6..a698df93e0 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -211,6 +211,10 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { message = "A valid usd amount is required" return new ValidationInternalError({ message, logger: baseLogger }) + case "BigIntToNumberConversionError": + message = "Sats amount passed is too large to be safely converted" + return new ValidationInternalError({ message, logger: baseLogger }) + case "BtcAmountTooLargeError": message = "Sats amount passed is too large" return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/core/api/src/services/api-keys/index.ts b/core/api/src/services/api-keys/index.ts index 23c27934d7..6244bada3d 100644 --- a/core/api/src/services/api-keys/index.ts +++ b/core/api/src/services/api-keys/index.ts @@ -8,6 +8,7 @@ import * as apiKeysGrpc from "./grpc-client" import { handleCommonApiKeysErrors } from "./errors" +import { safeIntFromBigInt } from "@/domain/shared" import { baseLogger } from "@/services/logger" import { wrapAsyncFunctionsToRunInSpan } from "@/services/tracing" @@ -20,7 +21,8 @@ export const ApiKeysService = (): IApiKeysService => { amount: BtcPaymentAmount }): Promise => { try { - const amountSats = Number(amount.amount) + const amountSats = safeIntFromBigInt(amount.amount) + if (amountSats instanceof Error) return amountSats const request = new CheckAndLockSpendingRequest() request.setApiKeyId(apiKeyId) request.setAmountSats(amountSats) @@ -49,7 +51,8 @@ export const ApiKeysService = (): IApiKeysService => { ephemeralId: EphemeralId }): Promise => { try { - const amountSats = Number(amount.amount) + const amountSats = safeIntFromBigInt(amount.amount) + if (amountSats instanceof Error) return amountSats const request = new RecordSpendingRequest() request.setApiKeyId(apiKeyId) request.setAmountSats(amountSats) From d6f459ea843d8634578da74cf4838c3a45c1c0c1 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 18:42:24 -0500 Subject: [PATCH 29/33] fix(core-api): reverse ErrorWithJournal settlement and unify spending settlement enum - ErrorWithJournal path in executePaymentViaLn now correctly uses reverseSettlement: the ledger is already reverted inline inside lockedPaymentViaLnSteps before this result is returned, so update-pending-payments will never process it and recordSettlement would permanently inflate the spending counter for failed payments - Remove duplicate SpendingLimitsSettlement const; spending-limits.ts now imports and uses ApiKeySpendingSettlementType directly as the single source of truth - Update index.types.d.ts and spending-limits.spec.ts accordingly --- core/api/src/app/payments/index.types.d.ts | 10 ++++------ core/api/src/app/payments/send-lightning.ts | 10 ++++++---- core/api/src/app/payments/spending-limits.ts | 16 ++++++---------- .../unit/app/payments/spending-limits.spec.ts | 4 ++-- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/core/api/src/app/payments/index.types.d.ts b/core/api/src/app/payments/index.types.d.ts index e2ea9b9137..aa2e4caf4e 100644 --- a/core/api/src/app/payments/index.types.d.ts +++ b/core/api/src/app/payments/index.types.d.ts @@ -22,19 +22,17 @@ type ApiKeySpendingSettlement = // NOTE: api-key settlement behavior must be explicit and must not be inferred // from result shape/status (e.g. result instanceof Error). -type SpendingLimitsSettlementObj = - typeof import("./spending-limits").SpendingLimitsSettlement -type SpendingLimitsSettlement = - SpendingLimitsSettlementObj[keyof SpendingLimitsSettlementObj] +type ApiKeySpendingSettlementTypeObj = + typeof import("./api-key-spending").ApiKeySpendingSettlementType type SpendingLimitsExecutionResult = | { - apiKeySettlement: SpendingLimitsSettlementObj["Record"] + apiKeySettlement: ApiKeySpendingSettlementTypeObj["Record"] settlementTransactionId: LedgerJournalId result: PaymentSendResult | ApplicationError } | { - apiKeySettlement: SpendingLimitsSettlementObj["Reverse"] + apiKeySettlement: ApiKeySpendingSettlementTypeObj["Reverse"] result: PaymentSendResult | ApplicationError } diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 9c1dfcc375..0d69ff531c 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -830,6 +830,11 @@ const executePaymentViaLn = async ({ journalId: paymentSendAttemptResult.journalId, }) if (walletTransaction instanceof Error) { + if ( + paymentSendAttemptResult.type === PaymentSendAttemptResultType.ErrorWithJournal + ) { + return reverseSettlement({ result: walletTransaction }) + } return recordSettlement({ result: walletTransaction, settlementTransactionId: paymentSendAttemptResult.journalId, @@ -843,10 +848,7 @@ const executePaymentViaLn = async ({ const { paymentHash } = decodedInvoice switch (paymentSendAttemptResult.type) { case PaymentSendAttemptResultType.ErrorWithJournal: - return recordSettlement({ - result: paymentSendAttemptResult.error, - settlementTransactionId: paymentSendAttemptResult.journalId, - }) + return reverseSettlement({ result: paymentSendAttemptResult.error }) case PaymentSendAttemptResultType.Pending: { const result = await getPendingPaymentResponse({ diff --git a/core/api/src/app/payments/spending-limits.ts b/core/api/src/app/payments/spending-limits.ts index dda19b0ba1..3d08c97286 100644 --- a/core/api/src/app/payments/spending-limits.ts +++ b/core/api/src/app/payments/spending-limits.ts @@ -1,4 +1,5 @@ import { + ApiKeySpendingSettlementType, lockApiKeySpending, reverseApiKeySpendingSettlement, recordApiKeySpendingSettlement, @@ -15,11 +16,6 @@ import { ErrorLevel } from "@/domain/shared" import { SettlementMethod } from "@/domain/wallets" import { recordExceptionInCurrentSpan } from "@/services/tracing" -export const SpendingLimitsSettlement = { - Record: "record", - Reverse: "reverse", -} as const - export const recordSettlement = ({ result, settlementTransactionId, @@ -27,7 +23,7 @@ export const recordSettlement = ({ result: PaymentSendResult | ApplicationError settlementTransactionId: LedgerJournalId }): SpendingLimitsExecutionResult => ({ - apiKeySettlement: SpendingLimitsSettlement.Record, + apiKeySettlement: ApiKeySpendingSettlementType.Record, settlementTransactionId, result, }) @@ -37,7 +33,7 @@ export const reverseSettlement = ({ }: { result: PaymentSendResult | ApplicationError }): SpendingLimitsExecutionResult => ({ - apiKeySettlement: SpendingLimitsSettlement.Reverse, + apiKeySettlement: ApiKeySpendingSettlementType.Reverse, result, }) @@ -47,7 +43,7 @@ const settlementFor = ( const { apiKeySettlement } = executionResult if ( - apiKeySettlement === SpendingLimitsSettlement.Record && + apiKeySettlement === ApiKeySpendingSettlementType.Record && !executionResult.settlementTransactionId ) { recordExceptionInCurrentSpan({ @@ -60,10 +56,10 @@ const settlementFor = ( } switch (apiKeySettlement) { - case SpendingLimitsSettlement.Record: + case ApiKeySpendingSettlementType.Record: return recordApiKeySpendingSettlement(executionResult.settlementTransactionId) - case SpendingLimitsSettlement.Reverse: + case ApiKeySpendingSettlementType.Reverse: return reverseApiKeySpendingSettlement() default: { diff --git a/core/api/test/unit/app/payments/spending-limits.spec.ts b/core/api/test/unit/app/payments/spending-limits.spec.ts index 4922718638..104de2535e 100644 --- a/core/api/test/unit/app/payments/spending-limits.spec.ts +++ b/core/api/test/unit/app/payments/spending-limits.spec.ts @@ -23,10 +23,10 @@ jest.mock("@/app/accounts", () => ({ checkWithdrawalLimits: jest.fn(), })) +import { ApiKeySpendingSettlementType } from "@/app/payments/api-key-spending" import { recordSettlement, reverseSettlement, - SpendingLimitsSettlement, withSpendingLimits, } from "@/app/payments/spending-limits" import { PaymentSendStatus } from "@/domain/bitcoin/lightning" @@ -319,7 +319,7 @@ describe("withSpendingLimits", () => { btcPaymentAmount, execute: async () => ({ - apiKeySettlement: SpendingLimitsSettlement.Record, + apiKeySettlement: ApiKeySpendingSettlementType.Record, result: paymentSendSuccessResult, }) as SpendingLimitsExecutionResult, }) From 6ae17b59d92e2c592773155db273db883e3def73 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 19:22:59 -0500 Subject: [PATCH 30/33] fix(api-keys): reduce hashtext collision risk in advisory lock - Replace hashtext($1)::bigint with hashtextextended($1, 0) to expand hash space from 32-bit to 64-bit - Reduces collision probability for pg_advisory_xact_lock keys when many API keys exist - Requires PostgreSQL 13+ (already in use for api-keys service) --- ...8c330387456dd68a8803972c7b3e2b6a2b95c27f15068bed2ca5.json} | 4 ++-- core/api-keys/src/limits/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename core/api-keys/.sqlx/{query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json => query-751f836dc8f78c330387456dd68a8803972c7b3e2b6a2b95c27f15068bed2ca5.json} (66%) diff --git a/core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json b/core/api-keys/.sqlx/query-751f836dc8f78c330387456dd68a8803972c7b3e2b6a2b95c27f15068bed2ca5.json similarity index 66% rename from core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json rename to core/api-keys/.sqlx/query-751f836dc8f78c330387456dd68a8803972c7b3e2b6a2b95c27f15068bed2ca5.json index 97437309d6..a784094d72 100644 --- a/core/api-keys/.sqlx/query-b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70.json +++ b/core/api-keys/.sqlx/query-751f836dc8f78c330387456dd68a8803972c7b3e2b6a2b95c27f15068bed2ca5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT pg_advisory_xact_lock(hashtext($1)::bigint)", + "query": "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "b73acdffd6095a6466e42058a0f8bcedbd4f1ec7b667462cf73e7f22fc023a70" + "hash": "751f836dc8f78c330387456dd68a8803972c7b3e2b6a2b95c27f15068bed2ca5" } diff --git a/core/api-keys/src/limits/mod.rs b/core/api-keys/src/limits/mod.rs index 6f37ba743c..d4f44deeeb 100644 --- a/core/api-keys/src/limits/mod.rs +++ b/core/api-keys/src/limits/mod.rs @@ -113,7 +113,7 @@ impl Limits { // either inserts its ephemeral row (the FOR UPDATE on api_key_limits alone cannot // block concurrent inserts into api_key_transactions when no limits row exists). sqlx::query!( - "SELECT pg_advisory_xact_lock(hashtext($1)::bigint)", + "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", api_key_id.to_string(), ) .execute(&mut *tx) From 6ca01a72cf9e141aa9eae4ce6d4211c05f40a3d2 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 21:32:29 -0500 Subject: [PATCH 31/33] fix: lazy gRPC client instantiation --- core/api/src/app/payments/api-key-spending.ts | 4 +++- core/api/src/app/payments/send-lightning.ts | 13 ++++++++----- .../api/src/app/payments/update-pending-payments.ts | 3 +-- core/api/src/domain/shared/safe.ts | 5 +++++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/core/api/src/app/payments/api-key-spending.ts b/core/api/src/app/payments/api-key-spending.ts index c32aafd60f..a9f1a58672 100644 --- a/core/api/src/app/payments/api-key-spending.ts +++ b/core/api/src/app/payments/api-key-spending.ts @@ -1,5 +1,4 @@ import { ApiKeysService } from "@/services/api-keys" -const apiKeys = ApiKeysService() export const ApiKeySpendingSettlementType = { Record: "record", @@ -26,6 +25,7 @@ export const lockApiKeySpending = async ({ }): Promise => { if (!apiKeyId) return undefined + const apiKeys = ApiKeysService() const ephemeralId = await apiKeys.checkAndLockSpending({ apiKeyId, amount, @@ -48,6 +48,8 @@ export const settleApiKeySpending = async ({ }): Promise => { if (!lock) return true + const apiKeys = ApiKeysService() + if (settlement.type === ApiKeySpendingSettlementType.Reverse) { const reverseResult = await apiKeys.reverseSpending({ transactionId: lock.ephemeralId, diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 0d69ff531c..48c83a0699 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -840,17 +840,16 @@ const executePaymentViaLn = async ({ settlementTransactionId: paymentSendAttemptResult.journalId, }) } - NotificationsService().sendTransaction({ - recipient: notificationRecipient, - transaction: walletTransaction, - }) - const { paymentHash } = decodedInvoice switch (paymentSendAttemptResult.type) { case PaymentSendAttemptResultType.ErrorWithJournal: return reverseSettlement({ result: paymentSendAttemptResult.error }) case PaymentSendAttemptResultType.Pending: { + NotificationsService().sendTransaction({ + recipient: notificationRecipient, + transaction: walletTransaction, + }) const result = await getPendingPaymentResponse({ walletId: senderWalletId, paymentHash, @@ -869,6 +868,10 @@ const executePaymentViaLn = async ({ } default: + NotificationsService().sendTransaction({ + recipient: notificationRecipient, + transaction: walletTransaction, + }) return recordSettlement({ result: { status: PaymentSendStatus.Success, diff --git a/core/api/src/app/payments/update-pending-payments.ts b/core/api/src/app/payments/update-pending-payments.ts index e81a748f36..9ae2ebe1dd 100644 --- a/core/api/src/app/payments/update-pending-payments.ts +++ b/core/api/src/app/payments/update-pending-payments.ts @@ -44,8 +44,6 @@ import { } from "@/services/tracing" import { runInParallel } from "@/utils" -const apiKeys = ApiKeysService() - export const updatePendingPayments = async (logger: Logger): Promise => { const ledgerService = LedgerService() const walletIdsWithPendingPayments = ledgerService.listWalletIdsWithPendingPayments() @@ -351,6 +349,7 @@ const lockedPendingPaymentSteps = async ({ { success: false, id: paymentHash, payment: pendingPayment }, "payment has failed. reverting transaction", ) + const apiKeys = ApiKeysService() if (paymentFlow.senderWalletCurrency === WalletCurrency.Btc) { const voided = await ledgerService.revertLightningPayment({ journalId, diff --git a/core/api/src/domain/shared/safe.ts b/core/api/src/domain/shared/safe.ts index 68a48402db..a549825331 100644 --- a/core/api/src/domain/shared/safe.ts +++ b/core/api/src/domain/shared/safe.ts @@ -27,5 +27,10 @@ export const safeIntFromBigInt = ( `BigInt value ${value} exceeds Number.MAX_SAFE_INTEGER and cannot be safely converted to number`, ) } + if (value < BigInt(Number.MIN_SAFE_INTEGER)) { + return new BigIntToNumberConversionError( + `BigInt value ${value} is below Number.MIN_SAFE_INTEGER and cannot be safely converted to number`, + ) + } return Number(value) } From 8720ea584af3463fe204e50664dc94342c656f63 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 22:20:50 -0500 Subject: [PATCH 32/33] fix: rollback send tx notification update --- core/api/src/app/payments/send-lightning.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index 48c83a0699..a99706087f 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -840,16 +840,18 @@ const executePaymentViaLn = async ({ settlementTransactionId: paymentSendAttemptResult.journalId, }) } + + NotificationsService().sendTransaction({ + recipient: notificationRecipient, + transaction: walletTransaction, + }) + const { paymentHash } = decodedInvoice switch (paymentSendAttemptResult.type) { case PaymentSendAttemptResultType.ErrorWithJournal: return reverseSettlement({ result: paymentSendAttemptResult.error }) case PaymentSendAttemptResultType.Pending: { - NotificationsService().sendTransaction({ - recipient: notificationRecipient, - transaction: walletTransaction, - }) const result = await getPendingPaymentResponse({ walletId: senderWalletId, paymentHash, @@ -868,10 +870,6 @@ const executePaymentViaLn = async ({ } default: - NotificationsService().sendTransaction({ - recipient: notificationRecipient, - transaction: walletTransaction, - }) return recordSettlement({ result: { status: PaymentSendStatus.Success, From e06e7eb2c98df1106076f4bcbb088de004b9d6c8 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 9 Apr 2026 22:23:04 -0500 Subject: [PATCH 33/33] fix: remove unnecessary code --- core/api/src/app/payments/send-lightning.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/core/api/src/app/payments/send-lightning.ts b/core/api/src/app/payments/send-lightning.ts index a99706087f..70974f2efe 100644 --- a/core/api/src/app/payments/send-lightning.ts +++ b/core/api/src/app/payments/send-lightning.ts @@ -856,12 +856,6 @@ const executePaymentViaLn = async ({ walletId: senderWalletId, paymentHash, }) - if (result instanceof Error) { - return recordSettlement({ - result, - settlementTransactionId: paymentSendAttemptResult.journalId, - }) - } return recordSettlement({ result,
NameAPI Key IDScopeBudget LimitsExpires AtLast UsedActionsNameAPI Key IDScopeExpires AtLast UsedAction
{name} {id} {getScopeText(scopes)} - {hasAnyLimit ? ( - - {dailyLimitSats && ( -
- - Daily: {dailyLimitSats.toLocaleString()} sats - - - Spent: {dailySpentSats.toLocaleString()} / Remaining:{" "} - {remainingDailyLimitSats?.toLocaleString() || 0} - -
- )} - {weeklyLimitSats && ( -
- - Weekly: {weeklyLimitSats.toLocaleString()}{" "} - sats - - - Spent: {weeklySpentSats.toLocaleString()} / Remaining:{" "} - {(weeklyLimitSats - weeklySpentSats).toLocaleString()} - -
- )} - {monthlyLimitSats && ( -
- - Monthly: {monthlyLimitSats.toLocaleString()}{" "} - sats - - - Spent: {monthlySpentSats.toLocaleString()} / Remaining:{" "} - {(monthlyLimitSats - monthlySpentSats).toLocaleString()} - -
- )} - {annualLimitSats && ( -
- - Annual: {annualLimitSats.toLocaleString()}{" "} - sats - - - Spent: {annualSpentSats.toLocaleString()} / Remaining:{" "} - {(annualLimitSats - annualSpentSats).toLocaleString()} - -
- )} -
- ) : ( - - Unlimited - - )} -
{expiresAt ? formatDate(expiresAt) : "Never"} {lastUsedAt ? formatDate(lastUsedAt) : "Never"} - - - - +