From 5d01857a4ba9ba3481a697c7c280e5331e1d465d Mon Sep 17 00:00:00 2001 From: basarrcan Date: Mon, 2 Mar 2026 15:11:56 +0300 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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))); }
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"} - + + + +