Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions apps/sdp-api/src/routes/earn.external-wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { generateEarnPositionId } from "@/db/repositories/earn-movements.reposit
import { app } from "@/index";
import { badRequest, conflict } from "@/lib/errors";
import { ALL_API_KEY_GRANTS } from "@/test/fixtures/api-keys";
import { recordCallScopes } from "@/test/helpers/cluster-scope";
import { env } from "@/test/helpers/env";
import { seedDefaultProjects } from "@/test/helpers/projects";
import { type SeededApiKey, seedApiKeyRow, seedTestDatabase } from "@/test/mocks/db";
Expand Down Expand Up @@ -390,6 +391,7 @@ afterEach(() => {
describe("POST /v1/earn/external-wallet/deposit-transactions — money-in gates", () => {
it("builds against a resolved, admitted strategy", async () => {
await seedAuth();
const scopes = recordCallScopes(buildExternalWalletDepositTransaction);
const strategy = await seedStrategy();

const res = await post("deposit-transactions", {
Expand All @@ -411,13 +413,15 @@ describe("POST /v1/earn/external-wallet/deposit-transactions — money-in gates"
expect.objectContaining({
organizationId: TEST_ORG.id,
projectId: TEST_PROJECT.id,
environment: "sandbox",
provider: "kamino",
providerReference: strategy.provider_reference,
ownerAddress: OWNER,
amount: "25",
})
);
expect(scopes).toEqual([
expect.objectContaining({ projectId: TEST_PROJECT.id, environment: "sandbox" }),
]);
});

it("404s an unknown strategy", async () => {
Expand Down Expand Up @@ -798,6 +802,7 @@ describe("POST /v1/earn/external-wallet/deposit-transactions — money-in gates"

it("opens Kamino from production and requires the caller's minSharesOut (PRO-1986)", async () => {
await seedAuth();
const scopes = recordCallScopes(buildExternalWalletDepositTransaction);
const strategy = await seedStrategy({ environment: "production", hostCluster: "mainnet-beta" });
const missingFloor = await post(
"deposit-transactions",
Expand Down Expand Up @@ -825,8 +830,9 @@ describe("POST /v1/earn/external-wallet/deposit-transactions — money-in gates"
expect(res.status).toBe(200);
expect(buildExternalWalletDepositTransaction).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ environment: "production", provider: "kamino", minSharesOut: "1" })
expect.objectContaining({ provider: "kamino", minSharesOut: "1" })
);
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});

it("keeps production closed for a provider the deposit-environment map leaves sandbox-only", async () => {
Expand All @@ -851,6 +857,7 @@ describe("POST /v1/earn/external-wallet/deposit-transactions — money-in gates"

it("opens Jupiter Lend only from production and requires the caller's minSharesOut", async () => {
await seedAuth();
const scopes = recordCallScopes(buildExternalWalletDepositTransaction);
const strategy = await seedStrategy({
provider: "jupiter_lend",
providerReference: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
Expand Down Expand Up @@ -883,12 +890,9 @@ describe("POST /v1/earn/external-wallet/deposit-transactions — money-in gates"
expect(res.status).toBe(200);
expect(buildExternalWalletDepositTransaction).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
environment: "production",
provider: "jupiter_lend",
minSharesOut: "24.9",
})
expect.objectContaining({ provider: "jupiter_lend", minSharesOut: "24.9" })
);
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});
});

Expand Down Expand Up @@ -926,6 +930,7 @@ describe("POST /v1/earn/external-wallet/deposits — the submit contract", () =>

it("records the submit and answers the movement in ledger vocabulary", async () => {
await seedAuth();
const scopes = recordCallScopes(submitExternalWalletDeposit);
const key = crypto.randomUUID();
const res = await post(
"deposits",
Expand All @@ -944,12 +949,14 @@ describe("POST /v1/earn/external-wallet/deposits — the submit contract", () =>
expect.objectContaining({
organizationId: TEST_ORG.id,
projectId: TEST_PROJECT.id,
environment: "sandbox",
transactionId: "earn_ext_tx",
signedTransaction: "AQ==",
requestId: key,
})
);
expect(scopes).toEqual([
expect.objectContaining({ projectId: TEST_PROJECT.id, environment: "sandbox" }),
]);
});
});

Expand Down
8 changes: 4 additions & 4 deletions apps/sdp-api/src/routes/earn.production-entitlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@/db/repositories/earn-movements.repository";
import { app } from "@/index";
import { ALL_API_KEY_GRANTS } from "@/test/fixtures/api-keys";
import { recordCallScopes } from "@/test/helpers/cluster-scope";
import {
seedTestPrivyConnection,
writeTestPrivyCredentialSecret,
Expand Down Expand Up @@ -217,6 +218,7 @@ describe.each([

it("withdraws an existing production position", async () => {
const positionId = await seedProductionPosition();
const scopes = recordCallScopes(withdrawFromVault);

const res = await request(actor, "POST", "/v1/earn/vault-withdrawals", {
positionId,
Expand All @@ -225,9 +227,7 @@ describe.each([

expect(res.status).toBe(200);
expect(withdrawFromVault).toHaveBeenCalledTimes(1);
expect(withdrawFromVault.mock.calls[0]?.[1]).toMatchObject({
positionId,
environment: "production",
});
expect(withdrawFromVault.mock.calls[0]?.[1]).toMatchObject({ positionId });
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});
});
4 changes: 3 additions & 1 deletion apps/sdp-api/src/routes/earn.vault-withdrawals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createVaultDeadline } from "@/services/earn/vault-deadline";
import { custodyProviderNotInReleaseChannel } from "@/services/provider-availability.service";
import { ALL_API_KEY_GRANTS } from "@/test/fixtures/api-keys";
import { seedProjectApiKey } from "@/test/helpers/api-keys";
import { recordCallScopes } from "@/test/helpers/cluster-scope";
import {
activateTestCustodyConnection,
insertTestCustodyConnection,
Expand Down Expand Up @@ -859,6 +860,7 @@ describe("POST /v1/earn/vault-withdrawals — exit safety (ADR 0002)", () => {
it("withdraws in PRODUCTION even while vault deposits are environment-closed there", async () => {
// The deposit route fail-closes production; an exit must work wherever a
// position exists, or the fail-close itself would trap funds.
const scopes = recordCallScopes(withdrawFromVault);
const prodKeyHash = await hashString(PROD_API_KEY.raw, env.API_KEY_PEPPER);
await seedApiKeyRow(env, prodKeyHash, {
...TEST_SEEDED_API_KEY,
Expand Down Expand Up @@ -907,7 +909,7 @@ describe("POST /v1/earn/vault-withdrawals — exit safety (ADR 0002)", () => {

expect(res.status).toBe(200);
expect(withdrawFromVault).toHaveBeenCalledTimes(1);
expect(withdrawFromVault.mock.calls[0][1]).toMatchObject({ environment: "production" });
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});

it("answers 501 when the provider cannot build an exit — capability, never permission", async () => {
Expand Down
25 changes: 10 additions & 15 deletions apps/sdp-api/src/routes/earn.vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { buildEarnVaultDepositFingerprint } from "@/lib/idempotency";
import { AuditService } from "@/services/audit.service";
import { resolveEarnExecutionClient } from "@/services/earn/execution-registry";
import { createVaultDeadline } from "@/services/earn/vault-deadline";
import { recordCallScopes } from "@/test/helpers/cluster-scope";
import { seedTestCustodyRows } from "@/test/helpers/custody";
import {
seedTestPrivyConnection,
Expand Down Expand Up @@ -533,6 +534,7 @@ describe("POST /v1/earn/vault-deposits — custody runtime admission", () => {
describe("POST /v1/earn/vault-deposits — catalogue admission", () => {
it("opens Kamino from production and requires the caller's minSharesOut (PRO-1986)", async () => {
await seedAuth();
const scopes = recordCallScopes(depositIntoVault);
await seedProductionConnectionWallet({
connectionId: "cconn_earn_vault_kamino_prod",
credentialId: "pcred_earn_vault_kamino_prod",
Expand Down Expand Up @@ -572,12 +574,9 @@ describe("POST /v1/earn/vault-deposits — catalogue admission", () => {
expect(res.status).toBe(200);
expect(depositIntoVault).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
environment: "production",
provider: "kamino",
minSharesOut: "9.99",
})
expect.objectContaining({ provider: "kamino", minSharesOut: "9.99" })
);
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});

it("keeps production closed for a provider the deposit-environment map leaves sandbox-only", async () => {
Expand Down Expand Up @@ -613,6 +612,7 @@ describe("POST /v1/earn/vault-deposits — catalogue admission", () => {

it("opens Jupiter Lend only from production and requires the caller's minSharesOut", async () => {
await seedAuth();
const scopes = recordCallScopes(depositIntoVault);
await seedProductionConnectionWallet({
connectionId: "cconn_earn_vault_jupiter",
credentialId: "pcred_earn_vault_jupiter",
Expand Down Expand Up @@ -657,16 +657,14 @@ describe("POST /v1/earn/vault-deposits — catalogue admission", () => {
expect(res.status).toBe(200);
expect(depositIntoVault).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
environment: "production",
provider: "jupiter_lend",
minSharesOut: "9.99",
})
expect.objectContaining({ provider: "jupiter_lend", minSharesOut: "9.99" })
);
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});

it("opens Ondo USDY only from production and requires the caller's minSharesOut", async () => {
await seedAuth();
const scopes = recordCallScopes(depositIntoVault);
await seedProductionConnectionWallet({
connectionId: "cconn_earn_vault_ondo",
credentialId: "pcred_earn_vault_ondo",
Expand Down Expand Up @@ -734,12 +732,9 @@ describe("POST /v1/earn/vault-deposits — catalogue admission", () => {
expect(res.status).toBe(200);
expect(depositIntoVault).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
environment: "production",
provider: "ondo",
minSharesOut: "9.9",
})
expect.objectContaining({ provider: "ondo", minSharesOut: "9.9" })
);
expect(scopes).toEqual([expect.objectContaining({ environment: "production" })]);
});

it("refuses a paused strategy", async () => {
Expand Down
20 changes: 14 additions & 6 deletions apps/sdp-api/src/routes/earn/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1622,12 +1622,20 @@ fail-closed + 4xx-vs-ambiguous outcomes in `../earn.vault.test.ts`, fail-open

## Conventions

- Environment resolution is the shared `@/lib/sdp-environment` helper: API-key
callers use the key's (project-derived) environment; dashboard/session
callers use the membership-verified `x-project-id` project's environment; a
request with neither fails closed (500), never defaults to sandbox, and the
deployment's `ENVIRONMENT` never decides. A production-project dashboard
session therefore drives provider production.
- **Environment and cluster come from the open cluster scope**
(`currentClusterScope()`, ADR 0007), never from `c`. The project middleware
opens it with the API key's (project-derived) environment or the
membership-verified `x-project-id` project's; the deployment's `ENVIRONMENT`
never decides. Code outside a scope fails closed (`CLUSTER_SCOPE_MISSING`,
500), never defaults to sandbox. Services take neither as a parameter and
read the scope where they need it; a provider client gets the scope's
environment through `earnRuntime(env)` (`services/earn/runtime.ts`). A read
about one recorded row (a queued request re-read before its cancel, the
post-exit close-out) keeps the row's own environment, which the URL helpers
refuse unless it is the scope's. `services/earn/cluster-rpc.boundary.test.ts`
fails an Earn route or service that imports `resolveSdpEnvironment` or
`CLUSTER_BY_SDP_ENVIRONMENT`. A production-project dashboard session
therefore drives provider production.
- `EARN_ENABLED` gates the whole family (index.ts), and Earn is a sub-module of
Markets — `isEarnEnabled` also requires the parent `MARKETS_ENABLED`, so
clearing that one flag darkens every Markets API surface. Both default off.
Expand Down
16 changes: 6 additions & 10 deletions apps/sdp-api/src/routes/earn/context.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,33 @@
import type { EarnRuntimeContext } from "@sdp/earn/types";
import { currentClusterScope } from "@sdp/rpc/scope";
import type { SdpEnvironment } from "@sdp/types/api-keys";
import type { Context } from "hono";
import type { EarnStrategyRow } from "@/db/repositories/earn.repository";
import { createEarnRepository } from "@/db/repositories/repository-factory";
import { notFound } from "@/lib/errors";
import { resolveSdpEnvironment } from "@/lib/sdp-environment";
import type { Env } from "@/types/env";

export type AppContext = Context<{ Bindings: Env }>;

/**
* Loads the strategy a route names in the caller's project environment. A row
* from another environment is 404, never a cross-tier answer.
* Loads the strategy a route names in the open cluster scope's environment, which the project
* middleware opened for the caller's project. A row from another environment is 404, never a
* cross-tier answer.
*
* @param c - The request context.
* @param strategyId - The catalogue strategy id the request names.
* @returns The strategy row and the project's environment.
* @returns The strategy row and the scope's environment.
* @throws AppError `NOT_FOUND` when the row is absent or belongs to another environment.
*/
export async function requireEarnStrategyForCaller(
c: AppContext,
strategyId: string
): Promise<{ strategy: EarnStrategyRow; environment: SdpEnvironment }> {
const environment = resolveSdpEnvironment(c);
const { environment } = currentClusterScope();
const strategy = await getEarnRepository(c).getStrategyById(strategyId);
if (!strategy || strategy.environment !== environment) throw notFound("Earn strategy");
return { strategy, environment };
}

export function earnRuntime(c: AppContext): EarnRuntimeContext {
return { env: c.env, environment: resolveSdpEnvironment(c) };
}

export function getEarnRepository(c: AppContext) {
return createEarnRepository(c.env);
}
11 changes: 4 additions & 7 deletions apps/sdp-api/src/routes/earn/handlers/admission.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isEarnProviderId } from "@sdp/earn";
import { providerNotConfigured } from "@sdp/earn/errors";
import { isClusterFundableInEnvironment } from "@sdp/earn/support";
import { currentClusterScope } from "@sdp/rpc/scope";
import type { SdpEnvironment } from "@sdp/types/api-keys";
import {
type EarnProviderId,
Expand All @@ -11,7 +12,6 @@ import {
import type { SolanaCluster } from "@sdp/types/well-known-tokens";
import type { EarnStrategyRow } from "@/db/repositories/earn.repository";
import { badRequest, forbidden } from "@/lib/errors";
import { resolveSdpEnvironment } from "@/lib/sdp-environment";
import type { AppContext } from "@/routes/earn/context";
import { assertVaultExposureWithinCap } from "@/services/earn/vault-exposure";
import { assertProviderAvailable } from "@/services/provider-availability.service";
Expand Down Expand Up @@ -126,12 +126,9 @@ export function assertVaultDirectDepositStyle(provider: string): void {
export async function assertVaultDepositAdmissible(
c: AppContext,
strategy: EarnStrategyRow,
amount?: string,
options: {
environment?: SdpEnvironment;
} = {}
amount?: string
): Promise<EarnProviderId> {
const environment = options.environment ?? resolveSdpEnvironment(c);
const { environment } = currentClusterScope();

assertVaultDirectDepositStyle(strategy.provider);
if (!isEarnProviderId(strategy.provider)) {
Expand All @@ -149,7 +146,7 @@ export async function assertVaultDepositAdmissible(
// Deposits only. The withdrawal handlers never call this function, and
// must not start to: refusing an exit over a platform cap traps funds
// (ADR 0002), which is the one thing no cap may do.
await assertVaultExposureWithinCap(c, strategy, amount, { environment });
await assertVaultExposureWithinCap(c, strategy, amount);
}

return provider;
Expand Down
Loading
Loading