Skip to content
Merged
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
79 changes: 55 additions & 24 deletions packages/sdk/src/vault-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,31 +145,29 @@ describe("convertAssetsToShares", () => {
);
});

it("round-trips: convertAssetsToShares then convertSharesToAssets ≈ original", () => {
const ta = 50_000_000n;
const ts = 45_000_000n;
const assets = 10_000_000n;
const shares = convertAssetsToShares(assets, ta, ts);
const backToAssets = convertSharesToAssets(shares, ta, ts);
// Both legs floor against the offset-inflated denominators, so the round
// trip can lose up to a stroop per leg.
expect(backToAssets).toBeLessThanOrEqual(assets);
expect(assets - backToAssets).toBeLessThanOrEqual(2n);
it("keeps the deposit offset when gross share conversion uses the raw ratio", () => {
const shares = convertAssetsToShares(10_000_000n, 50_000_000n, 45_000_000n);
expect(shares).toBe(9_000_019n);
expect(convertSharesToAssets(shares, 50_000_000n, 45_000_000n)).toBe(
10_000_021n
);
});
});

describe("convertSharesToAssets", () => {
it("empty vault: 1 share → 1 asset (offset cancels)", () => {
expect(convertSharesToAssets(1_000_000n, 0n, 0n)).toBe(1_000_000n);
it("returns zero when no shares are outstanding", () => {
expect(convertSharesToAssets(1_000_000n, 0n, 0n)).toBe(0n);
});

it("2x appreciation: 1 share → ~2 assets", () => {
// totalAssets = 20 USDC, totalSupply = 10 shares → 2:1
// assets = 10_000_000 * (20_000_000 + 1_000) / (10_000_000 + 1_000)
it("2x appreciation: shares receive their full gross proportional value", () => {
expect(convertSharesToAssets(10_000_000n, 20_000_000n, 10_000_000n)).toBe(
19_999_000n
20_000_000n
);
});

it("truncates fractional stroops when the vault has lost value", () => {
expect(convertSharesToAssets(3n, 5n, 7n)).toBe(2n);
});
});

describe("VIRTUAL_OFFSET", () => {
Expand Down Expand Up @@ -284,6 +282,42 @@ describe("VaultBase.withdraw", () => {
expect(shares).toEqual(nativeToScVal(5_000_000n, { type: "i128" }));
});

it.each([12_000_000_000n, 5_000_000_000n])(
"sizes maxWithdraw without the deposit offset for total assets %s",
async (totalAssets) => {
mockViewCalls({
get_position: 3_000_000_000n,
get_total_assets: totalAssets,
get_total_shares: 10_000_000_000n,
get_principal: 3_000_000_000n,
get_entry_time: 0n,
});
const vault = makeVault();
const amount = await vault.maxWithdraw(WALLET);
await vault.withdraw(amount, WALLET, WALLET);
const [, , op] = vi.mocked(prepareSorobanTx).mock.calls[0]!;
const shares = op
.body()
.invokeHostFunctionOp()
.hostFunction()
.invokeContract()
.args()[1]!;
expect(shares).toEqual(nativeToScVal(3_000_000_000n, { type: "i128" }));
}
);

it.each([
[0n, 0n],
[0n, 10n],
[10n, 0n],
])("rejects an empty withdrawal state (%s, %s)", async (assets, shares) => {
mockViewCalls({ get_total_assets: assets, get_total_shares: shares });
await expect(makeVault().withdraw(1n, WALLET, WALLET)).rejects.toThrow(
"vault has no redeemable assets or shares"
);
expect(prepareSorobanTx).not.toHaveBeenCalled();
});

it("throws when receiver differs from owner", async () => {
const vault = makeVault();
await expect(
Expand Down Expand Up @@ -476,10 +510,8 @@ describe("VaultBase.getPosition", () => {
expect(pos).not.toBeNull();
expect(pos!.vaultId).toBe(CONTRACT_ID);
expect(pos!.shares).toBe(10_000_000n);
// deposited = shares * (totalAssets + OFFSET) / (totalShares + OFFSET)
expect(pos!.deposited).toBe(11_999_800n);
// earned = deposited - principal = 11_999_800 - 10_000_000
expect(pos!.earned).toBe(1_999_800n);
expect(pos!.deposited).toBe(12_000_000n);
expect(pos!.earned).toBe(2_000_000n);
expect(pos!.principal).toBe(10_000_000n);
expect(pos!.entryTime).toBe(1_700_000_000);
});
Expand Down Expand Up @@ -509,7 +541,7 @@ describe("VaultBase.getPosition", () => {
const vault = makeVault();
const pos = await vault.getPosition(WALLET);
expect(pos!.earned).toBe(0n);
expect(pos!.deposited).toBe(8_000_199n);
expect(pos!.deposited).toBe(8_000_000n);
});
});

Expand Down Expand Up @@ -595,9 +627,8 @@ describe("VaultBase ERC-4626 view methods", () => {
get_total_shares: 10_000_000n,
});
const vault = makeVault();
// 5_000_000 * (20_000_000 + 1_000) / (10_000_000 + 1_000) (floor)
const assets = await vault.convertToAssets(5_000_000n);
expect(assets).toBe(9_999_500n);
expect(assets).toBe(10_000_000n);
});

describe("pause-aware ERC-4626 limits", () => {
Expand Down Expand Up @@ -779,6 +810,6 @@ describe("ERC-4626 inflation protection – deposit/withdraw math", () => {
150_000_000n,
100_000_000n
);
expect(assets).toBe(74_999_750n);
expect(assets).toBe(75_000_000n);
});
});
48 changes: 24 additions & 24 deletions packages/sdk/src/vault-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ export const VIRTUAL_OFFSET = 1_000n;
* Defaults to rounding *down*, matching the contract's deposit direction: a
* depositor is never credited more shares than their assets bought.
*
* Pass `"up"` when the share amount is being burned to release a requested
* amount of assets. Rounding down there would release slightly less than the
* requested USDC, so withdrawal sizing rounds up to the smallest share count
* whose payout covers the request.
* The virtual offset mirrors the contract's *deposit* pricing only. The
* contract's `withdraw` burns adapter shares with the raw ratio
* (`shares * total_adapter_shares / total_shares`, no offset), so this
* function is not the inverse of `convertSharesToAssets` and does not
* reproduce the on-chain withdrawal payout.
*
* virtualSupply = totalSupply + VIRTUAL_OFFSET
* virtualAssets = totalAssets + VIRTUAL_OFFSET
Expand All @@ -99,20 +100,20 @@ export function convertAssetsToShares(
}

/**
* Convert a share amount to assets using ERC-4626 virtual-offset math.
* Rounds *down* (floor division), matching the contract's withdrawal payout
* and ERC-4626's `previewRedeem`.
* Convert shares to gross position value using the raw assets-to-supply ratio.
* Rounds down in stroops, consistently with `computePosition` in
* `@meridian/stellar-sdk-helpers`. This is an estimate before performance fees;
* the adapter's separate share-redemption rounding can also reduce the payout.
*
* assets = shares * virtualAssets / virtualSupply
* assets = shares * totalAssets / totalSupply
*/
export function convertSharesToAssets(
shares: bigint,
totalAssets: bigint,
totalSupply: bigint
): bigint {
const virtualAssets = totalAssets + VIRTUAL_OFFSET;
const virtualSupply = totalSupply + VIRTUAL_OFFSET;
return (shares * virtualAssets) / virtualSupply;
if (totalSupply === 0n) return 0n;
return (shares * totalAssets) / totalSupply;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -233,8 +234,9 @@ export abstract class VaultBase {
* `owner` and pays USDC to that same account, so `receiver` must equal
* `owner` and a mismatch is rejected rather than silently ignored.
*
* The share amount is rounded up, so the payout covers `assets` rather than
* falling a stroop short of it.
* Shares are rounded up against the raw gross-value ratio, without the
* deposit pricing offset. Adapter rounding and performance fees can still
* reduce the net payout; use `minUsdcOut` to enforce a payout minimum.
*
* `minUsdcOut` is the contract's `min_usdc_out` slippage guard: the
* transaction reverts if the payout would be less than this amount. It
Expand Down Expand Up @@ -265,12 +267,10 @@ export abstract class VaultBase {
this.totalAssets(),
this.totalSupply(),
]);
const shares = convertAssetsToShares(
assets,
totalAssets,
totalSupply,
"up"
);
if (totalAssets <= 0n || totalSupply <= 0n) {
throw new Error("vault has no redeemable assets or shares");
}
const shares = (assets * totalSupply + totalAssets - 1n) / totalAssets;
return this.prepare(
this.contract().call(
"withdraw",
Expand Down Expand Up @@ -480,8 +480,8 @@ export abstract class VaultBase {
}

/**
* Convert a share amount to the equivalent assets at the current exchange
* rate, including ERC-4626 virtual-offset inflation protection.
* Convert a share amount to its estimated gross asset value at the current
* rate, before adapter rounding and performance fees.
* ERC-4626 `convertToAssets(uint256 shares)`.
*
* @param shares Share amount in stroops to convert
Expand Down Expand Up @@ -515,9 +515,9 @@ export abstract class VaultBase {
}

/**
* Maximum USDC that `owner` may withdraw in a single transaction: the full
* redemption value of their position. A pause does not restrict this, since
* the contract leaves withdrawals open while paused.
* Maximum estimated gross USDC value of `owner`'s full position. Adapter
* rounding and performance fees can reduce the amount actually received. A
* pause does not restrict withdrawals, since the contract leaves them open.
* ERC-4626 `maxWithdraw(address)`.
*
* @param owner Stellar G-address to check
Expand Down
53 changes: 53 additions & 0 deletions packages/stellar-sdk-helpers/src/positions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,56 @@ describe("computePosition with no recorded cost basis", () => {
expect(position?.earned).toBe(20);
});
});

describe("computePosition reports gross value with the raw ratio", () => {
// The vault burns shares * total_adapter_shares / total_shares adapter
// shares. Position value is estimated before adapter rounding and fees.
it("converts shares to assets with the raw ratio", () => {
// 300 shares of 1,000 total own 300 of 1,200 assets.
const [position] = computePosition(VAULT, {
shares: 300_0000000n,
totalShares: 1_000_0000000n,
totalAssets: 1_200_0000000n,
principal: 300_0000000n,
entryTime: 0n,
});
// 300 * 1200 / 1000 = 360 before fees and adapter rounding.
expect(position?.deposited).toBe(360);
expect(position?.earned).toBe(60);
});

it("preserves fractional USDC when totalAssets < totalShares", () => {
// Truncation applies to stroops, not whole USDC.
// 300 * 500 / 1000 = 150 exactly.
const [exact] = computePosition(VAULT, {
shares: 300_0000000n,
totalShares: 1_000_0000000n,
totalAssets: 500_0000000n,
principal: 300_0000000n,
entryTime: 0n,
});
expect(exact?.deposited).toBe(150);
expect(exact?.earned).toBe(0);

// 333 * 500 / 1000 = 166.5 USDC, with no fractional stroop.
const [rounded] = computePosition(VAULT, {
shares: 333_0000000n,
totalShares: 1_000_0000000n,
totalAssets: 500_0000000n,
principal: 333_0000000n,
entryTime: 0n,
});
expect(rounded?.deposited).toBe(166.5);
});

it("truncates a fractional stroop in the gross position value", () => {
const [position] = computePosition(VAULT, {
shares: 3n,
totalShares: 7n,
totalAssets: 10n,
principal: 3n,
entryTime: 0n,
});
expect(position?.deposited).toBe(0.0000004);
});
});
4 changes: 4 additions & 0 deletions packages/stellar-sdk-helpers/src/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export interface RawPosition {
* currentValue = shares * totalAssets / totalShares (value of the holding)
* earned = max(0, currentValue − principal) (yield accrued)
*
* The SDK uses the same raw ratio for gross position value. The vault burns
* `shares * total_adapter_shares / total_shares` adapter shares on withdrawal;
* adapter rounding and performance fees can reduce the actual payout.
*
* Returns an empty array when the address holds no shares.
*/
export function computePosition(
Expand Down
Loading