Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2b08984
feat(earn): add Blend deposit transaction encoding primitives
aristidesstaffieri Aug 17, 2026
a4c8274
feat(earn): add Blend API client, flow scaffolding, and Home entry point
aristidesstaffieri Aug 17, 2026
1385ebd
feat(earn): add the Choose Token screen
aristidesstaffieri Aug 17, 2026
37deb92
feat(earn): add the deposit amount screen
aristidesstaffieri Aug 17, 2026
90d0199
feat(earn): add the review sheet and deposit terminal
aristidesstaffieri Aug 17, 2026
3fa196f
feat(earn): add the swap-within-earn branch
aristidesstaffieri Aug 17, 2026
8665626
fix(earn): populate empty translations, add e2e spec and metric emits
aristidesstaffieri Aug 17, 2026
a5e935e
fix(earn): show the swap branch as a sheet and correct flow defects
aristidesstaffieri Aug 18, 2026
ee6cb04
fix(earn): resolve asset icons across the flow and align the review rows
aristidesstaffieri Aug 18, 2026
13a492a
fix(earn): align the deposit screens with Figma and pin the receive side
aristidesstaffieri Aug 18, 2026
e507aa9
feat(earn): instrument the deposit funnel and fix its mislabeled steps
aristidesstaffieri Aug 20, 2026
2d7f19e
fix(earn): stop holding XLM back from a deposit and explain the fee s…
aristidesstaffieri Aug 20, 2026
c7d7e83
refactor: hoist duplicated earn constants and helpers into shared files
aristidesstaffieri Aug 20, 2026
c9ceefb
fix(earn): extract the pool description for translation
aristidesstaffieri Aug 20, 2026
7d9e9e6
fix(earn): reset the deposit when the selected asset changes
aristidesstaffieri Aug 20, 2026
11d2b24
test(e2e): fix the earn deposit spec
aristidesstaffieri Aug 20, 2026
a2a7d25
fix(earn): sign deposits on hardware wallets and stop the resubmit loop
aristidesstaffieri Aug 21, 2026
c7484c3
Merge branch 'master' into feat/earn-deposit
aristidesstaffieri Aug 21, 2026
0e00205
fix(earn): check the resource fee on non-XLM deposits too
aristidesstaffieri Aug 21, 2026
58bc03e
fix(earn): await the position lookup with the deposit simulation
aristidesstaffieri Aug 21, 2026
c8415a4
fix(earn): report the real outcome of a deposit left in flight
aristidesstaffieri Aug 21, 2026
c2bbe61
fix(earn): stop labelling disabled reserves as accepted tokens
aristidesstaffieri Aug 21, 2026
6ab6418
feat(earn): rebuild the intro and token picker to the new designs
aristidesstaffieri Aug 24, 2026
3122393
fix(earn): treat closing the swap sheet after success as a completion
aristidesstaffieri Aug 24, 2026
af30894
test(earn): follow the token picker's redesign in the e2e suite
aristidesstaffieri Aug 24, 2026
b8d5ed4
fix(earn): repaint the deposit screen's APY ribbon to the new design
aristidesstaffieri Aug 24, 2026
1a2587d
Merge branch 'master' into feat/earn-deposit
aristidesstaffieri Aug 24, 2026
a03d32c
docs(earn): explain the SupplyCollateral request type at the call site
aristidesstaffieri Aug 25, 2026
db451df
fix(earn): send the internal stub, not a contract id, as Blockaid's url
aristidesstaffieri Aug 25, 2026
c60e904
fix(earn): fire the max-amount metric on the Max tap only
aristidesstaffieri Aug 25, 2026
55b1057
fix(earn): return early when the deposit submission is rejected
aristidesstaffieri Aug 25, 2026
92e5f56
fix(earn): surface the Blockaid verdict on the deposit review
aristidesstaffieri Aug 25, 2026
95619ff
fix(earn): match the review's Blockaid layout to the other review sc…
aristidesstaffieri Aug 25, 2026
1b4dd7d
Merge branch 'master' into feat/earn-deposit
aristidesstaffieri Aug 25, 2026
ab0953f
feat(earn): gate the home Earn tile behind the earn_deposit flag
aristidesstaffieri Aug 26, 2026
a22cfd8
refactor(earn): use the NO_FIAT_VALUE constant instead of "--" literals
aristidesstaffieri Aug 26, 2026
874a3b6
fix(earn): match the pool sheet description to the mocks, add the doc…
aristidesstaffieri Aug 26, 2026
e06f14f
fix(earn): align the pool details sheet with the mocks
aristidesstaffieri Aug 26, 2026
1d4d137
updates earn icon to new design icon
aristidesstaffieri Aug 26, 2026
7ac8fb8
reverts earn deposit flag from debug back to false default
aristidesstaffieri Aug 26, 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
387 changes: 387 additions & 0 deletions @shared/api/helpers/__tests__/blend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,387 @@
import {
getBlendEarnOptions,
getBlendPools,
getBlendSuppliedTokens,
} from "../blend";
import { sendMessageToBackground } from "../extensionMessaging";
import { SERVICE_TYPES } from "@shared/constants/services";
import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend";
import { PUBLIC_SACS } from "@shared/constants/sac";
import { NETWORKS } from "@shared/constants/stellar";

