Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
47 changes: 45 additions & 2 deletions apps/docs/operations/testnet-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Neither script requires manual `stellar contract invoke` steps — read them bef

Both scripts require a `DEPLOYER` secret key, funded via [Friendbot](https://friendbot.stellar.org/). `DEPLOYER` only pays transaction fees and signs the setup calls — it does **not** need to be kept around afterward, and can be thrown away once the script finishes.

`deploy-testnet.sh` additionally accepts an optional `ADMIN` **public key**. This becomes the deployed vault's permanent admin, the only address that can ever call `set_admin`, `set_paused`, `set_adapter`, or `migrate_adapter` on it. `ADMIN` is deliberately independent of `DEPLOYER`: whoever calls `initialize()` can pass in any address as the admin, since it's just a parameter, not tied to who signed the deploy transaction. If you don't set `ADMIN`, the script defaults it to `DEPLOYER`'s own address and prints a warning — fine for a quick throwaway test, but you should always set `ADMIN` explicitly to a separate, durable key for anything you intend to keep testing against, and it **must** be set explicitly ahead of any mainnet deployment.
`deploy-testnet.sh` additionally accepts an optional `ADMIN` **public key**. This becomes the deployed vault's permanent admin, the only address that can ever call `set_admin`, `set_paused`, `set_adapter`, or `migrate_adapter` on it. `ADMIN` is deliberately independent of `DEPLOYER` as an identity — but not as a _signer_: the vault's `initialize()` calls `admin.require_auth()`, so the deployed-with-`ADMIN`-set-separately case still needs a signature from `ADMIN` itself, not just from `DEPLOYER`. The script cannot provide that signature on your behalf (it never has access to `ADMIN`'s secret key, by design), so when `ADMIN` differs from `DEPLOYER` it builds the vault deployment up to that point and then prints the `initialize()` command for whoever holds the `ADMIN` key to run themselves. If you don't set `ADMIN`, the script defaults it to `DEPLOYER`'s own address and prints a warning — fine for a quick throwaway test (and the one case where the script _can_ fully automate `initialize()`, since `DEPLOYER`'s own signature already covers it), but you should always set `ADMIN` explicitly to a separate, durable key for anything you intend to keep testing against, and it **must** be set explicitly ahead of any mainnet deployment.

Save the `ADMIN` secret key somewhere durable (a password manager, not a plaintext file) the moment you deploy with it — there is no recovery path if it's lost. `set_admin`/`set_paused`/`set_adapter` become permanently inaccessible, and since adapters have no in-place upgrade path, that also means the vault can never be pointed at fixed adapter code again.

Expand All @@ -39,7 +39,7 @@ DEPLOYER=my-deployer ADMIN=$ADMIN_ADDR bash scripts/deploy-testnet.sh
This builds all three contract crates (`vault`, `blend-adapter`, `defindex-adapter`), uploads and deploys the vault and a `BlendAdapter`, deploys a fresh mUSDC Stellar Asset Contract, and wires everything together:

1. Initializes the `BlendAdapter` with the vault address, Blend's testnet pool, and USDC.
2. Initializes the vault with `admin`, `usdc`, `musdc`, and `adapter` (the just-deployed `BlendAdapter`).
2. Initializes the vault with `admin`, `usdc`, `musdc`, and `adapter` (the just-deployed `BlendAdapter`) — automatically if `ADMIN` was left to default to `DEPLOYER`, or by printing the command for the `ADMIN` key holder to run themselves if `ADMIN` is a separate address (see "The `DEPLOYER` / `ADMIN` split" above).
3. Sets the vault as mUSDC's admin, so it can mint/burn shares autonomously.

It prints the three contract IDs you need at the end:
Expand Down Expand Up @@ -116,6 +116,49 @@ This last step is deliberately manual. `set_adapter` resets the vault's adapter-

Blend's testnet pool uses USDC issued by Blend's own controlled test key, not Circle's testnet USDC — the two are different Stellar assets that happen to share an asset code. Fund a testnet wallet from [Blend's public faucet](https://testnet.blend.capital) or via its API endpoint (`fundFromBlendFaucet()` in `apps/web/src/hooks/useVaultActions.ts` calls this automatically when a depositing wallet has no USDC balance). In practice the default faucet call reliably grants BLND/wETH/wBTC but has not reliably granted USDC in testing — if a deposit fails with a missing-trustline or insufficient-balance error, you may need to fund the wallet directly through Blend's own faucet UI.

## Vault migration history

Adapter and vault contracts have no in-place upgrade path (see "Pushing new adapter code to a live vault" above for adapters; the vault itself is the same story). Shipping a vault-level change — new functionality, a bugfix — means a full cutover: deploy a new vault (+ new mUSDC), point `CONTRACT_ADDRESSES`/`KNOWN_POOLS` at it, and leave the old vault contract running, untouched, but unreachable through the app/docs from then on. This section is the durable record of each cutover: what the old address was, why it was superseded, and whether it still holds anything.

### 2026-08-20 — redeployed for `migrate_adapter` (#514)

The live testnet vault predated `migrate_adapter` (added in #464/#507, never on the live contract since — see #514 for the full writeup, including how `.github/workflows/verify-contract-addresses.yml`'s bytecode check caught it).

**Pre-cutover status, at the time this PR was opened:** the old vault below held `get_total_assets() = 200000000000` (20,000 USDC) in outstanding testnet deposits. If you hold a position there, **withdraw before this PR merges** — once merged, the app and `KNOWN_POOLS`/`CONTRACT_ADDRESSES` point at the new vault, and the old one is no longer reachable through the UI (though it keeps working, see below).

**Old vault (superseded):**

| Field | Value |
| ------------------- | ---------------------------------------------------------- |
| Vault contract | `CBQYEHWIRJWIPWCJFQZAOP3VAZHRWFGAUS5GZHWFDDYKMFHJ5S3YS2Q5` |
| mUSDC (share token) | `CBC5G4HXTOOZHTBCJQACZB3NJ636JHA5NEBQX5Q265QZN6XEG4LVZ5SB` |
| mUSDC issuer | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` |
| Admin | `GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD` |

This contract is not deleted or disabled — Soroban has no such operation, it keeps running exactly as deployed. `withdraw()` still works on it for anyone who already holds shares there:

```bash
stellar contract invoke --network testnet --source <your-key> \
--id CBQYEHWIRJWIPWCJFQZAOP3VAZHRWFGAUS5GZHWFDDYKMFHJ5S3YS2Q5 \
-- withdraw --caller <your-address> --shares <amount>
```

There is no automatic migration or sweep of old positions into the new vault — moving a position across a cutover is a manual withdraw-then-redeposit, not something the vault or its keepers do for you.

**New vault (current):**

| Field | Value |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Vault contract | `CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL` |
| Blend adapter | `CDFIDKNA2ZTB37I7RN32WH7VU5AP2PAOXLGFWMTW6T2RSUM23AJIV2YM` |
| mUSDC (share token) | `CCSYXC4SDCPTGENHM6CSQY4HMSZOPOY5TJW4QYYLE5RDBUBJX4N7ZHV5` |
| mUSDC issuer | `GBLYQ5EHXMMULOA7KA4KK2S5Q5GTTWYFVSC3FKLXRLH34EJX35BIAL35` |
| Admin | `GB74ZDVMBYMPKWBBVJ7TAN2QK2EAKQQ5OZO6ETYAMPN5VQVNLZSQUYHH` — a fresh, separate key generated for this deployment (not the deploying key), per "The `DEPLOYER` / `ADMIN` split" above. Its secret is currently held by this PR's author; rotate it via `set_admin` (no redeploy required) if maintainers want a different durable key in control long-term. |

Verified against #514's acceptance criteria before opening this PR: `migrate_adapter` is present in the deployed vault's function list (`stellar contract invoke ... -- --help`), `vault.get_adapter()` resolves to the Blend adapter above, and that adapter's `get_pool()`/`get_protocol()` resolve correctly — the same chain "Verifying the deployment" above walks through. Also confirmed the deployed vault's on-chain bytecode hash byte-for-byte against a from-source rebuild done on GitHub Actions itself (not a local machine — see the note below on why that distinction matters), matching what `.github/workflows/verify-contract-addresses.yml`'s "Verify On-Chain Bytecode" job independently rebuilds and checks.

**A note on reproducible builds:** `stellar contract build`'s WASM output is not guaranteed byte-identical across different `stellar-cli`/Rust toolchain versions, even from identical source — a newer `stellar-cli` can apply a different (or newly-default) optimization pass and pull in different `soroban-sdk` transitive versions, changing the compiled bytecode. `.github/workflows/verify-contract-addresses.yml` always rebuilds with whatever `stellar-cli` version `cargo install --locked stellar-cli` resolves to _at CI run time_, not a pinned version. If your local `stellar-cli` has drifted behind that (check with `stellar --version` against the [latest release](https://github.com/stellar/stellar-cli/releases)), a contract you deploy locally can genuinely mismatch what CI rebuilds and compares it against, independent of whether your source is correct. If in doubt, verify the WASM you're about to deploy was built with a `stellar-cli` at least as new as CI's, or build it in a CI job of your own (e.g. a throwaway `workflow_dispatch` job that uploads the built `.wasm` as an artifact) and deploy that exact artifact instead of a locally-built one.

## Run the signing flow end-to-end

With the contracts deployed and `known-pools.ts`/`constants.ts` updated:
Expand Down
13 changes: 10 additions & 3 deletions apps/web/e2e/withdraw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ test.describe("withdraw", () => {
// positions read is stubbed to seed one (see FAKE_POSITION above).
// Everything downstream is real: the withdraw tab, amount validation,
// and the POST /api/v1/tx/withdraw build against the real testnet vault
// contract, which correctly rejects it (InsufficientShares, the account
// holds no real shares) before signing is ever requested.
// contract, which correctly rejects it before signing is ever requested.
// Which contract error comes back depends on whether *anyone* has ever
// deposited into the live vault: InsufficientShares (#7) if the vault
// has other depositors and only this account holds none, or
// NoSharesOutstanding (#6) if the vault is empty of deposits entirely
// (see MeridianVault::withdraw's total_shares <= 0 check, which runs
// before the per-caller check). Either is the correct "nothing to
// withdraw" rejection this test cares about, so accept both rather than
// pin to whichever the live vault's deposit history happens to produce.
await page.route(`**/api/v1/positions/${TEST_ADDRESS}`, (route) =>
route.fulfill({ json: { positions: [FAKE_POSITION] } })
);
Expand All @@ -43,7 +50,7 @@ test.describe("withdraw", () => {
await page.getByTestId("vault-withdraw-submit").click();

await expect(
page.getByText("Simulation failed: HostError: Error(Contract, #7)")
page.getByText(/Simulation failed: HostError: Error\(Contract, #[67]\)/)
).toBeVisible({ timeout: 20_000 });

expect(await getSignedXdrs(page)).toHaveLength(0);
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/__tests__/hooks/useTrustlines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {
} from "../../hooks/useTrustlines";
import { useWalletStore } from "../../store/wallet";
import { useToastStore } from "../../store/toast";
import { USDC_ISSUER, MUSDC_ISSUER } from "@meridian/shared";

const KEY = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
const BLEND_TESTNET_USDC_ISSUER =
"GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56";
const MUSDC_TESTNET_ISSUER =
"GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD";
// Pulled from the source of truth rather than hardcoded, so these fixtures
// don't drift out of sync the next time the vault (and its mUSDC issuer) is
// redeployed, as happened with the previous hardcoded value in #514.
const BLEND_TESTNET_USDC_ISSUER = USDC_ISSUER.testnet;
const MUSDC_TESTNET_ISSUER = MUSDC_ISSUER.testnet;

vi.mock("../../lib/wallet", () => ({
wallet: {
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/__tests__/hooks/useVaultActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,14 @@ vi.mock("react-i18next", () => {

import { api } from "../../lib/api";
import { wallet } from "../../lib/wallet";
import { USDC_ISSUER, MUSDC_ISSUER } from "@meridian/shared";

const KEY = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
// Matches USDC_ISSUER.testnet / MUSDC_ISSUER.testnet in @meridian/shared.
const BLEND_TESTNET_USDC_ISSUER =
"GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56";
const MUSDC_TESTNET_ISSUER =
"GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD";
// Pulled from the source of truth rather than hardcoded, so these fixtures
// don't drift out of sync the next time the vault (and its mUSDC issuer) is
// redeployed, as happened with the previous hardcoded value in #514.
const BLEND_TESTNET_USDC_ISSUER = USDC_ISSUER.testnet;
const MUSDC_TESTNET_ISSUER = MUSDC_ISSUER.testnet;

function bothTrustlinesHorizonResponse() {
return new Response(
Expand Down
21 changes: 15 additions & 6 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ export const USDC_ISSUER: Record<string, string> = {
mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
};

// mUSDC is the vault's share token. Issuer = the musdc-issuer key used during
// deployment (see scripts/deploy-testnet.sh's ADMIN, which becomes the mUSDC
// asset's issuer before admin control is handed to the vault contract).
// mUSDC is the vault's share token. Issuer = the DEPLOYER address used for
// that deployment's `scripts/deploy-testnet.sh` run (the script mints mUSDC
// as `MUSDC:$DEPLOYER_ADDRESS`, then hands admin control of the asset to the
// vault contract via set_admin). Frozen at mint time: unlike the vault's own
// admin, a classic Stellar asset's issuer can't be rotated after the fact,
// so this changes only when mUSDC itself is redeployed (i.e. together with
// CONTRACT_ADDRESSES.testnet.{vault,musdc} on a vault redeployment).
export const MUSDC_ISSUER: Record<string, string> = {
testnet: "GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD",
testnet: "GBLYQ5EHXMMULOA7KA4KK2S5Q5GTTWYFVSC3FKLXRLH34EJX35BIAL35",
mainnet: "",
};

Expand All @@ -40,8 +44,13 @@ export const CONTRACT_ADDRESSES = {
usdc: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU",
// Stellar Asset Contract for Circle's testnet EURC (issuer: GB3Q6QDZYTHWT7...).
eurc: "CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ",
musdc: "CBC5G4HXTOOZHTBCJQACZB3NJ636JHA5NEBQX5Q265QZN6XEG4LVZ5SB",
vault: "CBQYEHWIRJWIPWCJFQZAOP3VAZHRWFGAUS5GZHWFDDYKMFHJ5S3YS2Q5",
musdc: "CCSYXC4SDCPTGENHM6CSQY4HMSZOPOY5TJW4QYYLE5RDBUBJX4N7ZHV5",
// Redeployed for #514: the previous vault (CBQYEHWIRJWIPWCJFQZAOP3VAZHRWFGAUS5GZHWFDDYKMFHJ5S3YS2Q5)
// predates `migrate_adapter` and was never redeployed since #464/#507
// added it. See apps/docs/operations/testnet-deployment.md's "Vault
// migration history" for the old address, why it's stale, and the
// pre-cutover withdrawal window for anyone still holding a position there.
vault: "CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL",
},
mainnet: {
blend: {
Expand Down
2 changes: 1 addition & 1 deletion packages/stellar-sdk-helpers/src/known-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const KNOWN_POOLS: {
name: "Meridian",
protocol: "meridian",
label: "USDC Vault",
contractId: "CBQYEHWIRJWIPWCJFQZAOP3VAZHRWFGAUS5GZHWFDDYKMFHJ5S3YS2Q5",
contractId: "CBOE7JPROCMUKQ4NJWPKCLBBQGHLTGV4X3463DHK4D7KX6KWXGZETAJL",
assetId: "CAQCFVLOBK5GIULPNZRGATJJMIZL5BSP7X5YJVMGCPTUEPFM4AVSRCJU",
asset: "USDC",
},
Expand Down
14 changes: 9 additions & 5 deletions packages/stellar-sdk-helpers/src/tx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
assertSubmittable,
} from "./tx";
import type { StellarNetwork } from "./types";
import { CONTRACT_ADDRESSES } from "@meridian/shared";
import { CONTRACT_ADDRESSES, MUSDC_ISSUER } from "@meridian/shared";

const { SUCCESS, FAILED, NOT_FOUND } = rpc.Api.GetTransactionStatus;

Expand Down Expand Up @@ -242,8 +242,10 @@ const TESTNET: StellarNetwork = {

const USDC_ISSUER_TESTNET =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR's own stated fix is only half-applied here: MUSDC_ISSUER_TESTNET below is converted to source from @meridian/shared, but USDC_ISSUER_TESTNET stays hardcoded right next to it, the exact pattern this PR exists to eliminate. USDC_ISSUER is already exported from @meridian/shared and used the same way elsewhere in this file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, that's half a fix. I only touched MUSDC_ISSUER_TESTNET because that's the one the redeployment broke, but leaving the literal for USDC_ISSUER_TESTNET sitting on the line above it is exactly the drift this PR is supposed to stop. Same duplicate exists again in the buildAddTrustlineTx via API block lower down at line 435.

Fixing both to import USDC_ISSUER from @meridian/shared and read USDC_ISSUER.testnet, same as the mUSDC one.

One I'm leaving as a literal on purpose: UNKNOWN_ISSUER at line 442 happens to hold the same value as USDC_ISSUER.mainnet, but its job in the test is to be a well-formed address that isn't on the testnet allowlist. Sourcing it from the mainnet constant would make the test read as though it cares about mainnet USDC, and it would silently stop testing anything if mainnet ever got added to the allowlist. Say the word if you'd rather it come from the constant anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7646eea. USDC_ISSUER is imported from @meridian/shared and both fixture blocks read USDC_ISSUER.testnet. UNKNOWN_ISSUER left as a literal for the reason above, happy to change it if you disagree.

"GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56";
const MUSDC_ISSUER_TESTNET =
"GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD";
// Pulled from the source of truth rather than hardcoded, so this fixture
// doesn't drift out of sync the next time the vault (and its mUSDC issuer)
// is redeployed, as happened with the previous hardcoded value in #514.
const MUSDC_ISSUER_TESTNET = MUSDC_ISSUER.testnet;

function makeBalance(
code: string,
Expand Down Expand Up @@ -432,8 +434,10 @@ describe("assertSubmittable", () => {
"CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75";
const USDC_ISSUER_TESTNET =
"GATALTGTWIOT6BUDBCZM3Q4OQ4BO2COLOAZ7IYSKPLC2PMSOPPGF5V56";
const MUSDC_ISSUER_TESTNET =
"GDZX7DOZMVEZJSWPDIZCTSCAKW4LBB3UGNWYAG5YTCBL4JPMUPAWWEUD";
// Pulled from the source of truth rather than hardcoded, so this fixture
// doesn't drift out of sync the next time the vault (and its mUSDC issuer)
// is redeployed, as happened with the previous hardcoded value in #514.
const MUSDC_ISSUER_TESTNET = MUSDC_ISSUER.testnet;
// Circle's mainnet USDC issuer: a validly-formed address that is not on the
// testnet allowlist.
const UNKNOWN_ISSUER =
Expand Down
Loading
Loading