Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a7bd936
fix(api): make Avenia outcome emails transactional
ebma Aug 25, 2026
593ef02
feat(sdk): add storeEphemeralKeysCallback for custom key persistence
ebma Aug 25, 2026
a0a6b99
docs(sdk): document custom ephemeral storage callback
ebma Aug 25, 2026
e10bbfb
docs(repo): correct ephemeral custody guidance in integration skill
ebma Aug 25, 2026
96cfd8b
chore(sdk): regenerate wire-contract snapshot for storage callback
ebma Aug 25, 2026
0f0d260
test(sdk): cover fail-closed ephemeral storage
ebma Aug 26, 2026
a40ec0d
test(dashboard): align transfer journeys with Base default
ebma Aug 28, 2026
c1cdc40
Merge pull request #1353 from pendulum-chain/codex/fix-avenia-notific…
ebma Aug 28, 2026
57a5b4e
Merge pull request #1360 from pendulum-chain/codex/fix-dashboard-e2e-…
ebma Aug 28, 2026
c3c2b30
chore(sdk): prepare package release candidates
ebma Aug 28, 2026
31f37de
chore(sdk): promote packages to stable releases
ebma Aug 28, 2026
d2d3994
docs(sdk): document custom storage in API guides
ebma Aug 28, 2026
437a58e
Merge pull request #1357 from pendulum-chain/feat/sdk-ephemeral-stora…
ebma Sep 1, 2026
e74038c
fix(api): anchor routed subsidies to usd value
ebma Sep 1, 2026
4120c8b
docs(api): document usd-denominated subsidy caps
ebma Sep 1, 2026
cba9ddc
fix(shared): validate squidrouter toAmountUSD as big-parseable
ebma Sep 1, 2026
1163d22
fix(api): surface routed subsidy probe fallbacks in logs
ebma Sep 1, 2026
84c10a1
test(api): derive fake squid usd value from route input
ebma Sep 1, 2026
3009d33
fix(api): keep malformed squid usd from blocking routed quotes
ebma Sep 1, 2026
e54c678
fix(shared): tolerate omitted squidrouter toAmountUSD
ebma Sep 2, 2026
c2746a1
Merge pull request #1362 from pendulum-chain/codex/fix-paxg-subsidy-v…
ebma Sep 2, 2026
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
4 changes: 2 additions & 2 deletions .agents/skills/vortex-integration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ const vortex = new VortexSdk({
});
```

For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), set `storeEphemeralKeys: false` and persist via your own mechanism.
For server processes that manage their own ephemeral key storage (e.g. HSM, encrypted DB), configure `storeEphemeralKeysCallback: async (keys, rampId) => { ... }`. The SDK calls it with the recovery material (`StoredEphemeralKey[]`: `{ address, rampId, secret, type }`) instead of writing the local file, and `storeEphemeralKeys` has no effect. A rejection aborts `registerRamp` before ephemeral-owned transactions are signed (same fail-closed contract as built-in storage). Setting only `storeEphemeralKeys: false` disables the recovery backup entirely — the secrets are not exposed anywhere else.

For browser integrations, never configure `secretKey`. Resolve the current renewable Supabase token on every request:

Expand All @@ -519,7 +519,7 @@ const vortex = new VortexSdk({
});
```

If both `secretKey` and `accessTokenProvider` are configured, the SDK uses the secret key and does not call the provider. Browser ephemeral recovery currently uses plain `localStorage`; this is intentionally prototype-grade. Set `storeEphemeralKeys: false` when the integrating application owns secure recovery storage.
If both `secretKey` and `accessTokenProvider` are configured, the SDK uses the secret key and does not call the provider. Browser ephemeral recovery currently uses plain `localStorage`; this is intentionally prototype-grade. Configure `storeEphemeralKeysCallback` when the integrating application owns secure recovery storage (or set `storeEphemeralKeys: false` to disable the backup entirely).