jest.mock("../extensionMessaging");
jest.mock("@sentry/browser", () => ({ captureException: jest.fn() }));

const mockedSend = sendMessageToBackground as jest.Mock;
const networkDetails = { network: "PUBLIC" } as never;

const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!;
const USDC_SAC = PUBLIC_SACS.USDC!;
const XLM_SAC = PUBLIC_SACS.XLM;
const PUBLIC_KEY = "GAX2VVWVHU5YQY5J3NJBXKHI3FFKZN54BE6GRJCWSIKSBZTQWJJNJMPC";

beforeEach(() => {
mockedSend.mockReset();
});

describe("getBlendEarnOptions", () => {
it("GETs earn-options with the network in the path", async () => {
// The query must live in `path` — authedFetch signs pathname + search, so a
// query appended anywhere downstream would break the JWT signature.
mockedSend.mockResolvedValue({
status: 200,
body: { data: { options: [] } },
});

await getBlendEarnOptions({ networkDetails });

expect(mockedSend).toHaveBeenCalledWith({
type: SERVICE_TYPES.FETCH_BACKEND_V2,
activePublicKey: null,
method: "GET",
path: "/protocols/blend/earn-options?network=PUBLIC",
});
});

it("maps snake_case wire fields to camelCase", async () => {
mockedSend.mockResolvedValue({
status: 200,
body: {
data: {
options: [
{
asset_id: USDC_SAC,
symbol: "USDC",
name: "USD Coin",
decimals: 7,
pools: [
{
id: POOL_ID,
name: "Fixed Pool v2",
supply_apy: 0.1694,
emissions_supply_apr: null,
supplied_usd: 50050000,
},
],
},
],
},
},
});

expect(await getBlendEarnOptions({ networkDetails })).toEqual([
{
assetId: USDC_SAC,
symbol: "USDC",
name: "USD Coin",
decimals: 7,
pools: [
{
id: POOL_ID,
name: "Fixed Pool v2",
supplyApy: 0.1694,
emissionsSupplyApr: null,
suppliedUsd: 50050000,
},
],
},
]);
});

it("preserves the null/zero distinction on rates", async () => {
// null means "no fresh oracle price"; 0 means the rate really is zero. The
// UI renders these differently, so coalescing would be a data bug.
mockedSend.mockResolvedValue({
status: 200,
body: {
data: {
options: [
{
asset_id: XLM_SAC,
symbol: "XLM",
name: null,
decimals: null,
pools: [
{
id: POOL_ID,
name: null,
supply_apy: 0,
emissions_supply_apr: null,
supplied_usd: null,
},
],
},
],
},
},
});

const [option] = await getBlendEarnOptions({ networkDetails });

expect(option.decimals).toBeNull();
expect(option.pools[0].supplyApy).toBe(0);
expect(option.pools[0].emissionsSupplyApr).toBeNull();
expect(option.pools[0].suppliedUsd).toBeNull();
});

it("throws on a non-200", async () => {
mockedSend.mockResolvedValue({ status: 500, body: { error: "upstream" } });

await expect(getBlendEarnOptions({ networkDetails })).rejects.toThrow();
});

it("throws on a 200 with no data payload", async () => {
mockedSend.mockResolvedValue({ status: 200, body: {} });

await expect(getBlendEarnOptions({ networkDetails })).rejects.toThrow();
});
});

describe("getBlendPools", () => {
it("GETs pools with the network in the path", async () => {
mockedSend.mockResolvedValue({
status: 200,
body: { data: { pools: [] } },
});

await getBlendPools({ networkDetails });

expect(mockedSend).toHaveBeenCalledWith({
type: SERVICE_TYPES.FETCH_BACKEND_V2,
activePublicKey: null,
method: "GET",
path: "/protocols/blend/pools?network=PUBLIC",
});
});

it("maps the pool and its reserves", async () => {
mockedSend.mockResolvedValue({
status: 200,
body: {
data: {
pools: [
{
id: POOL_ID,
name: "Fixed Pool v2",
status: "ACTIVE",
supplied_usd: 50050000,
borrowed_usd: 16150000,
interest_apy: 0.0424,
net_apy: 0.1694,
backstop_usd: 1530000,
reserves: [
{
asset_id: USDC_SAC,
symbol: "USDC",
name: "USD Coin",
decimals: 7,
enabled: true,
utilization: 0.32,
supply_apy: 0.0424,
borrow_apy: 0.09,
emissions_supply_apr: null,
supplied_usd: 50050000,
borrowed_usd: 16150000,
price_usd: 1,
},
],
},
],
},
},
});

const [pool] = await getBlendPools({ networkDetails });

expect(pool.interestApy).toBe(0.0424);
expect(pool.netApy).toBe(0.1694);
expect(pool.backstopUsd).toBe(1530000);
expect(pool.status).toBe("ACTIVE");
expect(pool.reserves[0]).toEqual({
assetId: USDC_SAC,
symbol: "USDC",
name: "USD Coin",
decimals: 7,
enabled: true,
utilization: 0.32,
supplyApy: 0.0424,
borrowApy: 0.09,
emissionsSupplyApr: null,
suppliedUsd: 50050000,
borrowedUsd: 16150000,
priceUsd: 1,
});
});

it.each([
["omitted", {}],
["null", { backstop_usd: null }],
])(
"maps a %s backstop to null rather than undefined or zero",
async (_label, backstop) => {
mockedSend.mockResolvedValue({
status: 200,
body: {
data: {
pools: [
{
id: POOL_ID,
name: "Fixed Pool v2",
status: "ACTIVE",
supplied_usd: 50050000,
borrowed_usd: 16150000,
interest_apy: 0.0424,
net_apy: 0.1694,
...backstop,
reserves: [],
},
],
},
},
});

const [pool] = await getBlendPools({ networkDetails });

expect(pool.backstopUsd).toBeNull();
},
);

it("throws on a non-200", async () => {
mockedSend.mockResolvedValue({ status: 503, body: {} });

await expect(getBlendPools({ networkDetails })).rejects.toThrow();
});
});

describe("getBlendSuppliedTokens", () => {
const buildBody = (supply: unknown[]) => ({
status: 200,
body: {
data: [
{
address: PUBLIC_KEY,
total_value_usd: 500.12,
net_apy: 0.1694,
positions: [
{
protocol: "BLEND",
id: POOL_ID,
name: "Fixed Pool v2",
net_usd: 500.12,
supplied_usd: 500.12,
borrowed_usd: 0,
net_apy: 0.1694,
blend: { supply, borrow: [] },
},
],
backstop: [],
},
],
},
});

const call = () =>
getBlendSuppliedTokens({
publicKey: PUBLIC_KEY,
poolId: POOL_ID,
assetId: USDC_SAC,
networkDetails,
});

it("POSTs the address batch with the network in the path", async () => {
mockedSend.mockResolvedValue({ status: 200, body: { data: [] } });

await call();

expect(mockedSend).toHaveBeenCalledWith({
type: SERVICE_TYPES.FETCH_BACKEND_V2,
activePublicKey: null,
method: "POST",
path: "/accounts/positions?network=PUBLIC",
body: JSON.stringify({ addresses: [PUBLIC_KEY] }),
});
});

it("reads total_tokens, not supplied_tokens", async () => {
// Deposits use SupplyCollateral, so the balance lands in collateral_tokens.
// Reading supplied_tokens here would always report zero.
mockedSend.mockResolvedValue(
buildBody([
{
asset_id: USDC_SAC,
supplied_tokens: "0",
collateral_tokens: "5000000000",
total_tokens: "5000000000",
},
]),
);

expect(await call()).toBe("5000000000");
});

it("returns 0 when the account has no position", async () => {
mockedSend.mockResolvedValue({ status: 200, body: { data: [] } });

expect(await call()).toBe("0");
});

it("returns 0 when the pool is present but the asset is not", async () => {
mockedSend.mockResolvedValue(
buildBody([
{
asset_id: XLM_SAC,
supplied_tokens: "0",
collateral_tokens: "3700000000",
total_tokens: "3700000000",
},
]),
);

expect(await call()).toBe("0");
});

it("returns 0 when the account holds positions only in another pool", async () => {
mockedSend.mockResolvedValue({
status: 200,
body: {
data: [
{
address: PUBLIC_KEY,
positions: [
{
protocol: "BLEND",
id: "COTHERPOOL",
blend: {
supply: [{ asset_id: USDC_SAC, total_tokens: "999" }],
borrow: [],
},
},
],
backstop: [],
},
],
},
});

expect(await call()).toBe("0");
});

it("returns 0 when the pool row carries no blend detail", async () => {
mockedSend.mockResolvedValue({
status: 200,
body: {
data: [
{ address: PUBLIC_KEY, positions: [{ id: POOL_ID }], backstop: [] },
],
},
});

expect(await call()).toBe("0");
});

it("throws on a non-200 so the caller can fall back", async () => {
mockedSend.mockResolvedValue({ status: 500, body: {} });

await expect(call()).rejects.toThrow();
});
});
Loading
Loading