## REST fallback
Use:
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/controllers/brla.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1336,7 +1336,7 @@ export const getKybAttemptStatus = async (

// Queue only after proving this is still the bound attempt. A queue failure rolls
// back the terminal state so a later poll can retry the notification.
await enqueueVerificationNotification(attempt, effectiveUserId, "business");
await enqueueVerificationNotification(attempt, effectiveUserId, "business", transaction);
await lockedRecord.update(
{
lastFailureReasons: failureReason ? [failureReason] : [],
Expand Down
49 changes: 47 additions & 2 deletions apps/api/src/api/services/avenia/avenia-customer.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { KycAttemptResult, KycAttemptStatus } from "@vortexfi/shared";
import { afterEach, describe, expect, it, mock } from "bun:test";
import sequelize from "../../../config/database";
import EmailNotification from "../../../models/emailNotification.model";
import KycCase from "../../../models/kycCase.model";
import ProviderCustomer, { VerificationStatus } from "../../../models/providerCustomer.model";
import { SupabaseAuthService } from "../auth";
import { updateAveniaKycOutcomeForCustomer, updateAveniaKycProgressForCustomer } from "./avenia-customer.service";

const originalTransaction = sequelize.transaction;
const originalCustomerFindByPk = ProviderCustomer.findByPk;
const originalCaseFindAll = KycCase.findAll;
const originalNotificationFindOne = EmailNotification.findOne;
const originalNotificationFindOrCreate = EmailNotification.findOrCreate;
const originalGetUserLocale = SupabaseAuthService.getUserLocale;

afterEach(() => {
sequelize.transaction = originalTransaction;
ProviderCustomer.findByPk = originalCustomerFindByPk;
KycCase.findAll = originalCaseFindAll;
EmailNotification.findOne = originalNotificationFindOne;
EmailNotification.findOrCreate = originalNotificationFindOrCreate;
SupabaseAuthService.getUserLocale = originalGetUserLocale;
});

function setup(
Expand Down Expand Up @@ -53,6 +62,7 @@ function setup(

ProviderCustomer.findByPk = mock(async () => customer) as unknown as typeof ProviderCustomer.findByPk;
KycCase.findAll = mock(async () => [kycCase]) as unknown as typeof KycCase.findAll;
const transaction = { LOCK: { UPDATE: "UPDATE" } } as never;
sequelize.transaction = mock(async callback => {
const customerSnapshot = { status: customer.status, statusExternal: customer.statusExternal };
const caseSnapshot = {
Expand All @@ -62,15 +72,15 @@ function setup(
statusExternal: kycCase.statusExternal
};
try {
return await callback({ LOCK: { UPDATE: "UPDATE" } } as never);
return await callback(transaction);
} catch (error) {
Object.assign(customer, customerSnapshot);
Object.assign(kycCase, caseSnapshot);
throw error;
}
}) as unknown as typeof sequelize.transaction;

return { customer, kycCase };
return { customer, kycCase, transaction };
}

describe("updateAveniaKycOutcomeForCustomer", () => {
Expand Down Expand Up @@ -107,6 +117,41 @@ describe("updateAveniaKycOutcomeForCustomer", () => {
expect(kycCase.rejectedAt).toBeNull();
});

it("queues the outcome notification in the status transaction", async () => {
const { customer, transaction } = setup(VerificationStatus.InReview, VerificationStatus.InReview);
let duplicateCheckTransaction: unknown;
let insertTransaction: unknown;
EmailNotification.findOne = mock(async options => {
duplicateCheckTransaction = options.transaction;
return null;
}) as unknown as typeof EmailNotification.findOne;
EmailNotification.findOrCreate = mock(async options => {
insertTransaction = options.transaction;
return [{} as EmailNotification, true];
}) as unknown as typeof EmailNotification.findOrCreate;
SupabaseAuthService.getUserLocale = mock(async () => "en-US") as typeof SupabaseAuthService.getUserLocale;

await updateAveniaKycOutcomeForCustomer(
customer,
VerificationStatus.Approved,
KycAttemptStatus.COMPLETED,
{ id: "case-1", providerCaseId: "attempt-1" },
{
attempt: {
id: "attempt-1",
result: KycAttemptResult.APPROVED,
status: KycAttemptStatus.COMPLETED,
updatedAt: "2026-08-25T12:00:00.000Z"
},
profileId: "user-1",
subject: "individual"
}
);

expect(duplicateCheckTransaction).toBe(transaction);
expect(insertTransaction).toBe(transaction);
});

it("does not downgrade either row for a stale rejection after approval", async () => {
const { customer, kycCase } = setup(VerificationStatus.Approved, VerificationStatus.Approved, {
customerStatusExternal: "COMPLETED",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export async function updateAveniaKycOutcomeForCustomer(
// Queue only after the case binding was proven above. Enqueuing is idempotent on the
// attempt id, and a queue failure rolls back the terminal state so a later poll can
// retry the notification (parity with GET /v1/brla/kyb/attempt-status).
await enqueueVerificationNotification(appliedNotify.attempt, appliedNotify.profileId, appliedNotify.subject);
await enqueueVerificationNotification(appliedNotify.attempt, appliedNotify.profileId, appliedNotify.subject, transaction);
}
return lockedRecord;
});
Expand Down
29 changes: 17 additions & 12 deletions apps/api/src/api/services/avenia/verification-notifications.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AveniaVerificationAttempt, KycAttemptResult, KycAttemptStatus, KycFailureReason } from "@vortexfi/shared";
import type { Transaction } from "sequelize";
import { NotificationProvider, NotificationType } from "../../../models/emailNotification.model";
import { enqueueNotification } from "../email";
import { VerificationSubject } from "../email/types";
Expand Down Expand Up @@ -64,25 +65,29 @@ function terminalNotificationType(attempt: NotifiableAttempt): NotificationType
export async function enqueueVerificationNotification(
attempt: NotifiableAttempt,
userId: string,
subject: VerificationSubject
subject: VerificationSubject,
transaction?: Transaction
): Promise<boolean> {
const type = terminalNotificationType(attempt);
if (!type) {
return false;
}

await enqueueNotification({
payload: {
reason:
type === NotificationType.VerificationRejected ? (attempt.resultMessage?.slice(0, MAX_REASON_LENGTH) ?? null) : null,
subject,
updatedAt: attempt.updatedAt
await enqueueNotification(
{
payload: {
reason:
type === NotificationType.VerificationRejected ? (attempt.resultMessage?.slice(0, MAX_REASON_LENGTH) ?? null) : null,
subject,
updatedAt: attempt.updatedAt
},
provider: NotificationProvider.Avenia,
resourceId: attempt.id,
type,
userId
},
provider: NotificationProvider.Avenia,
resourceId: attempt.id,
type,
userId
});
transaction
);

return true;
}
10 changes: 7 additions & 3 deletions apps/api/src/api/services/email/notification.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { literal, Op } from "sequelize";
import { literal, Op, type Transaction } from "sequelize";
import sequelize from "../../../config/database";
import logger from "../../../config/logger";
import { config } from "../../../config/vars";
Expand Down Expand Up @@ -31,17 +31,21 @@ function describeKey({ provider, type, resourceId }: NotificationKey): string {
* Records a notification to be emailed. Idempotent on the notification key:
* enqueuing the same event twice is a no-op, so callers can fire without guarding.
*/
export async function enqueueNotification({ userId, payload, ...key }: EnqueueParams): Promise<void> {
export async function enqueueNotification(
{ userId, payload, ...key }: EnqueueParams,
transaction?: Transaction
): Promise<void> {
// Duplicates are the common case (webhook replays, re-polled attempts), so check the
// key before resolving the locale — that resolution is a Supabase admin API call.
if (await EmailNotification.findOne({ where: { ...key } })) {
if (await EmailNotification.findOne({ transaction, where: { ...key } })) {
return;
}

const locale = await SupabaseAuthService.getUserLocale(userId);

const [, created] = await EmailNotification.findOrCreate({
defaults: { ...key, locale, payload, userId },
transaction,
where: { ...key }
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { afterAll, describe, expect, it, mock } from "bun:test";
import { afterAll, afterEach, describe, expect, it, mock } from "bun:test";
import Big from "big.js";
import { BrlaApiService, EPaymentMethod, EvmToken, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared";
import {
BrlaApiService,
EPaymentMethod,
EvmToken,
type EvmNetworks,
FiatToken,
Networks,
RampDirection,
RampPhase
} from "@vortexfi/shared";
import { config } from "../../../../../config/vars";
import * as partnerPricingNamespace from "../../../partners/partner-pricing.service";

const partnerPricingReal = { ...partnerPricingNamespace };
const brlaApiServiceGetInstanceReal = BrlaApiService.getInstance;
let activePricing: Awaited<ReturnType<typeof partnerPricingNamespace.findPartnerWithPricing>> = null;

mock.module("../core/nabla", () => ({
calculateNablaSwapOutput: async () => {
Expand All @@ -27,7 +37,8 @@ mock.module("../core/squidrouter", () => ({
}),
getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({
networkFeeUSD: "0.1",
outputAmountDecimal: new Big(amountDecimal)
outputAmountDecimal: new Big(amountDecimal),
outputAmountUsd: new Big(amountDecimal)
}),
getBridgeTargetTokenDetails: () => ({
erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
Expand All @@ -42,9 +53,13 @@ mock.module("../../../priceFeed.service", () => ({
}));

mock.module("../../../partners/partner-pricing.service", () => ({
findPartnerWithPricing: async () => null
findPartnerWithPricing: async () => activePricing
}));

afterEach(() => {
activePricing = null;
});

afterAll(() => {
BrlaApiService.getInstance = brlaApiServiceGetInstanceReal;
mock.module("../../../partners/partner-pricing.service", () => ({ ...partnerPricingReal }));
Expand Down Expand Up @@ -167,7 +182,11 @@ describe("BRL cross-chain onramp flow compile-time adjacency", () => {
});
});

function buildCtx(includeDynamicFunding = true): PhaseCtx {
function buildCtx(
includeDynamicFunding = true,
to: EvmNetworks = Networks.Arbitrum,
outputCurrency: EvmToken = EvmToken.USDC
): PhaseCtx {
const notes: string[] = [];
return {
addNote: (note: string) => {
Expand All @@ -184,7 +203,7 @@ function buildCtx(includeDynamicFunding = true): PhaseCtx {
fundingGasLimit: "21000",
isNativeTransfer: false,
maximumFeePerGas: "1",
network: Networks.Arbitrum,
network: to,
programVersion: 2 as const,
transferGasLimit: "100000"
}
Expand All @@ -198,9 +217,9 @@ function buildCtx(includeDynamicFunding = true): PhaseCtx {
inputAmount: "100",
inputCurrency: FiatToken.BRL,
network: Networks.Base,
outputCurrency: EvmToken.USDC,
outputCurrency,
rampType: RampDirection.BUY,
to: Networks.Arbitrum
to
}
};
}
Expand Down Expand Up @@ -235,6 +254,47 @@ describe("BRL cross-chain onramp flow simulation", () => {
config.evmDestinationGas.dynamicFundingEnabled = originalEnabled;
}
});

it("keeps non-stable destination subsidy bounded in source USDC", async () => {
activePricing = {
displayName: "Vortex",
fiatCurrency: FiatToken.BRL,
id: "vortex-pricing",
logoUrl: null,
markupCurrency: EvmToken.USDC,
markupType: "none",
markupValue: 0,
maxDynamicDifference: 0,
maxSubsidy: 0.003,
minDynamicDifference: 0,
name: "vortex",
payoutAddressEvm: null,
payoutAddressSubstrate: null,
rampType: RampDirection.BUY,
targetDiscount: -0.0017,
vortexFeeType: "none",
vortexFeeValue: 0
};
BrlaApiService.getInstance = mock(() => ({
createPayInQuote: mock(async () => ({
appliedFees: [{ amount: "0.2", type: "Gas Fee" }],
outputAmount: "99",
quoteToken: "mock-quote-token"
}))
})) as unknown as typeof BrlaApiService.getInstance;

const flow = makeBrlOnrampBaseCrossChainFlow(Networks.Ethereum, EvmToken.ETH);
const { metadata } = await flow.simulate(buildCtx(true, Networks.Ethereum, EvmToken.ETH));
const subsidizePost = getBlockMetadata(metadata, SubsidizePostContext);
const oracleExpectedUsd = new Big("100").times("0.18").times(new Big(1).minus("0.0017"));

expect(subsidizePost.outputCurrency).toBe(EvmToken.USDC);
expect(Big(subsidizePost.expectedOutputAmountDecimal).lt(20)).toBe(true);
expect(Big(subsidizePost.subsidyAmountInOutputTokenDecimal).lte(oracleExpectedUsd.times("0.003"))).toBe(true);
const finalSettlement = getBlockMetadata(metadata, FinalSettlementSubsidyContext);
expect(finalSettlement.applied).toBe(false);
expect(Big(finalSettlement.expectedOutputAmountDecimal).eq(finalSettlement.actualOutputAmountDecimal)).toBe(true);
});
});

describe("BRL cross-chain onramp flow metadata ownership", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ mock.module("../core/squidrouter", () => ({
},
getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({
networkFeeUSD: "0.1",
outputAmountDecimal: new Big(amountDecimal)
outputAmountDecimal: new Big(amountDecimal),
outputAmountUsd: new Big(amountDecimal)
}),
getBridgeTargetTokenDetails: (token: EvmToken) => evmTokenConfig[Networks.Base][token]
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ mock.module("../core/squidrouter", () => ({
networkFeeUSD: "0.1",
outputTokenDecimals: 6
}),
getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({
networkFeeUSD: "0.1",
outputAmountDecimal: new Big(amountDecimal),
outputAmountUsd: new Big(amountDecimal)
}),
getBridgeTargetTokenDetails: () => ({
erc20AddressSourceChain: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ mock.module("../core/squidrouter", () => ({
outputTokenDecimals: token?.decimals ?? 6
};
},
getEvmBridgeQuote: async ({ amountDecimal }: { amountDecimal: string }) => ({
networkFeeUSD: "0.1",
outputAmountDecimal: new Big(amountDecimal),
outputAmountUsd: new Big(amountDecimal)
}),
getBridgeTargetTokenDetails: (token: EvmToken) => evmTokenConfig[Networks.Base][token]
}));

Expand Down
Loading
Loading