From 728b4064df395f4d66f9e9c22787e2defe821e15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:25:52 +0000 Subject: [PATCH] sync: Rise TypeScript v0.4.76 from phoenix 116538d25601 --- README.md | 3 +- instructions.json | 2 + .../close-position-and-withdraw/Cargo.lock | 16 +- .../cli/Cargo.lock | 12 +- programs/example-program/Cargo.lock | 16 +- programs/trader-onboarder/Cargo.lock | 16 +- test-fixtures/sdk-instruction-fixtures.json | 10 + ts/CHANGELOG.md | 25 + ts/README.md | 6 + ts/bun.lock | 10 +- ts/bunfig.toml | 8 +- ts/examples/06-flight-market-order.ts | 31 + ts/instructions.json | 3 + ts/package.json | 6 +- ts/scripts/build-all-ix.ts | 100 ++- ts/scripts/run-sdk-localnet-ci.ts | 13 + ts/src/_exports/public-sdk.ts | 16 + ts/src/_exports/public-types.ts | 2 + ts/src/api/orders/types.ts | 2 + ts/src/api/traders/traderState.ts | 18 + ts/src/builders.ts | 22 +- ts/src/core/constants.ts | 82 ++ ts/src/core/discriminants.ts | 3 + ts/src/core/permissionInstructions.ts | 1 + ts/src/flame.ts | 215 ++++- ts/src/flight/README.md | 101 ++- ts/src/flight/client.ts | 89 +- ts/src/flight/core/constants.ts | 2 + .../core/ixBuilders/ProxyInstruction/ix.ts | 32 +- .../core/ixBuilders/ProxyInstruction/types.ts | 11 + ts/src/flight/helper.ts | 8 +- ts/src/flight/pdas.ts | 35 + ts/src/flight/types.ts | 8 + ts/src/flows.ts | 157 +++- ts/src/ixs/client.ts | 31 +- ts/src/ixs/operations.ts | 59 +- ts/src/ixs/types.ts | 23 + ts/src/margin/compute.ts | 133 ++- ts/src/margin/draftOrders.ts | 428 ++++++++++ ts/src/margin/index.ts | 11 + ts/src/margin/normalize.ts | 6 +- ts/src/margin/snapshot.ts | 63 +- ts/src/margin/types.ts | 59 ++ ts/src/trader-state/manager.ts | 4 + ts/src/trader-state/types.ts | 2 + ts/src/types/index.ts | 2 + ts/src/types/trader.ts | 24 + .../sdk-instruction-fixtures.json | 10 + ts/tests/exchange-metadata-client.test.ts | 120 ++- ts/tests/flame.test.ts | 145 ++++ ts/tests/flight-routing.test.ts | 758 +++++++++++++++++- ts/tests/ixs.test.ts | 74 +- ts/tests/margin-draft-orders.test.ts | 434 ++++++++++ ts/tests/margin-spot-collateral.test.ts | 164 ++++ ts/tests/orders-client.test.ts | 31 + ts/tests/phoenix-address-overrides.test.ts | 56 ++ ts/tests/public-surface.manifest.ts | 11 + ts/tests/sdk-generated-fixtures.test.ts | 2 +- ts/tests/sdk-localnet-vm.test.ts | 469 ++++++++++- ts/tests/test-harness/localnet.ts | 48 +- 60 files changed, 4106 insertions(+), 142 deletions(-) create mode 100644 ts/src/margin/draftOrders.ts create mode 100644 ts/tests/margin-draft-orders.test.ts create mode 100644 ts/tests/margin-spot-collateral.test.ts diff --git a/README.md b/README.md index 9b83b6d..479d2c2 100644 --- a/README.md +++ b/README.md @@ -429,7 +429,8 @@ let routed_ixs = builder ) .await? .into_iter() - .map(|ix| flight.try_wrap_order_instruction(ix, trader_authority)) + // false = owner-signed (no position authority) + .map(|ix| flight.try_wrap_order_instruction(ix, trader_authority, false)) .collect::, _>>()?; ``` diff --git a/instructions.json b/instructions.json index 6b1df29..9327dcd 100644 --- a/instructions.json +++ b/instructions.json @@ -14,6 +14,8 @@ "FlightPlaceLimitOrderWithConditionals": "189a38686a0823075f2d44a8e8dad25c0100e80300000000000064000000000000000000000000000000000000000000000000000000000000000000000000010001004c040000000000004b040000000000000101010084030000000000008303000000000000", "FlightPlacePositionConditionalOrder": "189a38686a082307416c53814cc15c8f0000000001000100e803000000000000e7030000000000000001640000000000000000", "FlightPlaceStopLoss": "189a38686a082307ec2ea442eb5ca289e803000000000000e7030000000000000000000000000000010100", + "FlightProxyPlaceMarketOrderDelegated": "189a38686a082307a02315eb6eb6f54102010064000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "FlightProxyPlaceMarketOrderPositionAuthority": "189a38686a0823075a76c0fcc063279102010064000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "OnboardTraderDelegated": "cfaa11153523589706000000000101010301020104010501", "PlaceAttachedConditionalOrder": "2b7588804896657ae803000000000000000000000000000000000000000101010084030000000000008303000000000000", "PlaceLimitOrder": "6cb021ba92e501c50100e8030000000000006400000000000000000000000000000000000000000000000000000000", diff --git a/programs/close-position-and-withdraw/Cargo.lock b/programs/close-position-and-withdraw/Cargo.lock index b9627bc..3f28f1c 100644 --- a/programs/close-position-and-withdraw/Cargo.lock +++ b/programs/close-position-and-withdraw/Cargo.lock @@ -2409,7 +2409,7 @@ dependencies = [ [[package]] name = "phoenix-rise" -version = "0.3.4" +version = "0.3.6" dependencies = [ "phoenix-rise-accounts", "phoenix-rise-api", @@ -2421,7 +2421,7 @@ dependencies = [ [[package]] name = "phoenix-rise-accounts" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bs58", @@ -2435,7 +2435,7 @@ dependencies = [ [[package]] name = "phoenix-rise-api" -version = "0.3.4" +version = "0.3.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -2473,7 +2473,7 @@ dependencies = [ [[package]] name = "phoenix-rise-core" -version = "0.3.4" +version = "0.3.6" dependencies = [ "base64 0.22.1", "bytemuck", @@ -2494,7 +2494,7 @@ dependencies = [ [[package]] name = "phoenix-rise-ix" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -2510,7 +2510,7 @@ dependencies = [ [[package]] name = "phoenix-rise-litesvm-test" -version = "0.3.4" +version = "0.3.6" dependencies = [ "base64 0.22.1", "borsh", @@ -2533,7 +2533,7 @@ dependencies = [ [[package]] name = "phoenix-rise-math" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -2548,7 +2548,7 @@ dependencies = [ [[package]] name = "phoenix-rise-types" -version = "0.3.4" +version = "0.3.6" dependencies = [ "chrono", "phoenix-rise-math", diff --git a/programs/close-position-and-withdraw/cli/Cargo.lock b/programs/close-position-and-withdraw/cli/Cargo.lock index caa771b..176d8a5 100644 --- a/programs/close-position-and-withdraw/cli/Cargo.lock +++ b/programs/close-position-and-withdraw/cli/Cargo.lock @@ -1670,7 +1670,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phoenix-rise" -version = "0.3.4" +version = "0.3.6" dependencies = [ "phoenix-rise-accounts", "phoenix-rise-api", @@ -1681,7 +1681,7 @@ dependencies = [ [[package]] name = "phoenix-rise-accounts" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -1693,7 +1693,7 @@ dependencies = [ [[package]] name = "phoenix-rise-api" -version = "0.3.4" +version = "0.3.6" dependencies = [ "async-trait", "base64", @@ -1731,7 +1731,7 @@ dependencies = [ [[package]] name = "phoenix-rise-ix" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -1747,7 +1747,7 @@ dependencies = [ [[package]] name = "phoenix-rise-math" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -1762,7 +1762,7 @@ dependencies = [ [[package]] name = "phoenix-rise-types" -version = "0.3.4" +version = "0.3.6" dependencies = [ "chrono", "phoenix-rise-math", diff --git a/programs/example-program/Cargo.lock b/programs/example-program/Cargo.lock index c0fccbc..766d420 100644 --- a/programs/example-program/Cargo.lock +++ b/programs/example-program/Cargo.lock @@ -2409,7 +2409,7 @@ dependencies = [ [[package]] name = "phoenix-rise" -version = "0.3.4" +version = "0.3.6" dependencies = [ "phoenix-rise-accounts", "phoenix-rise-api", @@ -2421,7 +2421,7 @@ dependencies = [ [[package]] name = "phoenix-rise-accounts" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bs58", @@ -2435,7 +2435,7 @@ dependencies = [ [[package]] name = "phoenix-rise-api" -version = "0.3.4" +version = "0.3.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -2473,7 +2473,7 @@ dependencies = [ [[package]] name = "phoenix-rise-core" -version = "0.3.4" +version = "0.3.6" dependencies = [ "base64 0.22.1", "bytemuck", @@ -2494,7 +2494,7 @@ dependencies = [ [[package]] name = "phoenix-rise-ix" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -2510,7 +2510,7 @@ dependencies = [ [[package]] name = "phoenix-rise-litesvm-test" -version = "0.3.4" +version = "0.3.6" dependencies = [ "base64 0.22.1", "borsh", @@ -2533,7 +2533,7 @@ dependencies = [ [[package]] name = "phoenix-rise-math" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -2548,7 +2548,7 @@ dependencies = [ [[package]] name = "phoenix-rise-types" -version = "0.3.4" +version = "0.3.6" dependencies = [ "chrono", "phoenix-rise-math", diff --git a/programs/trader-onboarder/Cargo.lock b/programs/trader-onboarder/Cargo.lock index a08f4bd..deeb5f8 100644 --- a/programs/trader-onboarder/Cargo.lock +++ b/programs/trader-onboarder/Cargo.lock @@ -2409,7 +2409,7 @@ dependencies = [ [[package]] name = "phoenix-rise" -version = "0.3.4" +version = "0.3.6" dependencies = [ "phoenix-rise-accounts", "phoenix-rise-api", @@ -2421,7 +2421,7 @@ dependencies = [ [[package]] name = "phoenix-rise-accounts" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bs58", @@ -2435,7 +2435,7 @@ dependencies = [ [[package]] name = "phoenix-rise-api" -version = "0.3.4" +version = "0.3.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -2473,7 +2473,7 @@ dependencies = [ [[package]] name = "phoenix-rise-core" -version = "0.3.4" +version = "0.3.6" dependencies = [ "base64 0.22.1", "bytemuck", @@ -2494,7 +2494,7 @@ dependencies = [ [[package]] name = "phoenix-rise-ix" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -2510,7 +2510,7 @@ dependencies = [ [[package]] name = "phoenix-rise-litesvm-test" -version = "0.3.4" +version = "0.3.6" dependencies = [ "base64 0.22.1", "borsh", @@ -2533,7 +2533,7 @@ dependencies = [ [[package]] name = "phoenix-rise-math" -version = "0.3.4" +version = "0.3.6" dependencies = [ "borsh", "bytemuck", @@ -2548,7 +2548,7 @@ dependencies = [ [[package]] name = "phoenix-rise-types" -version = "0.3.4" +version = "0.3.6" dependencies = [ "chrono", "phoenix-rise-math", diff --git a/test-fixtures/sdk-instruction-fixtures.json b/test-fixtures/sdk-instruction-fixtures.json index 8d3e7e6..942a11b 100644 --- a/test-fixtures/sdk-instruction-fixtures.json +++ b/test-fixtures/sdk-instruction-fixtures.json @@ -291,6 +291,16 @@ "discriminatorHex": "9da33f1bf248fb61", "discriminatorBase64": "naM/G/JI+2E=" }, + { + "program": "phoenix-eternal", + "enumName": "PhoenixInstruction", + "instructionName": "AuthorizedTransferCollateral", + "snakeCaseName": "authorized_transfer_collateral", + "preimage": "global:authorized_transfer_collateral", + "tag": "2700316393894958814", + "discriminatorHex": "ded6450f74737925", + "discriminatorBase64": "3tZFD3RzeSU=" + }, { "program": "phoenix-eternal", "enumName": "PhoenixInstruction", diff --git a/ts/CHANGELOG.md b/ts/CHANGELOG.md index c994923..9ebb6df 100644 --- a/ts/CHANGELOG.md +++ b/ts/CHANGELOG.md @@ -3,6 +3,31 @@ Entries are drafted by Phoenix Rise sync PRs. Review and edit each entry in this repo before merging. +## v0.4.76 - 2026-07-31 + +Source Phoenix commit: `116538d2560135ebd0d7d11fbb8804d32db043d8` + +### Summary + +- Flight order wrapping gains position-authority (delegate-signed) support: `tryWrapFlightInstruction` is renamed to `tryWrapOrderInstruction` and now takes a `usePositionAuthority` flag so Flight can append the collateral-transfer tail and collect its builder fee when a delegate, not the trader owner, signs — including for `PlaceMarketOrderDelegated`. +- Added `buildFlameAtomicDepositFlow` (plus `buildFlameDepositToPhoenixIx`, `deriveFlameDepositToPhoenixAddresses`, `deriveFlameGlobalStateAddress`) for atomic, sponsor-cranked Flame deposits so wallets holding no SOL can deposit in one bundled transaction. +- Added spot collateral support: new `spotCollaterals` fields on `SubaccountMarginInputs`, `TraderView`, and trader-state snapshot/delta types, plus new margin totals (`spotCollateralNotionalQuoteLots`, `spotCollateralDiscountedQuoteLots`). +- Added draft-order margin helpers (`computeDraftOrderMarginRequirementFromInputs`/`FromSnapshot`, `computeMaxDraftOrderSizeForAvailableMarginFromInputs`/`FromSnapshot`) for pre-trade margin estimation. +- Added `resolvePhoenixBuilderAddresses`, `BETA_USDC_MINT_ADDRESS`, `EMBER_STATE_ADDRESS`, and `DEPOSIT_PERMISSION` exports, and a `minBaseLotsToFill` field on isolated market order requests. + +### Breaking Changes + +- `tryWrapFlightInstruction(instruction, authority)` is renamed to `tryWrapOrderInstruction(instruction, signer, usePositionAuthority?)` on the Flight client; update call sites, and pass `usePositionAuthority: true` when the signer is a delegate rather than the trader owner. +- `wrapInstructionWithFlight`'s `authority` param is renamed to `signer`; it now also accepts `usePositionAuthority`/`resolveRootAuthority`, and calling it with `usePositionAuthority: true` but no `resolveRootAuthority` now throws. +- `PhoenixIxOperationContext.maybeWrapOrderIx` gained an optional third `usePositionAuthority` parameter, and the interface now requires a new `maybeWrapConditionalOrderIx` method — custom implementations of this interface need updating. +- Position-authority Flight proxy instructions (`ProxyInstructionParams`/`buildProxyInstructionIx`) now append two extra trailing accounts (collateral-transfer authority + permission account) when `rootAuthority` is set; code that indexes into the proxy instruction's account list positionally must account for this. + +### Consumer Notes + +- `ClientPlaceMarketOrderDelegatedInput.traderWallet` is deprecated in favor of `positionAuthority`, now the shared way to declare a delegate signer across all order builders — the effective signer is `positionAuthority ?? authority`, and high-level `client.ixs` methods pick the position-authority Flight path automatically when they differ. +- `buildFlameAtomicDepositFlow` currently only supports `traderSubaccountIndex` 0 and throws for any other value. +- The new `spotCollaterals` fields on `SubaccountMarginInputs`, `TraderView`, and trader-state types are optional and default to empty, so existing integrations keep compiling without changes. + ## v0.4.67 - 2026-07-09 Source Phoenix commit: `39520ccb1d19d0f7610909dd2718dc7918d0ec22` diff --git a/ts/README.md b/ts/README.md index d08ecc4..bfbed14 100644 --- a/ts/README.md +++ b/ts/README.md @@ -326,6 +326,12 @@ USDC from the user's wallet ATA into that proxy ATA. They do not perform the Phoenix collateral deposit directly; the Flame crank/indexer completes that asynchronously after the proxy account is funded. +For an atomic sponsored deposit, use `buildFlameAtomicDepositFlow`. It adds +permission setup plus the Flame `DepositToPhoenix` instruction. The user signs +the deposit permission; the sponsor fee payer cranks the deposit so wallets +holding no SOL can deposit (the crank fronts rent for the transient proxy +Phoenix ATA and the same instruction refunds it on close). + `buildDepositFlow` remains the direct Ember + Phoenix deposit path. ## Sync Order Packet Builders diff --git a/ts/bun.lock b/ts/bun.lock index d029b54..e8fc8a8 100644 --- a/ts/bun.lock +++ b/ts/bun.lock @@ -31,12 +31,12 @@ }, }, "overrides": { - "brace-expansion": ">=5.0.6", + "brace-expansion": ">=5.0.8", "defu": "6.1.6", "flatted": "3.4.2", "minimatch": ">=10.2.3", "picomatch": ">=4.0.4", - "postcss": ">=8.5.10", + "postcss": ">=8.5.12", "vite": "8.0.16", "ws": ">=8.21.0", }, @@ -319,7 +319,7 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], @@ -565,7 +565,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -599,7 +599,7 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], - "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], "postcss-values-parser": ["postcss-values-parser@6.0.2", "", { "dependencies": { "color-name": "^1.1.4", "is-url-superb": "^4.0.0", "quote-unquote": "^1.0.0" }, "peerDependencies": { "postcss": "^8.2.9" } }, "sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw=="], diff --git a/ts/bunfig.toml b/ts/bunfig.toml index 0405b02..14bdb15 100644 --- a/ts/bunfig.toml +++ b/ts/bunfig.toml @@ -1,4 +1,10 @@ [install] minimumReleaseAge = 604800 -minimumReleaseAgeExcludes = ["@types/node", "typescript", "flatted", "vite"] +minimumReleaseAgeExcludes = [ + "@types/node", + "typescript", + "brace-expansion", + "flatted", + "vite", +] diff --git a/ts/examples/06-flight-market-order.ts b/ts/examples/06-flight-market-order.ts index 0c21dd8..c954acc 100644 --- a/ts/examples/06-flight-market-order.ts +++ b/ts/examples/06-flight-market-order.ts @@ -6,6 +6,21 @@ * * Run with: * bun examples/06-flight-market-order.ts [PRICE_LIMIT_TICKS] + * + * By default the order is owner-signed: TRADER_AUTHORITY is both the trader + * account owner and the wallet that signs. To build the delegate-signed + * variant instead, set POSITION_AUTHORITY to the delegate wallet: + * + * POSITION_AUTHORITY= bun examples/06-flight-market-order.ts ... + * + * Use it when the transaction is signed by the trader's position authority + * (a delegate key the owner authorized on-chain), not by the wallet owner. + * The trader account still derives from TRADER_AUTHORITY; passing the + * delegate as `positionAuthority` routes the wrap through the Flight + * position-authority path, which appends the collateral-transfer tail so the + * builder fee can be collected. The permission account in that tail is + * derived from the Phoenix root authority, resolved automatically from the + * exchange snapshot. */ import { @@ -29,6 +44,10 @@ const [ priceLimitTicksArg, ] = process.argv.slice(2); +// Optional delegate wallet that signs instead of the owner. When unset the +// order is owner-signed (the common case). +const positionAuthoritySigner = process.env.POSITION_AUTHORITY; + if ( !builderAuthority || !traderAuthority || @@ -107,8 +126,18 @@ async function main() { cancelExisting: false, }; + // `authority` is always the trader account owner — the trader PDA + // derives from it. When POSITION_AUTHORITY is set, the delegate becomes + // the wallet on the instruction (the effective signer is + // `positionAuthority ?? authority`) and the wrap automatically takes the + // Flight position-authority path because the signer differs from the + // owner: the collateral-transfer tail is appended, with the permission + // account derived from the root authority in the exchange snapshot. const ix = await client.ixs.placeMarketOrder({ authority: traderAuthority as Authority, + ...(positionAuthoritySigner + ? { positionAuthority: positionAuthoritySigner as Authority } + : {}), symbol: marketSymbol, orderPacket, }); @@ -116,6 +145,8 @@ async function main() { console.log({ builderAuthority, traderAuthority, + signer: positionAuthoritySigner ?? traderAuthority, + signerKind: positionAuthoritySigner ? "position-authority" : "owner", requestedSymbol, marketSymbol, side: Side[side], diff --git a/ts/instructions.json b/ts/instructions.json index a958cfb..9327dcd 100644 --- a/ts/instructions.json +++ b/ts/instructions.json @@ -6,6 +6,7 @@ "CancelUpTo": "1ad1f4fd3bafe3360101020000000000000001e803000000000000", "CreateConditionalOrdersAccount": "c906703de70f39f820", "DeactivateSpline": "00fe1664eec576b5", + "DelegateTrader": "03a4c06a775b7ae0", "DepositFunds": "ca2734d33514fa580100000000000000", "EmberDeposit": "f223c68952e1f2b60100000000000000", "EmberWithdraw": "b712469c946da122010100000000000000", @@ -13,6 +14,8 @@ "FlightPlaceLimitOrderWithConditionals": "189a38686a0823075f2d44a8e8dad25c0100e80300000000000064000000000000000000000000000000000000000000000000000000000000000000000000010001004c040000000000004b040000000000000101010084030000000000008303000000000000", "FlightPlacePositionConditionalOrder": "189a38686a082307416c53814cc15c8f0000000001000100e803000000000000e7030000000000000001640000000000000000", "FlightPlaceStopLoss": "189a38686a082307ec2ea442eb5ca289e803000000000000e7030000000000000000000000000000010100", + "FlightProxyPlaceMarketOrderDelegated": "189a38686a082307a02315eb6eb6f54102010064000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "FlightProxyPlaceMarketOrderPositionAuthority": "189a38686a0823075a76c0fcc063279102010064000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "OnboardTraderDelegated": "cfaa11153523589706000000000101010301020104010501", "PlaceAttachedConditionalOrder": "2b7588804896657ae803000000000000000000000000000000000000000101010084030000000000008303000000000000", "PlaceLimitOrder": "6cb021ba92e501c50100e8030000000000006400000000000000000000000000000000000000000000000000000000", diff --git a/ts/package.json b/ts/package.json index d533e54..afc61ad 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "@ellipsis-labs/rise", - "version": "0.4.67", + "version": "0.4.76", "packageManager": "bun@1.3.13", "repository": { "type": "git", @@ -69,11 +69,11 @@ "zustand": "^5.0.12" }, "overrides": { - "brace-expansion": ">=5.0.6", + "brace-expansion": ">=5.0.8", "defu": "6.1.6", "flatted": "3.4.2", "minimatch": ">=10.2.3", - "postcss": ">=8.5.10", + "postcss": ">=8.5.12", "picomatch": ">=4.0.4", "vite": "8.0.16", "ws": ">=8.21.0" diff --git a/ts/scripts/build-all-ix.ts b/ts/scripts/build-all-ix.ts index aa29b07..c453e31 100644 --- a/ts/scripts/build-all-ix.ts +++ b/ts/scripts/build-all-ix.ts @@ -43,6 +43,7 @@ import { Direction, StopLossOrderKind, flight, + getPhoenixTraderSubaccountAddress, PHOENIX_PROGRAM_ADDRESS, PHOENIX_LOG_AUTHORITY_ADDRESS, PHOENIX_GLOBAL_CONFIGURATION_ADDRESS, @@ -635,7 +636,7 @@ try { executionDirection: Direction.LessThan, orderKind: StopLossOrderKind.IOC, }); - const wrappedStopLossIx = await flightClient.tryWrapFlightInstruction( + const wrappedStopLossIx = await flightClient.tryWrapOrderInstruction( stopLossIx, p(0) ); @@ -664,7 +665,7 @@ try { sizePercent: null, }); const wrappedPositionConditionalIx = - await flightClient.tryWrapFlightInstruction(positionConditionalIx, p(0)); + await flightClient.tryWrapOrderInstruction(positionConditionalIx, p(0)); results["FlightPlacePositionConditionalOrder"] = hexEncode( wrappedPositionConditionalIx.data ); @@ -692,7 +693,7 @@ try { }, }); const wrappedAttachedConditionalIx = - await flightClient.tryWrapFlightInstruction(attachedConditionalIx, p(0)); + await flightClient.tryWrapOrderInstruction(attachedConditionalIx, p(0)); results["FlightPlaceAttachedConditionalOrder"] = hexEncode( wrappedAttachedConditionalIx.data ); @@ -736,10 +737,7 @@ try { }, }); const wrappedLimitWithConditionalsIx = - await flightClient.tryWrapFlightInstruction( - limitWithConditionalsIx, - p(0) - ); + await flightClient.tryWrapOrderInstruction(limitWithConditionalsIx, p(0)); results["FlightPlaceLimitOrderWithConditionals"] = hexEncode( wrappedLimitWithConditionalsIx.data ); @@ -916,6 +914,94 @@ try { results["DelegateTrader"] = hexEncode(ix.data); } + // 33. Flight ProxyInstruction wrapping PlaceMarketOrderDelegated signed by + // a secondary position authority (distinct delegate wallet plus a fixed + // permission account), with the collateral-transfer tail appended via + // `rootAuthority`. + { + console.error("Building FlightProxyPlaceMarketOrderDelegated..."); + const inner = buildPlaceMarketOrderDelegatedIx({ + traderWallet: p(9), + permissionAccount: p(10), + traderAccount: p(1), + perpAssetMap: p(2), + orderbook: p(3), + splineCollection: p(4), + globalTraderIndex: vec2(5, 6), + activeTraderBuffer: vec2(7, 8), + orderPacket: { + side: Side.Ask, + priceInTicks: null, + numBaseLots: baseLots(100n), + numQuoteLots: null, + minBaseLotsToFill: baseLots(0n), + minQuoteLotsToFill: quoteLots(0n), + selfTradeBehavior: SelfTradeBehavior.Abort, + matchLimit: null, + clientOrderId: 0n, + lastValidSlot: null, + orderFlags: OrderFlags.None, + cancelExisting: false, + }, + }); + + const ix = await flight.buildProxyInstructionIx({ + builderAuthority: p(11), + builderTraderAccount: p(12), + traderWallet: p(9), + rootAuthority: p(0), + innerInstruction: inner, + }); + results["FlightProxyPlaceMarketOrderDelegated"] = hexEncode(ix.data); + } + + // 34. Flight ProxyInstruction wrapping a plain PlaceMarketOrder whose + // trader wallet is the trader's position authority (the delegate signs; + // the trader account is derived from the owner), with the + // collateral-transfer tail appended via `rootAuthority`. + { + console.error("Building FlightProxyPlaceMarketOrderPositionAuthority..."); + const ownerTraderAccount = await getPhoenixTraderSubaccountAddress({ + authority: p(0), + traderPdaIndex: 0, + subaccountIndex: 0, + }); + const inner = buildPlaceMarketOrderIx({ + trader: p(9), + traderAccount: ownerTraderAccount, + perpAssetMap: p(2), + orderbook: p(3), + splineCollection: p(4), + globalTraderIndex: vec2(5, 6), + activeTraderBuffer: vec2(7, 8), + orderPacket: { + side: Side.Ask, + priceInTicks: null, + numBaseLots: baseLots(100n), + numQuoteLots: null, + minBaseLotsToFill: baseLots(0n), + minQuoteLotsToFill: quoteLots(0n), + selfTradeBehavior: SelfTradeBehavior.Abort, + matchLimit: null, + clientOrderId: 0n, + lastValidSlot: null, + orderFlags: OrderFlags.None, + cancelExisting: false, + }, + }); + + const ix = await flight.buildProxyInstructionIx({ + builderAuthority: p(11), + builderTraderAccount: p(12), + traderWallet: p(9), + rootAuthority: p(0), + innerInstruction: inner, + }); + results["FlightProxyPlaceMarketOrderPositionAuthority"] = hexEncode( + ix.data + ); + } + // Output JSON in sorted order const json = JSON.stringify( Object.fromEntries( diff --git a/ts/scripts/run-sdk-localnet-ci.ts b/ts/scripts/run-sdk-localnet-ci.ts index ae075b0..26eeb24 100644 --- a/ts/scripts/run-sdk-localnet-ci.ts +++ b/ts/scripts/run-sdk-localnet-ci.ts @@ -9,6 +9,7 @@ type Target = { const FLOW_SUITE = "SDK localnet common flows"; const VM_SUITE = "SDK localnet VM harness"; +const FLIGHT_SUITE = "Flight delegated market order proxy"; const targets: readonly Target[] = [ { @@ -28,6 +29,18 @@ const targets: readonly Target[] = [ expectedPassed: 1, fullTestName: `${VM_SUITE} builds and executes SDK deposit and withdraw instructions`, }, + { + file: "tests/sdk-localnet-vm.test.ts", + label: "SDK localnet VM flight delegated proxy fee", + expectedPassed: 1, + fullTestName: `${FLIGHT_SUITE} flight proxy collects builder fee for delegated market order signed by a secondary position authority`, + }, + { + file: "tests/sdk-localnet-vm.test.ts", + label: "SDK localnet VM flight delegated proxy missing tail", + expectedPassed: 1, + fullTestName: `${FLIGHT_SUITE} flight proxy rejects delegated market order missing the collateral transfer tail`, + }, ...[ "deposits collateral", "registers a trader subaccount and syncs parent capabilities", diff --git a/ts/src/_exports/public-sdk.ts b/ts/src/_exports/public-sdk.ts index ebc9669..9e99273 100644 --- a/ts/src/_exports/public-sdk.ts +++ b/ts/src/_exports/public-sdk.ts @@ -16,14 +16,19 @@ export { type PhoenixInstructionAddressSource, type ResolvePhoenixInstructionAddressesInput, USDC_MINT_ADDRESS, + BETA_USDC_MINT_ADDRESS, + EMBER_STATE_ADDRESS, SPL_TOKEN_PROGRAM_ADDRESS, SPL_ATA_PROGRAM_ADDRESS, SYSTEM_PROGRAM_ADDRESS, EMBER_PROGRAM_ADDRESS, + type PhoenixBuilderAddressDefaults, + type ResolvePhoenixBuilderAddressesInput, clientPhoenixInstructionAddresses, getPhoenixInstructionAddresses, getPhoenixProgramAddress, phoenixInstructionAddresses, + resolvePhoenixBuilderAddresses, resolvePhoenixEnv, resolvePhoenixInstructionAddresses, } from "../core/constants"; @@ -272,11 +277,17 @@ export { export { FLAME_PROGRAM_ADDRESS, + buildFlameDepositToPhoenixIx, + deriveFlameDepositToPhoenixAddresses, deriveFlameDepositAddress, deriveFlameDepositAddresses, + deriveFlameGlobalStateAddress, deriveFlameProxyAuthorityAddress, type FlameDepositAddressInput, type FlameDepositAddresses, + type FlameDepositToPhoenixAddresses, + type FlameDepositToPhoenixIx, + type FlameDepositToPhoenixParams, type FlameProxyAuthorityAddressInput, type ResolveFlameAddressesInput, } from "../flame"; @@ -295,6 +306,7 @@ export { getSetPermissionParamsCodec, getSetPermissionParamsDecoder, getSetPermissionParamsEncoder, + DEPOSIT_PERMISSION, TRADER_MANAGEMENT_PERMISSION, TRADER_ONBOARDING_PERMISSION, type CreatePermissionAccounts, @@ -354,6 +366,7 @@ export { export { buildDepositFlow, + buildFlameAtomicDepositFlow, buildFlameDepositFundingFlow, buildGrantEscrowPermissionFlow, buildPlaceLimitOrderFlow, @@ -364,6 +377,9 @@ export { type DepositFlowInstructions, type DepositFlowParams, type DepositFlowResult, + type FlameAtomicDepositFlowInstructions, + type FlameAtomicDepositFlowParams, + type FlameAtomicDepositFlowResult, type FlameDepositFundingFlowInstructions, type FlameDepositFundingFlowParams, type FlameDepositFundingFlowResult, diff --git a/ts/src/_exports/public-types.ts b/ts/src/_exports/public-types.ts index 68615be..9874429 100644 --- a/ts/src/_exports/public-types.ts +++ b/ts/src/_exports/public-types.ts @@ -8,6 +8,8 @@ export { type CapabilityAccess, type TraderCapabilities, TraderCapabilitiesSchema, + type SpotCollateralBalance, + SpotCollateralBalanceSchema, type TraderView, TraderViewSchema, type MarketUnits, diff --git a/ts/src/api/orders/types.ts b/ts/src/api/orders/types.ts index c00205f..882b7e8 100644 --- a/ts/src/api/orders/types.ts +++ b/ts/src/api/orders/types.ts @@ -570,6 +570,7 @@ export interface PlaceIsolatedMarketOrderRequest { symbol: string; side: string; numBaseLots?: number; + minBaseLotsToFill?: number; quantity?: number; transferAmount?: number; maxPriceInTicks?: number; @@ -590,6 +591,7 @@ export const PlaceIsolatedMarketOrderRequestSchema: z.ZodType = + z.object({ + assetIndex: z.number(), + symbol: z.string(), + balance: z.string(), + }); + const TraderStateSubaccountSnapshotSchema: z.ZodType = z.object({ subaccountIndex: z.number(), sequence: z.number(), collateral: z.string(), + spotCollaterals: z.array(TraderStateSpotCollateralSchema).default([]), capabilities: TraderStateCapabilitiesSchema.optional(), cooldownStatus: CooldownStatusSchema.optional(), positions: z.array(TraderStatePositionSnapshotSchema).default([]), @@ -518,6 +535,7 @@ const TraderStateSubaccountDeltaSchema: z.ZodType = subaccountIndex: z.number(), sequence: z.number(), collateral: z.string(), + spotCollaterals: z.array(TraderStateSpotCollateralSchema).default([]), capabilities: TraderStateCapabilitiesSchema.optional(), cooldownStatus: CooldownStatusSchema.optional(), positions: z.array(TraderStatePositionDeltaSchema).default([]), diff --git a/ts/src/builders.ts b/ts/src/builders.ts index e51aa4f..2bf7adf 100644 --- a/ts/src/builders.ts +++ b/ts/src/builders.ts @@ -213,18 +213,31 @@ const createLegacyPhoenixIxOperationContext = ( const maybeWrapOrderIx = async ( instruction: TIx, - authority: Authority + signer: Authority, + usePositionAuthority = false ): Promise => { if (!isFlightClient(client)) { return instruction; } - return (await client.tryWrapFlightInstruction( + return (await client.tryWrapOrderInstruction( instruction, - authority + signer, + usePositionAuthority )) as TIx; }; + // Conditional placements never collect a builder fee and Flight forwards + // their full account list into the inner CPI — never append the tail; wrap + // as owner-signed regardless of positionAuthority. Mirrors the API + // server's `maybe_wrap_conditional_order_instruction_with_flight`. + const maybeWrapConditionalOrderIx = async < + TIx extends InstructionsWithAccountsAndData, + >( + instruction: TIx, + authority: Authority + ): Promise => maybeWrapOrderIx(instruction, authority); + return { orderPackets: unsupportedOrderPackets, phoenixProgramAddress, @@ -232,6 +245,8 @@ const createLegacyPhoenixIxOperationContext = ( const [exchangeAccounts, market, traderAccount] = await Promise.all([ resolveExchangeInstructionAccounts(), resolveMarketContext(params.symbol), + // The trader PDA always derives from the owner (`authority`), even + // when a delegate `positionAuthority` signs the instruction. resolveTraderAccount({ authority: params.authority, traderPdaIndex: params.traderPdaIndex, @@ -287,6 +302,7 @@ const createLegacyPhoenixIxOperationContext = ( phoenixProgramAddress, }), maybeWrapOrderIx, + maybeWrapConditionalOrderIx, accountExists: async (address) => typeof client.accountExists === "function" ? client.accountExists(address) diff --git a/ts/src/core/constants.ts b/ts/src/core/constants.ts index 1cee647..0af7761 100644 --- a/ts/src/core/constants.ts +++ b/ts/src/core/constants.ts @@ -1,5 +1,6 @@ import type { EmberProgramAddress, + EmberStateAddress, GlobalConfigurationAddress, LogAuthorityAddress, MintAddress, @@ -42,6 +43,10 @@ export const EMBER_PROGRAM_ADDRESS = address( "EMBERpYNE6ehWmXymZZS2skiFmCa9V5dp14e1iduM5qy" ) as EmberProgramAddress; +export const EMBER_STATE_ADDRESS = address( + "6ur7v6AXNpnHeEb6xuk7PyezvZ1i5GrgYyWZkNCpzbRz" +) as EmberStateAddress; + const BETA_PHOENIX_PROGRAM_ADDRESS = address( "phDEVv4w6BcfkLrLNeXr8HhhgQxnxziVGXpGPcaadMf" ) as PhoenixProgramAddress; @@ -54,6 +59,14 @@ const BETA_PHOENIX_GLOBAL_CONFIGURATION_ADDRESS = address( "3CkM38UaZW6nyTJku4ABE5jjS5AComQErrkd55LGTfxa" ) as GlobalConfigurationAddress; +export const BETA_USDC_MINT_ADDRESS = address( + "DPTSTVhvfzQhY8pAJL5EeqfZxf9aNKTmHErfG4R3Z1SE" +) as MintAddress; + +const BETA_EMBER_STATE_ADDRESS = address( + "HVpfk2HMkR85rvaXezoNjXfB5J4ds4PurSfi6n4DPm2Z" +) as EmberStateAddress; + export interface PhoenixInstructionAddresses { programAddress: PhoenixProgramAddress; logAuthorityAddress: LogAuthorityAddress; @@ -179,6 +192,75 @@ export const resolvePhoenixInstructionAddresses = ( }; }; +/** + * Full env-resolved address set for constructing a PhoenixInstructionClient's + * `addresses`, including the per-environment USDC mint and ember state. + * Unlike `resolvePhoenixInstructionAddresses`, this covers every address the + * builders read from the client, so a beta client cannot silently fall back + * to the mainnet USDC mint. + */ +export interface PhoenixBuilderAddressDefaults { + phoenixProgramAddress: PhoenixProgramAddress; + logAuthorityAddress: LogAuthorityAddress; + globalConfigurationAddress: GlobalConfigurationAddress; + usdcMintAddress: MintAddress; + emberStateAddress: EmberStateAddress; +} + +export interface ResolvePhoenixBuilderAddressesInput extends Partial { + phoenixEnv?: string | null; +} + +const PROD_PHOENIX_BUILDER_ADDRESSES: PhoenixBuilderAddressDefaults = { + phoenixProgramAddress: PHOENIX_PROGRAM_ADDRESS, + logAuthorityAddress: PHOENIX_LOG_AUTHORITY_ADDRESS, + globalConfigurationAddress: PHOENIX_GLOBAL_CONFIGURATION_ADDRESS, + usdcMintAddress: USDC_MINT_ADDRESS, + emberStateAddress: EMBER_STATE_ADDRESS, +}; + +const BETA_PHOENIX_BUILDER_ADDRESSES: PhoenixBuilderAddressDefaults = { + phoenixProgramAddress: BETA_PHOENIX_PROGRAM_ADDRESS, + logAuthorityAddress: BETA_PHOENIX_LOG_AUTHORITY_ADDRESS, + globalConfigurationAddress: BETA_PHOENIX_GLOBAL_CONFIGURATION_ADDRESS, + usdcMintAddress: BETA_USDC_MINT_ADDRESS, + emberStateAddress: BETA_EMBER_STATE_ADDRESS, +}; + +const knownPhoenixBuilderAddresses = ( + phoenixProgramAddress?: PhoenixProgramAddress +): PhoenixBuilderAddressDefaults | undefined => { + if (!phoenixProgramAddress) return undefined; + if (phoenixProgramAddress === BETA_PHOENIX_PROGRAM_ADDRESS) { + return BETA_PHOENIX_BUILDER_ADDRESSES; + } + if (phoenixProgramAddress === PHOENIX_PROGRAM_ADDRESS) { + return PROD_PHOENIX_BUILDER_ADDRESSES; + } + return undefined; +}; + +export const resolvePhoenixBuilderAddresses = ( + input: ResolvePhoenixBuilderAddressesInput = {} +): PhoenixBuilderAddressDefaults => { + const defaults = + knownPhoenixBuilderAddresses(input.phoenixProgramAddress) ?? + (resolvePhoenixEnv(input) === "beta" + ? BETA_PHOENIX_BUILDER_ADDRESSES + : PROD_PHOENIX_BUILDER_ADDRESSES); + + return { + phoenixProgramAddress: + input.phoenixProgramAddress ?? defaults.phoenixProgramAddress, + logAuthorityAddress: + input.logAuthorityAddress ?? defaults.logAuthorityAddress, + globalConfigurationAddress: + input.globalConfigurationAddress ?? defaults.globalConfigurationAddress, + usdcMintAddress: input.usdcMintAddress ?? defaults.usdcMintAddress, + emberStateAddress: input.emberStateAddress ?? defaults.emberStateAddress, + }; +}; + export const getPhoenixProgramAddress = ( input: ResolvePhoenixInstructionAddressesInput = {} ): PhoenixProgramAddress => diff --git a/ts/src/core/discriminants.ts b/ts/src/core/discriminants.ts index 94357fc..094529d 100644 --- a/ts/src/core/discriminants.ts +++ b/ts/src/core/discriminants.ts @@ -27,6 +27,9 @@ export const DISCRIMINANTS: DiscriminantMap = { "global:set_trader_capabilities_delegated" ), TRANSFER_COLLATERAL: sha2_const("global:transfer_collateral"), + AUTHORIZED_TRANSFER_COLLATERAL: sha2_const( + "global:authorized_transfer_collateral" + ), TRANSFER_COLLATERAL_CHILD_TO_PARENT: sha2_const( "global:transfer_collateral_child_to_parent" ), diff --git a/ts/src/core/permissionInstructions.ts b/ts/src/core/permissionInstructions.ts index 415ebbf..2cbd923 100644 --- a/ts/src/core/permissionInstructions.ts +++ b/ts/src/core/permissionInstructions.ts @@ -73,6 +73,7 @@ export type SetPermissionDelegatedAccounts = readonly AccountMeta[]; export const TRADER_ONBOARDING_PERMISSION: bigint = 1n << 4n; export const TRADER_MANAGEMENT_PERMISSION: bigint = 1n << 7n; +export const DEPOSIT_PERMISSION: bigint = 1n << 12n; export const getCreatePermissionEncoder = (): Encoder => getConstantEncoder(DISCRIMINANTS.CREATE_PERMISSION); diff --git a/ts/src/flame.ts b/ts/src/flame.ts index 6b58aab..86bbfb1 100644 --- a/ts/src/flame.ts +++ b/ts/src/flame.ts @@ -1,16 +1,47 @@ -import { getPhoenixProgramAddress } from "@/core/constants"; -import { getPhoenixTraderTokenAccountAddress } from "@/pdas"; +import { + EMBER_PROGRAM_ADDRESS, + SPL_ATA_PROGRAM_ADDRESS, + SPL_TOKEN_PROGRAM_ADDRESS, + SYSTEM_PROGRAM_ADDRESS, + getPhoenixInstructionAddresses, + getPhoenixProgramAddress, + type ResolvePhoenixInstructionAddressesInput, +} from "@/core/constants"; +import { sha2_const } from "@/core/discriminants"; +import { + generateReadonlyAccount, + generateWritableAccount, + generateWritableSignerAccount, + generateArenaAccounts, +} from "@/core/utils/accountMeta"; +import { + getEmberStateAddress, + getEmberVaultAddress, + getPhoenixGlobalVaultAddress, + getPhoenixPermissionAddress, + getPhoenixTraderSubaccountAddress, + getPhoenixTraderTokenAccountAddress, +} from "@/pdas"; import type { + ActiveTraderBufferAddressArray, Authority, + EmberStateAddress, + EmberVaultAddress, FlameProgramAddress, + GlobalTraderIndexAddressArray, + GlobalVaultAddress, MintAddress, PhoenixProgramAddress, TokenAccountAddress, + TraderAddress, } from "@/primitives"; +import type { InstructionsWithAccountsAndData } from "@/primitives/_utilityTypes"; import { address, getBase58Encoder, getProgramDerivedAddress, + type AccountMeta, + type Address, } from "@solana/kit"; export const FLAME_PROGRAM_ADDRESS = address( @@ -37,6 +68,38 @@ export interface FlameDepositAddresses { proxyAta: TokenAccountAddress; } +export interface FlameDepositToPhoenixParams + extends + ResolveFlameAddressesInput, + Pick< + ResolvePhoenixInstructionAddressesInput, + "logAuthorityAddress" | "globalConfigurationAddress" + > { + crank: Authority; + userAuthority: Authority; + inputMint: MintAddress; + outputMint: MintAddress; + globalTraderIndex: GlobalTraderIndexAddressArray; + activeTraderBuffer: ActiveTraderBufferAddressArray; + traderPdaIndex: number; + traderSubaccountIndex?: number; +} + +export type FlameDepositToPhoenixIx = InstructionsWithAccountsAndData; + +export interface FlameDepositToPhoenixAddresses { + globalState: Address; + proxyAuthority: Authority; + proxyTokenAccount: TokenAccountAddress; + feeVaultTokenAccount: TokenAccountAddress; + emberState: EmberStateAddress; + emberVault: EmberVaultAddress; + proxyPhoenixTokenAccount: TokenAccountAddress; + phoenixTraderAccount: TraderAddress; + phoenixGlobalVault: GlobalVaultAddress; + phoenixPermissionAccount: Address; +} + const assertU8 = (value: number, fieldName: string): void => { if (!Number.isInteger(value) || value < 0 || value > 255) { throw new Error(`${fieldName} must be between 0 and 255`); @@ -97,3 +160,151 @@ export const deriveFlameDepositAddresses = async ({ proxyAta, }; }; + +export const deriveFlameGlobalStateAddress = async ( + input: ResolveFlameAddressesInput = {} +): Promise
=> { + const { flameProgramAddress, phoenixProgramAddress } = + resolveFlameAddresses(input); + const base58Encoder = getBase58Encoder(); + const [globalState] = await getProgramDerivedAddress({ + programAddress: flameProgramAddress, + seeds: [base58Encoder.encode(phoenixProgramAddress), "global_state"], + }); + return globalState; +}; + +export const deriveFlameDepositToPhoenixAddresses = async ({ + userAuthority, + inputMint, + outputMint, + traderPdaIndex, + traderSubaccountIndex = 0, + ...input +}: Omit< + FlameDepositToPhoenixParams, + "crank" | "globalTraderIndex" | "activeTraderBuffer" +>): Promise => { + assertU8(traderPdaIndex, "Trader PDA index"); + assertU8(traderSubaccountIndex, "Trader subaccount index"); + const { phoenixProgramAddress } = resolveFlameAddresses(input); + const proxyAuthority = await deriveFlameProxyAuthorityAddress({ + userAuthority, + traderPdaIndex, + ...input, + }); + const globalState = await deriveFlameGlobalStateAddress(input); + + const [ + proxyTokenAccount, + feeVaultTokenAccount, + emberState, + emberVault, + proxyPhoenixTokenAccount, + phoenixTraderAccount, + phoenixGlobalVault, + phoenixPermissionAccount, + ] = await Promise.all([ + getPhoenixTraderTokenAccountAddress(proxyAuthority, inputMint), + getPhoenixTraderTokenAccountAddress(globalState as Authority, inputMint), + getEmberStateAddress(phoenixProgramAddress), + getEmberVaultAddress(phoenixProgramAddress), + getPhoenixTraderTokenAccountAddress(proxyAuthority, outputMint), + getPhoenixTraderSubaccountAddress({ + authority: userAuthority, + traderPdaIndex, + subaccountIndex: traderSubaccountIndex, + phoenixProgramAddress, + }), + getPhoenixGlobalVaultAddress(outputMint, phoenixProgramAddress), + getPhoenixPermissionAddress( + userAuthority, + proxyAuthority, + phoenixProgramAddress + ), + ]); + + return { + globalState, + proxyAuthority, + proxyTokenAccount, + feeVaultTokenAccount, + emberState, + emberVault, + proxyPhoenixTokenAccount, + phoenixTraderAccount, + phoenixGlobalVault, + phoenixPermissionAccount, + }; +}; + +const getFlameDepositToPhoenixData = ( + traderPdaIndex: number, + traderSubaccountIndex: number +): Uint8Array => { + assertU8(traderPdaIndex, "Trader PDA index"); + assertU8(traderSubaccountIndex, "Trader subaccount index"); + const data = new Uint8Array(10); + data.set(sha2_const("global:deposit_to_phoenix"), 0); + data[8] = traderPdaIndex; + data[9] = traderSubaccountIndex; + return data; +}; + +export const buildFlameDepositToPhoenixIx = async ( + params: FlameDepositToPhoenixParams +): Promise => { + const { flameProgramAddress, phoenixProgramAddress } = + resolveFlameAddresses(params); + const { logAuthorityAddress, globalConfigurationAddress, programAddress } = + getPhoenixInstructionAddresses({ + programAddress: phoenixProgramAddress, + logAuthorityAddress: params.logAuthorityAddress, + globalConfigurationAddress: params.globalConfigurationAddress, + }); + const traderSubaccountIndex = params.traderSubaccountIndex ?? 0; + const addresses = await deriveFlameDepositToPhoenixAddresses({ + userAuthority: params.userAuthority, + inputMint: params.inputMint, + outputMint: params.outputMint, + traderPdaIndex: params.traderPdaIndex, + traderSubaccountIndex, + phoenixProgramAddress: programAddress, + flameProgramAddress, + }); + + const accounts: AccountMeta[] = [ + generateReadonlyAccount(addresses.globalState), + generateReadonlyAccount(programAddress), + generateWritableSignerAccount(params.crank), + generateReadonlyAccount(params.userAuthority), + generateReadonlyAccount(addresses.proxyAuthority), + generateWritableAccount(addresses.proxyTokenAccount), + generateWritableAccount(addresses.feeVaultTokenAccount), + generateReadonlyAccount(SPL_TOKEN_PROGRAM_ADDRESS), + generateReadonlyAccount(SYSTEM_PROGRAM_ADDRESS), + generateReadonlyAccount(SPL_ATA_PROGRAM_ADDRESS), + generateReadonlyAccount(EMBER_PROGRAM_ADDRESS), + generateReadonlyAccount(addresses.emberState), + generateReadonlyAccount(params.inputMint), + generateWritableAccount(params.outputMint), + generateWritableAccount(addresses.proxyPhoenixTokenAccount), + generateWritableAccount(addresses.emberVault), + generateReadonlyAccount(logAuthorityAddress), + generateWritableAccount(globalConfigurationAddress), + generateWritableAccount(addresses.phoenixTraderAccount), + generateWritableAccount(addresses.phoenixGlobalVault), + generateWritableAccount(addresses.phoenixPermissionAccount), + ...generateArenaAccounts(params.globalTraderIndex), + ...generateArenaAccounts(params.activeTraderBuffer), + ]; + + return { + programAddress: flameProgramAddress, + accounts, + data: getFlameDepositToPhoenixData( + params.traderPdaIndex, + traderSubaccountIndex + ), + }; +}; diff --git a/ts/src/flight/README.md b/ts/src/flight/README.md index 0e6240b..cc34d25 100644 --- a/ts/src/flight/README.md +++ b/ts/src/flight/README.md @@ -73,6 +73,22 @@ Recommended settings: builder's registered fee. - Keep `exchangeMetadata: { stream: true }` enabled for long-running apps that build orders against live market metadata. +- One rule for the collateral-transfer tail: **signing as a position + authority ⇒ declare it**. Wraps never infer the tail from the wrapped + instruction — a plain wrap of a delegated market order appends nothing, + because owner-signed `PlaceMarketOrderDelegated` settles the builder fee + via the plain transfer on-chain, and the tail write-locks a global + permission account, so it is opt-in by signer kind only. On the high-level + `client.ixs` order methods the declaration is derived for you: pass the + delegate as `positionAuthority` and the wrap takes the position-authority + path whenever the effective signer (`positionAuthority ?? authority`) + differs from `authority`. +- Position-authority wraps append a collateral-transfer permission account + derived from the Phoenix root authority. `rise` always resolves that root + authority from the client's exchange metadata snapshot at wrap time — it + is never configured manually and never cached, so on-chain authority + rotations (streamed as `exchangeKeysUpdated` deltas in websocket mode, or + picked up by HTTP refreshes otherwise) are reflected automatically. - Keep PDA caching enabled. `pdaCache: { maxEntries: 1024 }` is the explicit default for long-lived processes. @@ -102,6 +118,38 @@ The builder trader that collects fees is always derived from: - `builderPdaIndex ?? 0` - `builderSubaccountIndex ?? 0` +There is no root-authority configuration. When a wrap needs the Phoenix root +authority (position-authority orders), the client reads it from the exchange +metadata snapshot at wrap time, so a root-authority rotation on-chain never +leaves the client deriving a stale permission PDA. + +## Position-Authority (Delegate-Signed) Orders + +The trader PDA always derives from `authority` (the owner). When a delegate +key signs instead of the owner, pass it as `positionAuthority` on the +high-level order methods — mirroring the API server DTOs, the effective +signer is `positionAuthority ?? authority`, and the path is picked from the +pure comparison of that signer with `authority`, never from the wrapped +instruction: + +```ts +const ix = await client.ixs.placeMarketOrder({ + authority: ownerWallet, // trader account owner; trader PDA derives from it + positionAuthority: delegateWallet, // signs the transaction + symbol: "SOL-PERP", + orderPacket, +}); +``` + +With `positionAuthority` set (and different from `authority`), the Flight +wrap appends the collateral-transfer authority and permission accounts so +the builder fee can move via `AuthorizedTransferCollateral`; the permission +account derives from the Phoenix root authority resolved from the exchange +snapshot, and the collateral-transfer authority is scoped to the exact +Phoenix program address. `client.ixs.placeMarketOrderDelegated(...)` follows +the same rule via its effective signer +(`traderWallet ?? positionAuthority ?? authority`). + ## Build A Flight-Wrapped Limit Order Ix ```ts @@ -216,6 +264,14 @@ Flight-wrapped market-order instruction via `client.ixs.placeMarketOrder(...)`: bun examples/06-flight-market-order.ts [PRICE_LIMIT_TICKS] ``` +Set `POSITION_AUTHORITY=` to build the delegate-signed +variant, where the delegate is passed as `positionAuthority` and the wrap +goes through the position-authority path: + +```bash +POSITION_AUTHORITY= bun examples/06-flight-market-order.ts [PRICE_LIMIT_TICKS] +``` + ## Wrap A Native Order Ix Manually If you already built a native Phoenix order instruction and just want to wrap @@ -233,7 +289,7 @@ const innerIx = buildPlaceLimitOrderIxResolved({ const wrappedIx = await flight.wrapInstructionWithFlight({ phoenixInstruction: innerIx, - authority: "Authority111111111111111111111111111111111", + signer: "Authority111111111111111111111111111111111", phoenixProgramAddress: client.pda.getProgramAddress(), flight: { builderAuthority: "Builder111111111111111111111111111111111", @@ -252,7 +308,29 @@ const wrappedIx = await flight.wrapInstructionWithFlight({ ``` `wrapInstructionWithFlight(...)` only wraps supported placement instructions. -Unsupported instructions are returned unchanged. +Unsupported instructions are returned unchanged. `signer` is the wallet that +signs the wrapped instruction — the effective signer +(`positionAuthority ?? ownerAuthority`), not necessarily the trader account's +owner. + +If `signer` signs as the trader's position authority, declare it with +`usePositionAuthority: true` and pass `resolveRootAuthority` so the wrapper +can derive the collateral-transfer permission account (the declaration is +the only trigger — the wrapper never infers the tail from the instruction, +so owner-signed delegated market orders wrap plainly). Source the root +authority from live exchange metadata rather than a hardcoded value: + +```ts +const wrappedPositionAuthorityIx = await flight.wrapInstructionWithFlight({ + // ... same fields as above ... + usePositionAuthority: true, + resolveRootAuthority: async () => { + await client.exchange.ready(); + return client.exchange.snapshot().exchange.currentAuthorities + .rootAuthority as Authority; + }, +}); +``` ## Wrap An Existing `PhoenixInstructionClient` @@ -268,7 +346,7 @@ const flightClient = new flight.PhoenixFlightClient(instructionClient, { builderSubaccountIndex: 0, }); -const wrappedIx = await flightClient.tryWrapFlightInstruction( +const wrappedIx = await flightClient.tryWrapOrderInstruction( nativeOrderIx, "Authority111111111111111111111111111111111" ); @@ -276,3 +354,20 @@ const wrappedIx = await flightClient.tryWrapFlightInstruction( This is the lower-level path if you are integrating Flight into an existing ix builder stack rather than using `createPhoenixClient(...)`. + +`tryWrapOrderInstruction(ix, signer, usePositionAuthority = false)` is the +single wrap entry, an exact mirror of the Rust rise client's +`try_wrap_order_instruction`: `signer` is the wallet that signs the wrapped +instruction, and `usePositionAuthority` declares that the signer is the +trader's position authority. Derive the flag as `signer !== ownerAuthority` +when the owner is known — never from the instruction being wrapped. Left +`false` (owner-signed), the wrap never appends the collateral-transfer tail, +delegated market orders included; set `true`, it always appends it. +Unsupported instructions come back unchanged, so the result is a plain +`InstructionsWithAccountsAndData`, not necessarily a proxy instruction. + +For position-authority wraps (`usePositionAuthority: true`), the wrapped +`instructionClient` must expose exchange metadata +(`instructionClient.exchange`) — `PhoenixFlightClient` resolves the current +Phoenix root authority from that snapshot on every wrap and throws if the +metadata is unavailable. diff --git a/ts/src/flight/client.ts b/ts/src/flight/client.ts index 7156ba4..777d256 100644 --- a/ts/src/flight/client.ts +++ b/ts/src/flight/client.ts @@ -3,10 +3,7 @@ import type { InstructionsWithAccountsAndData, TraderAddress, } from "@/primitives/index.js"; -import { - buildProxyInstructionIx, - type ProxyInstructionIx, -} from "./core/index.js"; +import { buildProxyInstructionIx } from "./core/index.js"; import type { PhoenixBuilderAddresses, PhoenixInstructionClient, @@ -57,18 +54,50 @@ export const resolvePhoenixFlightOrderRequestFields = async ( export const wrapInstructionWithFlight = async (params: { phoenixInstruction: InstructionsWithAccountsAndData; - authority: Authority; + /** + * Wallet that signs the wrapped instruction — the effective signer + * (`positionAuthority ?? ownerAuthority`), placed verbatim in the proxy's + * trader-wallet slot. Not necessarily the trader account's owner. + */ + signer: Authority; phoenixProgramAddress: PhoenixInstructionClient["addresses"]["phoenixProgramAddress"]; flight: PhoenixFlightClientConfig; + /** + * Set when `signer` signs as the trader's position authority rather than + * as the trader account's owner, so the collateral-transfer tail accounts + * are appended to the proxy instruction. This declaration is the single + * source of truth for the tail: wraps never infer it from the inner + * instruction, so owner-signed delegated market orders wrap without the + * tail (Flight detects the owner signer on-chain and uses the plain + * transfer, and the tail would needlessly write-lock a global permission + * account). + */ + usePositionAuthority?: boolean; resolveFeeCollectorTraderAddress: ( traderPdaIndex: number, subaccountIndex: number ) => Promise; + /** + * Supplies the current Phoenix root authority used to derive the + * collateral-transfer permission account. Only invoked — and only + * required — when `usePositionAuthority` is set. + */ + resolveRootAuthority?: () => Promise; }): Promise => { if (!isFlightRoutableInstruction(params.phoenixInstruction)) { return params.phoenixInstruction; } + let rootAuthority: Authority | undefined; + if (params.usePositionAuthority === true) { + if (params.resolveRootAuthority === undefined) { + throw new Error( + "Root authority is required for position-authority wraps; pass resolveRootAuthority" + ); + } + rootAuthority = await params.resolveRootAuthority(); + } + return buildProxyInstructionIx({ phoenixProgramAddress: params.phoenixProgramAddress, builderAuthority: params.flight.builderAuthority, @@ -76,8 +105,9 @@ export const wrapInstructionWithFlight = async (params: { params.flight, params.resolveFeeCollectorTraderAddress ), - traderWallet: params.authority, + traderWallet: params.signer, feeBpsOverride: params.flight.feeBpsOverride, + rootAuthority, innerInstruction: params.phoenixInstruction, }); }; @@ -114,13 +144,36 @@ export class PhoenixFlightClient implements PhoenixInstructionClient { return this.instructionClient.fetchAccount(address); } - async tryWrapFlightInstruction( + /** + * Wrap a Flight-routable instruction in a Flight proxy instruction; return + * unsupported instructions unchanged (hence the + * `InstructionsWithAccountsAndData` return type — passthroughs are not + * proxy instructions). Exact mirror of the Rust rise client's + * `try_wrap_order_instruction`: `signer` is the wallet that signs the + * wrapped instruction, and `usePositionAuthority` declares that the signer + * is the trader's position authority rather than the trader account's + * owner — derive it as `signer !== ownerAuthority` when the owner is + * known, never from the instruction being wrapped. + * + * With `usePositionAuthority` set, the collateral-transfer authority and + * permission accounts are appended so Flight can collect the builder fee + * via `AuthorizedTransferCollateral`; the permission account derives from + * the current Phoenix root authority, resolved from the wrapped + * instruction client's exchange metadata on every wrap (throws when that + * metadata is unavailable). Owner-signed orders — including owner-signed + * `PlaceMarketOrderDelegated` — must leave it `false`: on-chain, Flight + * detects the owner signature and settles via the plain transfer, and the + * tail write-locks a global permission account. + */ + async tryWrapOrderInstruction( phoenixInstruction: InstructionsWithAccountsAndData, - authority: Authority - ): Promise { + signer: Authority, + usePositionAuthority = false + ): Promise { return wrapInstructionWithFlight({ phoenixInstruction, - authority, + signer, + usePositionAuthority, phoenixProgramAddress: this.instructionClient.addresses.phoenixProgramAddress, flight: this, @@ -132,6 +185,22 @@ export class PhoenixFlightClient implements PhoenixInstructionClient { phoenixProgramAddress: this.instructionClient.addresses.phoenixProgramAddress, }), + // Only invoked for position-authority wraps, and always resolved from + // the exchange snapshot at wrap time (never cached): the root authority + // can rotate on-chain and the snapshot store tracks + // `exchangeKeysUpdated` deltas. `snapshot()` is a cheap in-memory read + // once `ready()` has resolved. + resolveRootAuthority: async () => { + const exchange = this.instructionClient.exchange; + if (exchange === undefined) { + throw new Error( + "Flight position-authority orders require exchange metadata to resolve the root authority" + ); + } + await exchange.ready(); + return exchange.snapshot().exchange.currentAuthorities + .rootAuthority as Authority; + }, }); } } diff --git a/ts/src/flight/core/constants.ts b/ts/src/flight/core/constants.ts index 8916132..10f3ae4 100644 --- a/ts/src/flight/core/constants.ts +++ b/ts/src/flight/core/constants.ts @@ -68,3 +68,5 @@ export const clientFlightInstructionAddresses = ( export const GLOBAL_STATE_SEED = "global_state"; export const BUILDER_STATE_SEED = "builder_state"; +export const COLLATERAL_TRANSFER_AUTHORITY_SEED = + "collateral_transfer_authority"; diff --git a/ts/src/flight/core/ixBuilders/ProxyInstruction/ix.ts b/ts/src/flight/core/ixBuilders/ProxyInstruction/ix.ts index c21cfb7..c6457de 100644 --- a/ts/src/flight/core/ixBuilders/ProxyInstruction/ix.ts +++ b/ts/src/flight/core/ixBuilders/ProxyInstruction/ix.ts @@ -5,6 +5,8 @@ import { import { getFlightInstructionAddresses } from "@/flight/core/constants"; import { getFlightBuilderStateAddress, + getFlightCollateralTransferAuthorityAddress, + getFlightAuthorizedCollateralTransferPermissionAddress, getFlightGlobalStateAddress, } from "@/flight/pdas"; import { @@ -37,7 +39,7 @@ export const buildProxyInstructionIx = async ( ), ]); - const accounts: ProxyInstructionAccounts = [ + const accounts = [ generateReadonlyAccount(globalStateAccount), generateReadonlyAccount(phoenixProgramAddress), generateReadonlyAccount(params.builderAuthority), @@ -45,7 +47,27 @@ export const buildProxyInstructionIx = async ( generateReadonlyAccount(builderStateAccount), generateReadonlyAccount(params.traderWallet), ...(params.innerInstruction.accounts ?? []), - ] as const; + ]; + + // `rootAuthority` presence is the single source of truth for the + // collateral-transfer tail: the tail write-locks a global permission + // account, so it is appended only when the caller declares that + // `traderWallet` signs as the trader's position authority. Owner-signed + // orders — including owner-signed `PlaceMarketOrderDelegated` — never + // need it; Flight detects the owner signer on-chain and collects the fee + // via the plain transfer. + if (params.rootAuthority != null) { + const [collateralTransferAuthority, collateralTransferPermissionAccount] = + await Promise.all([ + getFlightCollateralTransferAuthorityAddress(phoenixProgramAddress), + getFlightAuthorizedCollateralTransferPermissionAddress( + params.rootAuthority, + phoenixProgramAddress + ), + ]); + accounts.push(generateReadonlyAccount(collateralTransferAuthority)); + accounts.push(generateWritableAccount(collateralTransferPermissionAccount)); + } const innerInstructionData = params.innerInstruction.data ?? new Uint8Array(0); @@ -59,7 +81,7 @@ export const buildProxyInstructionIx = async ( return { programAddress, - accounts, + accounts: accounts as ProxyInstructionAccounts, data, }; }; @@ -85,7 +107,9 @@ const validate = ( params.feeBpsOverride < 0n || params.feeBpsOverride > MAX_BASIS_POINTS ) { - throw new Error("Fee bps override must be in the range 0..=10000"); + // Keep this wording in lockstep with the Rust builder's + // `PhoenixIxError::InvalidFeeBpsOverride` display string. + throw new Error("Invalid fee bps override (must be in 0..=10000)"); } } diff --git a/ts/src/flight/core/ixBuilders/ProxyInstruction/types.ts b/ts/src/flight/core/ixBuilders/ProxyInstruction/types.ts index 8345a73..192a35b 100644 --- a/ts/src/flight/core/ixBuilders/ProxyInstruction/types.ts +++ b/ts/src/flight/core/ixBuilders/ProxyInstruction/types.ts @@ -8,6 +8,17 @@ export interface ProxyInstructionParams extends ResolveFlightInstructionAddresse builderTraderAccount: TraderAddress; traderWallet: Authority; feeBpsOverride?: bigint | null; + /** + * Phoenix root authority used to derive the collateral-transfer permission + * account; set iff the signer is a position authority — presence appends + * the collateral-transfer tail accounts. This is the single source of + * truth for the tail (never inferred from the inner instruction): leave it + * unset for owner-signed orders — including owner-signed + * `PlaceMarketOrderDelegated`, which Flight detects on-chain and settles + * via the plain transfer — because the tail write-locks a global + * permission account. + */ + rootAuthority?: Authority; innerInstruction: InstructionsWithAccountsAndData; } diff --git a/ts/src/flight/helper.ts b/ts/src/flight/helper.ts index 5b41f44..db2e1dc 100644 --- a/ts/src/flight/helper.ts +++ b/ts/src/flight/helper.ts @@ -1,7 +1,7 @@ import { DISCRIMINANTS } from "@/core/discriminants.js"; import type { Instruction } from "@solana/kit"; -const hasDiscriminant = ( +export const hasDiscriminant = ( data: Instruction["data"], discriminant: Uint8Array ): boolean => { @@ -18,10 +18,16 @@ const hasDiscriminant = ( return true; }; +const isPlaceMarketOrderDelegatedInstruction = ( + instruction: Instruction +): boolean => + hasDiscriminant(instruction.data, DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED); + export const isFlightRoutableInstruction = ( instruction: Instruction ): boolean => hasDiscriminant(instruction.data, DISCRIMINANTS.PLACE_MARKET_ORDER) || + isPlaceMarketOrderDelegatedInstruction(instruction) || hasDiscriminant(instruction.data, DISCRIMINANTS.PLACE_LIMIT_ORDER) || hasDiscriminant(instruction.data, DISCRIMINANTS.PLACE_STOP_LOSS) || hasDiscriminant( diff --git a/ts/src/flight/pdas.ts b/ts/src/flight/pdas.ts index 97b749b..a2528da 100644 --- a/ts/src/flight/pdas.ts +++ b/ts/src/flight/pdas.ts @@ -4,10 +4,13 @@ import { getBase58Encoder, getProgramDerivedAddress } from "@solana/kit"; import { FLIGHT_PROGRAM_ADDRESS, BUILDER_STATE_SEED, + COLLATERAL_TRANSFER_AUTHORITY_SEED, GLOBAL_STATE_SEED, } from "./core/constants"; import type { FlightBuilderStateAddress, + FlightCollateralTransferAuthorityAddress, + FlightAuthorizedCollateralTransferPermissionAddress, FlightGlobalStateAddress, } from "./types"; @@ -40,3 +43,35 @@ export const getFlightBuilderStateAddress = async ( return pda as FlightBuilderStateAddress; }; + +export const getFlightCollateralTransferAuthorityAddress = async ( + phoenixProgramAddress: PhoenixProgramAddress = getPhoenixProgramAddress() +): Promise => { + const [pda] = await getProgramDerivedAddress({ + programAddress: FLIGHT_PROGRAM_ADDRESS, + seeds: [ + COLLATERAL_TRANSFER_AUTHORITY_SEED, + getBase58Encoder().encode(phoenixProgramAddress), + ], + }); + + return pda as FlightCollateralTransferAuthorityAddress; +}; + +export const getFlightAuthorizedCollateralTransferPermissionAddress = async ( + rootAuthority: Authority, + phoenixProgramAddress: PhoenixProgramAddress = getPhoenixProgramAddress() +): Promise => { + const collateralTransferAuthority = + await getFlightCollateralTransferAuthorityAddress(phoenixProgramAddress); + const [pda] = await getProgramDerivedAddress({ + programAddress: phoenixProgramAddress, + seeds: [ + "permission", + getBase58Encoder().encode(rootAuthority), + getBase58Encoder().encode(collateralTransferAuthority), + ], + }); + + return pda as FlightAuthorizedCollateralTransferPermissionAddress; +}; diff --git a/ts/src/flight/types.ts b/ts/src/flight/types.ts index d1682b5..fc66b65 100644 --- a/ts/src/flight/types.ts +++ b/ts/src/flight/types.ts @@ -4,3 +4,11 @@ import type { Address } from "@solana/kit"; export type FlightProgramAddress = Branded; export type FlightGlobalStateAddress = Branded; export type FlightBuilderStateAddress = Branded; +export type FlightCollateralTransferAuthorityAddress = Branded< + Address, + "FlightCollateralTransferAuthority" +>; +export type FlightAuthorizedCollateralTransferPermissionAddress = Branded< + Address, + "FlightAuthorizedCollateralTransferPermission" +>; diff --git a/ts/src/flows.ts b/ts/src/flows.ts index 3aec695..e64a366 100644 --- a/ts/src/flows.ts +++ b/ts/src/flows.ts @@ -24,6 +24,7 @@ import { clientPhoenixInstructionAddresses } from "@/core/constants"; import { buildCreatePermissionIx, buildSetPermissionIx, + DEPOSIT_PERMISSION, } from "@/core/permissionInstructions"; import { buildDepositFunds, @@ -56,7 +57,10 @@ import { getPhoenixTraderSubaccountAddress, getPhoenixTraderTokenAccountAddress, } from "@/pdas"; -import { deriveFlameDepositAddresses } from "@/flame"; +import { + buildFlameDepositToPhoenixIx, + deriveFlameDepositAddresses, +} from "@/flame"; import { address, type Address } from "@solana/kit"; import { buildPlaceLimitOrderIx } from "./core/ixBuilders/PlaceLimitOrder"; import { buildPlaceMarketOrderIx } from "./core/ixBuilders/PlaceMarketOrder"; @@ -124,6 +128,28 @@ export interface FlameDepositFundingFlowResult { traderPdaIndex: number; } +export type FlameAtomicDepositFlowParams = BaseDepositFlowParams & + SponsorshipUserIdentifier & { + feePayer: Authority; + sponsorshipToken: string; + }; + +export interface FlameAtomicDepositFlowInstructions extends FlameDepositFundingFlowInstructions { + createPermission: InstructionsWithAccountsAndData; + setPermission: InstructionsWithAccountsAndData; + depositToPhoenix: InstructionsWithAccountsAndData; +} + +export interface FlameAtomicDepositFlowResult { + instructions: InstructionsWithAccountsAndData[]; + named: FlameAtomicDepositFlowInstructions; + proxyAuthority: Authority; + depositAddress: TokenAccountAddress; + proxyAta: TokenAccountAddress; + traderPdaIndex: number; + traderSubaccountIndex: number; +} + const resolveFlowPayer = (params: { authority: Authority; feePayer?: Authority | null; @@ -415,6 +441,94 @@ export const buildFlameDepositFundingFlow = async ( }; }; +export const buildFlameAtomicDepositFlow = async ( + params: FlameAtomicDepositFlowParams, + client: PhoenixInstructionClient +): Promise => { + const { authority, traderPdaIndex = 0 } = params; + const requestedTraderSubaccountIndex = ( + params as FlameAtomicDepositFlowParams & { traderSubaccountIndex?: number } + ).traderSubaccountIndex; + if ( + requestedTraderSubaccountIndex != null && + requestedTraderSubaccountIndex !== 0 + ) { + throw new Error( + "Flame atomic deposit sponsorship only supports traderSubaccountIndex 0" + ); + } + const traderSubaccountIndex = 0; + const payer = resolveFlowPayer(params); + // The sponsor fee payer cranks the deposit so wallets holding no SOL can + // deposit: the crank fronts rent for the transient proxy Phoenix ATA and is + // refunded by the same instruction's close, so net sponsor spend is zero. + const crank = payer; + const [ + { globalConfiguration, arenaAddresses, globalTraderIndexAddresses }, + funding, + ] = await Promise.all([ + fetchRequiredAccounts(client), + buildFlameDepositFundingFlow(params, client), + ]); + const phoenixAddresses = clientPhoenixInstructionAddresses(client); + const permissionPda = await getPhoenixPermissionAddress( + authority, + funding.proxyAuthority, + client.addresses.phoenixProgramAddress + ); + const createPermission = buildCreatePermissionIx({ + ...phoenixAddresses, + payer, + permissionAuthority: authority, + delegatedKey: funding.proxyAuthority, + permissionPda, + }); + const setPermission = buildSetPermissionIx({ + ...phoenixAddresses, + permissionAuthority: authority, + delegatedKey: funding.proxyAuthority, + permissionPda, + permission: DEPOSIT_PERMISSION, + expiresAtTimestamp: null, + allowedSignerActions: null, + }); + const depositToPhoenix = await buildFlameDepositToPhoenixIx({ + crank, + userAuthority: authority, + inputMint: client.addresses.usdcMintAddress, + outputMint: globalConfiguration.canonicalTokenMintKey, + globalTraderIndex: globalTraderIndexAddresses, + activeTraderBuffer: arenaAddresses, + traderPdaIndex, + traderSubaccountIndex, + phoenixProgramAddress: client.addresses.phoenixProgramAddress, + logAuthorityAddress: client.addresses.logAuthorityAddress, + globalConfigurationAddress: client.addresses.globalConfigurationAddress, + }); + + return { + instructions: [ + funding.named.createProxyAta, + funding.named.transferUsdcToProxy, + createPermission, + setPermission, + depositToPhoenix, + ], + named: { + createProxyAta: funding.named.createProxyAta, + transferUsdcToProxy: funding.named.transferUsdcToProxy, + createPermission, + setPermission, + depositToPhoenix, + }, + proxyAuthority: funding.proxyAuthority, + depositAddress: funding.depositAddress, + proxyAta: funding.proxyAta, + traderPdaIndex, + traderSubaccountIndex, + }; +}; + export const buildWithdrawFlow = async ( params: WithdrawFlowParams, client: PhoenixInstructionClient @@ -655,10 +769,15 @@ export const buildPlaceLimitOrderFlow = async ( ); const orderFlags = isReduceOnly ? OrderFlags.ReduceOnly : OrderFlags.None; + // Effective signer of the placement instruction; the Flight wrap must name + // the same wallet and take the position-authority path whenever it is not + // the owner. + const signer = positionAuthority ?? authority; + const placeOrderIx = isPostOnly ? buildPlacePostOnlyOrderIx({ ...clientPhoenixInstructionAddresses(client), - trader: positionAuthority ?? authority, + trader: signer, traderAccount: subaccountAddress, perpAssetMap: globalConfiguration.perpAssetMapKey, orderbook: marketAccount, @@ -678,7 +797,7 @@ export const buildPlaceLimitOrderFlow = async ( }) : buildPlaceLimitOrderIx({ ...clientPhoenixInstructionAddresses(client), - trader: positionAuthority ?? authority, + trader: signer, traderAccount: subaccountAddress, perpAssetMap: globalConfiguration.perpAssetMapKey, orderbook: marketAccount, @@ -699,7 +818,11 @@ export const buildPlaceLimitOrderFlow = async ( }); const maybeWrappedIx = isFlightClient(client) - ? await client.tryWrapFlightInstruction(placeOrderIx, authority) + ? await client.tryWrapOrderInstruction( + placeOrderIx, + signer, + signer !== authority + ) : placeOrderIx; instructions.push(maybeWrappedIx); @@ -815,9 +938,14 @@ export const buildPlaceMarketOrderFlow = async ( resolvedPriceInTicks = ticks(BigInt(Math.floor(priceTicks))); } + // Effective signer of the placement instruction; the Flight wrap must name + // the same wallet and take the position-authority path whenever it is not + // the owner. + const signer = positionAuthority ?? authority; + const placeOrderIx = buildPlaceMarketOrderIx({ ...clientPhoenixInstructionAddresses(client), - trader: positionAuthority ?? authority, + trader: signer, traderAccount: subaccountAddress, perpAssetMap: globalConfiguration.perpAssetMapKey, orderbook: marketAccount, @@ -841,7 +969,11 @@ export const buildPlaceMarketOrderFlow = async ( }); const maybeWrappedIx = isFlightClient(client) - ? await client.tryWrapFlightInstruction(placeOrderIx, authority) + ? await client.tryWrapOrderInstruction( + placeOrderIx, + signer, + signer !== authority + ) : placeOrderIx; instructions.push(maybeWrappedIx); @@ -1034,6 +1166,11 @@ export const buildPlaceMultiLimitOrderFlow = async ( const chunks = chunkScaleLevelsForTx(placeableLevels, { maxOrdersPerTx }); const batches: PlaceMultiLimitOrderFlowBatch[] = []; + // Effective signer of the placement instructions. Multi-limit orders are + // not Flight-routable today, so the wrap below is a passthrough; the + // signer is still named for uniformity with the other flows. + const signer = positionAuthority ?? authority; + for (let i = 0; i < chunks.length; i++) { const multipleOrderPacket = scaleLevelsToMultipleOrderPacket( chunks[i], @@ -1043,7 +1180,7 @@ export const buildPlaceMultiLimitOrderFlow = async ( const placeIx = buildPlaceMultiLimitOrderIx({ ...clientPhoenixInstructionAddresses(client), - trader: positionAuthority ?? authority, + trader: signer, traderAccount: subaccountAddress, perpAssetMap: globalConfiguration.perpAssetMapKey, orderbook: marketAccount, @@ -1054,7 +1191,11 @@ export const buildPlaceMultiLimitOrderFlow = async ( }); const placeMultiLimitOrder = isFlightClient(client) - ? await client.tryWrapFlightInstruction(placeIx, authority) + ? await client.tryWrapOrderInstruction( + placeIx, + signer, + signer !== authority + ) : placeIx; const instructions: InstructionsWithAccountsAndData[] = []; diff --git a/ts/src/ixs/client.ts b/ts/src/ixs/client.ts index 9c5f6d5..db0e260 100644 --- a/ts/src/ixs/client.ts +++ b/ts/src/ixs/client.ts @@ -199,6 +199,8 @@ export const createPhoenixIxClient = (config: { await Promise.all([ getInstructionContextOrThrow(config.exchange, params.symbol), logAuthorityAddressPromise, + // The trader PDA always derives from the owner (`authority`), even + // when a delegate `positionAuthority` signs the instruction. config.pda.getTraderAddress({ authority: params.authority, traderPdaIndex: params.traderPdaIndex ?? 0, @@ -325,18 +327,21 @@ export const createPhoenixIxClient = (config: { const maybeWrapOrderIx = async ( instruction: TIx, - authority: Authority + signer: Authority, + usePositionAuthority = false ): Promise => { if (!config.flight) { return instruction; } debugRise("ix", "flight.wrap:start", { - authority, + signer, + usePositionAuthority, }); return (await wrapInstructionWithFlight({ phoenixInstruction: instruction, - authority, + signer, + usePositionAuthority, phoenixProgramAddress, flight: config.flight, resolveFeeCollectorTraderAddress: (traderPdaIndex, subaccountIndex) => @@ -345,9 +350,28 @@ export const createPhoenixIxClient = (config: { traderPdaIndex, subaccountIndex ), + // Only invoked for position-authority wraps, and always resolved from + // the exchange snapshot at wrap time (never cached): the root authority + // can rotate on-chain and the snapshot store tracks + // `exchangeKeysUpdated` deltas. + resolveRootAuthority: async () => { + const exchangeSnapshot = await getExchangeSnapshot(config.exchange); + return exchangeSnapshot.currentAuthorities.rootAuthority as Authority; + }, })) as TIx; }; + // Conditional placements never collect a builder fee and Flight forwards + // their full account list into the inner CPI — never append the tail; wrap + // as owner-signed regardless of positionAuthority. Mirrors the API + // server's `maybe_wrap_conditional_order_instruction_with_flight`. + const maybeWrapConditionalOrderIx = async < + TIx extends InstructionsWithAccountsAndData, + >( + instruction: TIx, + authority: Authority + ): Promise => maybeWrapOrderIx(instruction, authority); + const resolveTraderTokenAccounts = async ( authority: Authority, params: { @@ -424,6 +448,7 @@ export const createPhoenixIxClient = (config: { phoenixProgramAddress, }), maybeWrapOrderIx, + maybeWrapConditionalOrderIx, accountExists, }); diff --git a/ts/src/ixs/operations.ts b/ts/src/ixs/operations.ts index 32316f2..4a08c97 100644 --- a/ts/src/ixs/operations.ts +++ b/ts/src/ixs/operations.ts @@ -182,13 +182,53 @@ export interface PhoenixIxOperationContext { traderAccount: TraderAddress; assetId: bigint; }) => Promise
; + /** + * Optionally wrap a fee-collecting placement instruction with Flight. + * `signer` is the wallet that signs the instruction; `usePositionAuthority` + * declares that `signer` is the trader's position authority rather than + * the trader account's owner, which makes the wrap append the + * collateral-transfer tail accounts so Flight can collect the builder fee. + * Conditional placements go through {@link maybeWrapConditionalOrderIx} + * instead. + */ maybeWrapOrderIx: ( + instruction: TIx, + signer: Authority, + usePositionAuthority?: boolean + ) => Promise; + /** + * Optionally wrap a conditional placement (stop-loss, position/attached + * conditional orders) with Flight. Conditional placements never collect a + * builder fee and Flight forwards their full account list into the inner + * CPI — never append the tail; wrap as owner-signed regardless of + * `positionAuthority`. Mirrors the API server's + * `maybe_wrap_conditional_order_instruction_with_flight`. + */ + maybeWrapConditionalOrderIx: ( instruction: TIx, authority: Authority ) => Promise; accountExists: (address: Address) => Promise; } +/** + * Effective wallet that signs a placement instruction, mirroring the API + * server DTOs: `position_authority.unwrap_or(authority)` is the signer + * (`traderWallet` is the delegated market order's spelling of the same + * concept). The trader PDA always derives from `authority` (the owner), and + * `usePositionAuthority` — the single derivation of "the signer is not the + * owner" — routes the Flight wrap through the position-authority path. + */ +const resolveOrderSigner = (params: { + authority: Authority; + positionAuthority?: Authority; + traderWallet?: Authority; +}): { signer: Authority; usePositionAuthority: boolean } => { + const signer = + params.traderWallet ?? params.positionAuthority ?? params.authority; + return { signer, usePositionAuthority: signer !== params.authority }; +}; + const priceToTicks = ( priceUsd: bigint | number, tickSize: number, @@ -242,7 +282,12 @@ export const createPhoenixIxOperations = ( ...(await context.resolvePlaceOrderContext(params)), ...params, }; - return context.maybeWrapOrderIx(buildInstruction(input), params.authority); + const { signer, usePositionAuthority } = resolveOrderSigner(params); + return context.maybeWrapOrderIx( + buildInstruction(input), + signer, + usePositionAuthority + ); }; const buildWrappedLimitOrder = ( @@ -358,7 +403,7 @@ export const createPhoenixIxOperations = ( }), ]); - return context.maybeWrapOrderIx( + return context.maybeWrapConditionalOrderIx( buildPlacePositionConditionalOrderIx({ programAddress: exchangeAccounts.phoenixProgramAddress, logAuthorityAddress: exchangeAccounts.logAuthorityAddress, @@ -398,7 +443,7 @@ export const createPhoenixIxOperations = ( }), ]); - return context.maybeWrapOrderIx( + return context.maybeWrapConditionalOrderIx( buildPlaceAttachedConditionalOrderIx({ programAddress: exchangeAccounts.phoenixProgramAddress, logAuthorityAddress: exchangeAccounts.logAuthorityAddress, @@ -425,6 +470,7 @@ export const createPhoenixIxOperations = ( const buildWrappedLimitOrderWithConditionals = async ( params: ClientPlaceLimitOrderWithConditionalsInput ): Promise => { + const { signer, usePositionAuthority } = resolveOrderSigner(params); const [exchangeAccounts, market, traderAccount] = await Promise.all([ context.resolveExchangeInstructionAccounts(), context.resolveMarketContext(params.symbol), @@ -440,7 +486,7 @@ export const createPhoenixIxOperations = ( programAddress: exchangeAccounts.phoenixProgramAddress, logAuthorityAddress: exchangeAccounts.logAuthorityAddress, globalConfigurationAddress: exchangeAccounts.globalConfigurationAddress, - traderWallet: params.positionAuthority ?? params.authority, + traderWallet: signer, traderAccount, perpAssetMap: exchangeAccounts.perpAssetMap, globalTraderIndex: exchangeAccounts.globalTraderIndex, @@ -457,7 +503,8 @@ export const createPhoenixIxOperations = ( ), lessTriggerOrder: normalizeTriggerOrderParams(params.lessTriggerOrder), }), - params.authority + signer, + usePositionAuthority ); }; @@ -906,7 +953,7 @@ export const createPhoenixIxOperations = ( slippageBps: params.slippageBps, }); - return context.maybeWrapOrderIx( + return context.maybeWrapConditionalOrderIx( buildPlaceStopLossIxResolved({ exchange: { phoenixProgramAddress: exchangeAccounts.phoenixProgramAddress, diff --git a/ts/src/ixs/types.ts b/ts/src/ixs/types.ts index 4b90635..64d43a4 100644 --- a/ts/src/ixs/types.ts +++ b/ts/src/ixs/types.ts @@ -136,7 +136,16 @@ export interface BuildPlaceStopLossIxResolvedInput extends ResolvedPlaceOrderCon } export interface ClientPlaceOrderInput { + /** Trader account owner; the trader PDA always derives from this key. */ authority: Authority; + /** + * Wallet that signs the placement instruction when the trader's position + * authority signs on the owner's behalf; the effective signer is + * `positionAuthority ?? authority` (mirroring the API server DTOs). The + * trader PDA still derives from `authority`, and Flight wraps take the + * position-authority path automatically whenever the effective signer + * differs from `authority` — callers never pick that path manually. + */ positionAuthority?: Authority; symbol: Symbol; orderPacket: TPacket; @@ -145,6 +154,13 @@ export interface ClientPlaceOrderInput { } export interface ClientPlaceMarketOrderDelegatedInput extends ClientPlaceOrderInput { + /** + * Wallet that signs the delegated instruction; defaults to + * `positionAuthority ?? authority`. + * + * @deprecated Use `positionAuthority` instead — it is the shared spelling + * of the delegated signer across all placement inputs. + */ traderWallet?: Authority; permissionAccount?: Address; } @@ -306,7 +322,14 @@ export interface ClientPlaceAttachedConditionalOrderInput { } export interface ClientPlaceLimitOrderWithConditionalsInput { + /** Trader account owner; the trader PDA always derives from this key. */ authority: Authority; + /** + * Wallet that signs the instruction when the trader's position authority + * signs on the owner's behalf; the effective signer is + * `positionAuthority ?? authority`. When it differs from `authority`, the + * Flight wrap takes the position-authority path automatically. + */ positionAuthority?: Authority; payer?: Authority; symbol: Symbol; diff --git a/ts/src/margin/compute.ts b/ts/src/margin/compute.ts index bd0be6c..58ec3d5 100644 --- a/ts/src/margin/compute.ts +++ b/ts/src/margin/compute.ts @@ -34,6 +34,8 @@ import type { MarketMarginResult, MarketParams, OrderMarginResult, + SpotCollateralMarginInput, + SpotCollateralMarginResult, SubaccountMarginInputs, SubaccountMarginResult, TraderMarginInputs, @@ -232,14 +234,32 @@ export const computeSubaccountMarginFromInputs = ( return a.orderSequenceNumber.localeCompare(b.orderSequenceNumber); }); + const spotCollaterals = (inputs.spotCollaterals ?? []).map((spot) => + valueSpotCollateral(spot, marketsBySymbol) + ); + spotCollaterals.sort((a, b) => a.assetIndex - b.assetIndex); + let totalSpotNotional = 0n; + let totalSpotDiscounted = 0n; + for (const spot of spotCollaterals) { + totalSpotNotional += toBigInt(spot.notionalQuoteLots); + totalSpotDiscounted += toBigInt(spot.discountedQuoteLots); + } + const collateralBalance = toBigInt(inputs.collateralBalanceQuoteLots ?? "0"); + // Spot collateral enters effective collateral discounted and portfolio value + // undiscounted, and never backs quote withdrawals — mirroring phoenix-state + // TraderPortfolioMargin (sdk/phoenix-state/src/margin.rs). const effectiveCollateral = - collateralBalance + totalDiscountedUnrealizedPnl + totalUnsettledFunding; + collateralBalance + + totalDiscountedUnrealizedPnl + + totalUnsettledFunding + + totalSpotDiscounted; const effectiveCollateralForWithdrawals = collateralBalance + totalDiscountedPnlForWithdrawals + totalUnsettledFunding; - const portfolioValue = collateralBalance + totalUnrealizedPnl; + const portfolioValue = + collateralBalance + totalUnrealizedPnl + totalSpotNotional; const riskState = computeRiskState(totalInitialMargin, effectiveCollateral); const riskTier = computeRiskTier( @@ -281,13 +301,98 @@ export const computeSubaccountMarginFromInputs = ( margin.orderLeverageAdjustedInitialMarginQuoteLots = totalOrderLeverageAdjustedInitialMargin.toString(); } + if (spotCollaterals.length > 0) { + margin.spotCollateralNotionalQuoteLots = totalSpotNotional.toString(); + margin.spotCollateralDiscountedQuoteLots = totalSpotDiscounted.toString(); + } - return { + const result: SubaccountMarginResult = { subaccountIndex: inputs.subaccountIndex, margin, marketMargins, limitOrders, }; + if (spotCollaterals.length > 0) { + result.spotCollaterals = spotCollaterals; + } + return result; +}; + +const BPS_UPPER_BOUND = 10_000n; + +/** + * Values one spot collateral asset the way the on-chain RiskView does + * (program-core/exchange/src/risk_view/mod.rs `notional_native_sol_balance` + + * `discounted_native_sol_collateral`): the balance is priced per base lot with + * truncating dust handling, and the margin discount interpolates linearly from + * `minMarginDiscountBps` at zero balance to `maxMarginDiscountBps` at the + * global cap, evaluated at the trader's own balance. + */ +const valueSpotCollateral = ( + spot: SpotCollateralMarginInput, + marketsBySymbol: NormalizedMarketParamsBySymbol +): SpotCollateralMarginResult => { + const pricingSymbol = spot.pricingMarketSymbol ?? spot.symbol; + const marketParams = marketsBySymbol[pricingSymbol]; + if (!marketParams) { + throw new Error( + `Missing market params for spot collateral pricing market ${pricingSymbol}` + ); + } + const balance = requireNonNegativeBigInt( + spot.balance, + `Spot collateral balance for ${spot.symbol} must be non-negative` + ); + const priceTicks = + spot.indexPriceTicks !== undefined + ? requirePositiveBigInt( + spot.indexPriceTicks, + `Spot collateral indexPriceTicks for ${spot.symbol} must be positive` + ) + : marketParams.markPriceTicks; + + const decimalsDifference = spot.decimals - marketParams.baseLotDecimals; + if (decimalsDifference < 0) { + throw new Error( + `Spot collateral decimals for ${spot.symbol} are below the pricing market's base lot decimals` + ); + } + const nativePerBaseLot = 10n ** BigInt(decimalsDifference); + const priceQuoteLotsPerBaseLot = priceTicks * marketParams.tickSize; + const baseLots = balance / nativePerBaseLot; + const dust = balance - baseLots * nativePerBaseLot; + const notional = + priceQuoteLotsPerBaseLot * baseLots + + (dust * priceQuoteLotsPerBaseLot) / nativePerBaseLot; + + const maxGlobalBalance = requirePositiveBigInt( + spot.maxGlobalBalance, + `Spot collateral maxGlobalBalance for ${spot.symbol} must be positive` + ); + const retentionUpper = BPS_UPPER_BOUND - BigInt(spot.minMarginDiscountBps); + const retentionLower = BPS_UPPER_BOUND - BigInt(spot.maxMarginDiscountBps); + if (retentionUpper < retentionLower) { + throw new Error( + `Spot collateral margin discount curve for ${spot.symbol} is inverted` + ); + } + const target = balance > maxGlobalBalance ? maxGlobalBalance : balance; + const retention = + target === 0n + ? retentionUpper + : target === maxGlobalBalance + ? retentionLower + : retentionUpper - + ((retentionUpper - retentionLower) * target) / maxGlobalBalance; + const discounted = (notional * retention) / BPS_UPPER_BOUND; + + return { + assetIndex: spot.assetIndex, + symbol: spot.symbol, + balance: balance.toString(), + notionalQuoteLots: notional.toString(), + discountedQuoteLots: discounted.toString(), + }; }; export const computeTraderLiquidationPricesFromInputs = ( @@ -888,17 +993,12 @@ const buildScopedSubaccountInput = ( collateralBalanceQuoteLots: scope.isolatedCollateralBalanceQuoteLots ?? subaccount.collateralBalanceQuoteLots, + // Spot collateral only backs cross margin, so isolated scopes drop it. markets: [cloneMarketInput(currentMarket ?? { symbol })], }; } - return { - subaccountIndex: subaccount.subaccountIndex, - collateralBalanceQuoteLots: subaccount.collateralBalanceQuoteLots, - markets: subaccount.markets.map((marketInput) => - cloneMarketInput(marketInput) - ), - }; + return cloneSubaccountInput(subaccount); }; const applySimulationAction = ( @@ -1396,7 +1496,7 @@ const settleFundingForActionScope = ( return settledFundingQuoteLots; }; -const getOrCreateMarketInput = ( +export const getOrCreateMarketInput = ( subaccount: SubaccountMarginInputs, symbol: string ): MarketMarginInputs => { @@ -1415,7 +1515,7 @@ const getMarketInput = ( ): MarketMarginInputs | undefined => subaccount.markets.find((marketInput) => marketInput.symbol === symbol); -const ensureOrderListCanBeMutated = ( +export const ensureOrderListCanBeMutated = ( marketInput: MarketMarginInputs, action: string ): void => { @@ -1572,7 +1672,7 @@ const buildProjectedPosition = ( }; }; -const cloneSubaccountInput = ( +export const cloneSubaccountInput = ( subaccount: SubaccountMarginInputs ): SubaccountMarginInputs => ({ subaccountIndex: subaccount.subaccountIndex, @@ -1580,6 +1680,13 @@ const cloneSubaccountInput = ( markets: subaccount.markets.map((marketInput) => cloneMarketInput(marketInput) ), + ...(subaccount.spotCollaterals + ? { + spotCollaterals: subaccount.spotCollaterals.map((spot) => ({ + ...spot, + })), + } + : {}), }); const cloneMarketInput = ( diff --git a/ts/src/margin/draftOrders.ts b/ts/src/margin/draftOrders.ts new file mode 100644 index 0000000..b77b910 --- /dev/null +++ b/ts/src/margin/draftOrders.ts @@ -0,0 +1,428 @@ +import { Side } from "../primitives/Side"; +import { + cloneSubaccountInput, + computeSubaccountMarginFromInputs, + ensureOrderListCanBeMutated, + getOrCreateMarketInput, +} from "./compute"; +import { absBigInt, toBigInt } from "./math"; +import { buildNormalizedMarketParamsBySymbol } from "./normalize"; +import type { NormalizedMarketParamsBySymbol } from "./normalize"; +import { + buildSubaccountMarginInputsFromSnapshot, + type MarginSnapshotSubaccount, +} from "./snapshot"; +import type { + LimitOrderMarginInput, + MarginCalculationOptions, + MarketParams, + SubaccountMarginInputs, + SubaccountMarginResult, +} from "./types"; + +export type MarginMarketsInput = + | readonly MarketParams[] + | NormalizedMarketParamsBySymbol; + +export type DraftOrderMarginInput = { + symbol: string; + side: Side | "bid" | "ask"; + orderType: "market" | "limit"; + priceTicks: string | number | bigint; + sizeBaseLots: string | number | bigint; + reduceOnly?: boolean; +}; + +export type DraftOrderMarginRequirementResult = { + /** + * The value product surfaces should show/use. + * This is adjusted when order leverage prefs apply, otherwise protocol. + */ + marginRequirementQuoteLots: string; + /** Protocol margin without order-leverage display adjustment. */ + protocolMarginRequirementQuoteLots: string; + /** Present only when order leverage prefs change the result. */ + orderLeverageAdjustedMarginRequirementQuoteLots?: string; +}; + +const DRAFT_ORDER_SEQUENCE_NUMBER = "__rise_draft_order__"; + +const isMarketParamsArray = ( + markets: MarginMarketsInput +): markets is readonly MarketParams[] => Array.isArray(markets); + +const normalizeMarginMarketsInput = ( + markets: MarginMarketsInput +): NormalizedMarketParamsBySymbol => + isMarketParamsArray(markets) + ? buildNormalizedMarketParamsBySymbol(markets) + : markets; + +const normalizeDraftSide = ( + side: DraftOrderMarginInput["side"] +): "bid" | "ask" => { + if (side === "bid" || side === Side.Bid) { + return "bid"; + } + if (side === "ask" || side === Side.Ask) { + return "ask"; + } + throw new Error('Invalid draft order side. Expected "bid" or "ask".'); +}; + +const requirePositiveDraftPriceTicks = ( + priceTicks: DraftOrderMarginInput["priceTicks"] +): void => { + if (toBigInt(priceTicks) <= 0n) { + throw new Error("Draft order priceTicks must be positive"); + } +}; + +const buildSubaccountInputsWithDraftLimitOrder = ( + subaccount: SubaccountMarginInputs, + draftOrder: DraftOrderMarginInput, + sizeBaseLots: bigint +): SubaccountMarginInputs => { + const projected = cloneSubaccountInput(subaccount); + const marketInput = getOrCreateMarketInput(projected, draftOrder.symbol); + ensureOrderListCanBeMutated(marketInput, "project a draft limit order"); + const order: LimitOrderMarginInput = { + orderSequenceNumber: DRAFT_ORDER_SEQUENCE_NUMBER, + side: normalizeDraftSide(draftOrder.side), + priceTicks: toBigInt(draftOrder.priceTicks).toString(), + sizeRemainingLots: sizeBaseLots.toString(), + initialSizeLots: sizeBaseLots.toString(), + reduceOnly: draftOrder.reduceOnly ?? false, + isStopLoss: false, + isStopLossDirection: false, + status: "active", + }; + + marketInput.limitOrders = [...(marketInput.limitOrders ?? []), order]; + // The canonical calculator rebuilds this aggregate from complete order rows, + // the current position, and the current mark price. + marketInput.limitOrderMargin = undefined; + return projected; +}; + +/** + * Draft order sizing only needs the initial-margin delta, so market drafts + * intentionally project a base-lot delta without modeling realized PnL, + * funding settlement, or virtual quote changes. Use the action simulator for + * full post-fill account-state projections. + */ +const buildSubaccountInputsWithDraftMarketFill = ( + subaccount: SubaccountMarginInputs, + draftOrder: DraftOrderMarginInput, + sizeBaseLots: bigint +): SubaccountMarginInputs => { + const projected = cloneSubaccountInput(subaccount); + const marketInput = getOrCreateMarketInput(projected, draftOrder.symbol); + ensureOrderListCanBeMutated(marketInput, "project a draft market fill"); + const signedBaseLots = + normalizeDraftSide(draftOrder.side) === "bid" + ? sizeBaseLots + : -sizeBaseLots; + + marketInput.position = marketInput.position + ? { + ...marketInput.position, + basePositionLots: ( + toBigInt(marketInput.position.basePositionLots) + signedBaseLots + ).toString(), + } + : { + basePositionLots: signedBaseLots.toString(), + virtualQuotePositionLots: "0", + entryPriceTicks: toBigInt(draftOrder.priceTicks).toString(), + unsettledFundingQuoteLots: "0", + accumulatedFundingQuoteLots: "0", + }; + // A position change can change the executable cap for reduce-only orders. + // Rebuild their aggregate from the complete order rows. + marketInput.limitOrderMargin = undefined; + + return projected; +}; + +/** + * Initial margin to display: leverage-adjusted when computed, base otherwise. + * + * `orderLeverageAdjustedInitialMarginQuoteLots` is only present when + * `MarginCalculationOptions.orderLeverageLimitsBySymbol` changed the result, + * so this returns the protocol `initialMarginQuoteLots` in every other case. + */ +const getDisplayInitialMarginQuoteLots = ( + margin: SubaccountMarginResult["margin"] +): string => + margin.orderLeverageAdjustedInitialMarginQuoteLots ?? + margin.initialMarginQuoteLots; + +const displayInitialMargin = (result: SubaccountMarginResult): bigint => + toBigInt(getDisplayInitialMarginQuoteLots(result.margin)); + +const positiveDelta = (postValue: bigint, preValue: bigint): bigint => { + const delta = postValue - preValue; + return delta > 0n ? delta : 0n; +}; + +const buildInitialMarginDeltaResult = ( + preOrderMargin: SubaccountMarginResult, + postOrderMargin: SubaccountMarginResult +): DraftOrderMarginRequirementResult => { + const protocolMargin = positiveDelta( + toBigInt(postOrderMargin.margin.initialMarginQuoteLots), + toBigInt(preOrderMargin.margin.initialMarginQuoteLots) + ); + const adjustedMargin = positiveDelta( + displayInitialMargin(postOrderMargin), + displayInitialMargin(preOrderMargin) + ); + return buildDraftOrderMarginRequirementResult( + protocolMargin, + adjustedMargin !== protocolMargin ? adjustedMargin : undefined + ); +}; + +const findPositionBaseLots = ( + subaccount: SubaccountMarginInputs, + symbol: string +): bigint | undefined => { + const position = subaccount.markets.find( + (marketInput) => marketInput.symbol === symbol + )?.position; + return position ? toBigInt(position.basePositionLots) : undefined; +}; + +const executableReduceOnlyMarketSizeBaseLots = ( + subaccount: SubaccountMarginInputs, + draftOrder: Pick, + requestedSizeBaseLots: bigint +): bigint => { + if (!draftOrder.reduceOnly) { + return requestedSizeBaseLots; + } + + const basePositionLots = findPositionBaseLots(subaccount, draftOrder.symbol); + if (basePositionLots === undefined || basePositionLots === 0n) { + return 0n; + } + + const side = normalizeDraftSide(draftOrder.side); + const reducesPosition = + (side === "bid" && basePositionLots < 0n) || + (side === "ask" && basePositionLots > 0n); + if (!reducesPosition) { + return 0n; + } + + const reducibleSizeBaseLots = absBigInt(basePositionLots); + return requestedSizeBaseLots < reducibleSizeBaseLots + ? requestedSizeBaseLots + : reducibleSizeBaseLots; +}; + +const buildDraftOrderMarginRequirementResult = ( + protocolMarginRequirementQuoteLots: bigint | string, + orderLeverageAdjustedMarginRequirementQuoteLots?: bigint | string +): DraftOrderMarginRequirementResult => { + const protocolMargin = protocolMarginRequirementQuoteLots.toString(); + const adjustedMargin = + orderLeverageAdjustedMarginRequirementQuoteLots?.toString(); + return adjustedMargin === undefined || adjustedMargin === protocolMargin + ? { + marginRequirementQuoteLots: protocolMargin, + protocolMarginRequirementQuoteLots: protocolMargin, + } + : { + marginRequirementQuoteLots: adjustedMargin, + protocolMarginRequirementQuoteLots: protocolMargin, + orderLeverageAdjustedMarginRequirementQuoteLots: adjustedMargin, + }; +}; + +/** + * Computes the incremental initial margin a draft order would require, from + * already-built margin inputs. + * + * `marginRequirementQuoteLots` already incorporates the order-leverage + * adjustment whenever `options.orderLeverageLimitsBySymbol` changes the + * result (see `DraftOrderMarginRequirementResult`); the unadjusted value is + * always available as `protocolMarginRequirementQuoteLots`. + */ +export const computeDraftOrderMarginRequirementFromInputs = (params: { + subaccount: SubaccountMarginInputs; + markets: MarginMarketsInput; + draftOrder: DraftOrderMarginInput; + options?: MarginCalculationOptions; +}): DraftOrderMarginRequirementResult => { + requirePositiveDraftPriceTicks(params.draftOrder.priceTicks); + const markets = normalizeMarginMarketsInput(params.markets); + const sizeBaseLots = toBigInt(params.draftOrder.sizeBaseLots); + if (sizeBaseLots <= 0n) { + return buildDraftOrderMarginRequirementResult(0n); + } + + // Keep this path separate from simulateMarginFromInputs: the simulator models + // instruction-like state changes, while draft order sizing needs the display + // initial-margin delta under optional order-leverage limits. + const preOrderMargin = computeSubaccountMarginFromInputs( + params.subaccount, + markets, + params.options + ); + + if (params.draftOrder.orderType === "limit") { + const postOrderMargin = computeSubaccountMarginFromInputs( + buildSubaccountInputsWithDraftLimitOrder( + params.subaccount, + params.draftOrder, + sizeBaseLots + ), + markets, + params.options + ); + return buildInitialMarginDeltaResult(preOrderMargin, postOrderMargin); + } + + if (params.draftOrder.orderType === "market") { + const executableSizeBaseLots = executableReduceOnlyMarketSizeBaseLots( + params.subaccount, + params.draftOrder, + sizeBaseLots + ); + if (executableSizeBaseLots <= 0n) { + return buildDraftOrderMarginRequirementResult(0n); + } + + const postOrderMargin = computeSubaccountMarginFromInputs( + buildSubaccountInputsWithDraftMarketFill( + params.subaccount, + params.draftOrder, + executableSizeBaseLots + ), + markets, + params.options + ); + return buildInitialMarginDeltaResult(preOrderMargin, postOrderMargin); + } + + throw new Error('Invalid draft order type. Expected "market" or "limit".'); +}; + +/** + * Snapshot-shaped variant of `computeDraftOrderMarginRequirementFromInputs`. + */ +export const computeDraftOrderMarginRequirementFromSnapshot = (params: { + subaccount: MarginSnapshotSubaccount; + markets: MarginMarketsInput; + draftOrder: DraftOrderMarginInput; + options?: MarginCalculationOptions; +}): DraftOrderMarginRequirementResult => + computeDraftOrderMarginRequirementFromInputs({ + subaccount: buildSubaccountMarginInputsFromSnapshot(params.subaccount), + markets: params.markets, + draftOrder: params.draftOrder, + options: params.options, + }); + +/** + * Finds the largest draft order size (in base lots, up to `maxSizeBaseLots`) + * whose incremental margin requirement fits within + * `availableMarginQuoteLots`, from already-built margin inputs. + * + * Reduce-only market drafts are first capped to the closable position size. + * Negative available margin is treated as zero for those drafts so a close + * with no incremental margin requirement remains available. + */ +export const computeMaxDraftOrderSizeForAvailableMarginFromInputs = (params: { + subaccount: SubaccountMarginInputs; + markets: MarginMarketsInput; + draftOrder: Omit; + maxSizeBaseLots: string | number | bigint; + availableMarginQuoteLots: string | number | bigint; + options?: MarginCalculationOptions; +}): bigint => { + requirePositiveDraftPriceTicks(params.draftOrder.priceTicks); + const requestedMaxSizeBaseLots = toBigInt(params.maxSizeBaseLots); + const maxSizeBaseLots = + params.draftOrder.orderType === "market" + ? executableReduceOnlyMarketSizeBaseLots( + params.subaccount, + params.draftOrder, + requestedMaxSizeBaseLots + ) + : requestedMaxSizeBaseLots; + if (maxSizeBaseLots <= 0n) { + return 0n; + } + + const rawAvailableMarginQuoteLots = toBigInt(params.availableMarginQuoteLots); + const availableMarginQuoteLots = + rawAvailableMarginQuoteLots < 0n && + params.draftOrder.orderType === "market" && + params.draftOrder.reduceOnly + ? 0n + : rawAvailableMarginQuoteLots; + if (availableMarginQuoteLots < 0n) { + return 0n; + } + + const markets = normalizeMarginMarketsInput(params.markets); + const marginForSize = (sizeBaseLots: bigint): bigint => + toBigInt( + computeDraftOrderMarginRequirementFromInputs({ + subaccount: params.subaccount, + markets, + draftOrder: { + ...params.draftOrder, + sizeBaseLots, + }, + options: params.options, + }).marginRequirementQuoteLots + ); + + if (marginForSize(maxSizeBaseLots) <= availableMarginQuoteLots) { + return maxSizeBaseLots; + } + + // Initial-margin requirements are monotonic in draft base size under the + // current margin formulas; the positive-delta clamp preserves that for + // reducing and flip-through-zero market drafts. + let low = 0n; + let high = maxSizeBaseLots; + let best = 0n; + + while (low <= high) { + const mid = (low + high) / 2n; + if (marginForSize(mid) <= availableMarginQuoteLots) { + best = mid; + low = mid + 1n; + } else { + high = mid - 1n; + } + } + + return best; +}; + +/** + * Snapshot-shaped variant of + * `computeMaxDraftOrderSizeForAvailableMarginFromInputs`. + */ +export const computeMaxDraftOrderSizeForAvailableMarginFromSnapshot = (params: { + subaccount: MarginSnapshotSubaccount; + markets: MarginMarketsInput; + draftOrder: Omit; + maxSizeBaseLots: string | number | bigint; + availableMarginQuoteLots: string | number | bigint; + options?: MarginCalculationOptions; +}): bigint => + computeMaxDraftOrderSizeForAvailableMarginFromInputs({ + subaccount: buildSubaccountMarginInputsFromSnapshot(params.subaccount), + markets: params.markets, + draftOrder: params.draftOrder, + maxSizeBaseLots: params.maxSizeBaseLots, + availableMarginQuoteLots: params.availableMarginQuoteLots, + options: params.options, + }); diff --git a/ts/src/margin/index.ts b/ts/src/margin/index.ts index f71aae3..d6e2906 100644 --- a/ts/src/margin/index.ts +++ b/ts/src/margin/index.ts @@ -4,6 +4,17 @@ export * from "./marketParamsStore"; export * from "./inputs"; export * from "./snapshot"; export * from "./liquidation"; +export { + computeDraftOrderMarginRequirementFromInputs, + computeDraftOrderMarginRequirementFromSnapshot, + computeMaxDraftOrderSizeForAvailableMarginFromInputs, + computeMaxDraftOrderSizeForAvailableMarginFromSnapshot, +} from "./draftOrders"; +export type { + DraftOrderMarginInput, + DraftOrderMarginRequirementResult, + MarginMarketsInput, +} from "./draftOrders"; export { buildMarketParamsBySymbol as buildMarketParamsBySymbolFromParams, computeSubaccountLiquidationPricesFromInputs, diff --git a/ts/src/margin/normalize.ts b/ts/src/margin/normalize.ts index b5072b6..2361d2d 100644 --- a/ts/src/margin/normalize.ts +++ b/ts/src/margin/normalize.ts @@ -27,7 +27,9 @@ export type NormalizedMarketParamsBySymbol = Record< NormalizedMarketParams >; -const parseLeverageTiers = (tiers: LeverageTierParams[]): LeverageTier[] => +const parseLeverageTiers = ( + tiers: readonly LeverageTierParams[] +): LeverageTier[] => tiers.map((tier) => ({ upperBoundSize: toBigInt(tier.upperBoundSize), maxLeverage: toBigInt(tier.maxLeverage), @@ -61,7 +63,7 @@ export const normalizeMarketParams = ( }); export const buildNormalizedMarketParamsBySymbol = ( - markets: MarketParams[] + markets: readonly MarketParams[] ): NormalizedMarketParamsBySymbol => { const map: NormalizedMarketParamsBySymbol = {}; for (const market of markets) { diff --git a/ts/src/margin/snapshot.ts b/ts/src/margin/snapshot.ts index 5856745..149c348 100644 --- a/ts/src/margin/snapshot.ts +++ b/ts/src/margin/snapshot.ts @@ -2,6 +2,7 @@ import type { LimitOrderMarginInput, MarginPositionState, MarketMarginInputs, + SpotCollateralMarginInput, SubaccountMarginInputs, TraderMarginInputs, } from "./types"; @@ -32,13 +33,42 @@ export interface MarginSnapshotPosition { accumulatedFundingQuoteLots: string; } +/** Raw spot collateral balance as carried by the traderState stream. */ +export interface MarginSnapshotSpotCollateral { + assetIndex: number; + symbol: string; + /** Balance in the asset's native units (lamports for SOL). */ + balance: string; +} + export interface MarginSnapshotSubaccount { subaccountIndex: number; collateral?: string | null; + spotCollaterals?: MarginSnapshotSpotCollateral[]; positions?: MarginSnapshotPosition[]; orders?: MarginSnapshotLimitOrderEvent[]; } +/** + * Per-asset valuation context for spot collateral balances: native decimals, + * pricing market, and the margin discount curve from `/v1/collateral/assets`. + * A snapshot balance whose asset has no entry here is left unvalued (the + * pre-spot behavior). + */ +export interface SpotCollateralAssetParams { + assetIndex: number; + decimals: number; + pricingMarketSymbol?: string; + indexPriceTicks?: string; + maxGlobalBalance: string; + minMarginDiscountBps: number; + maxMarginDiscountBps: number; +} + +export interface MarginSnapshotOptions { + spotAssetParamsByIndex?: Record; +} + export interface MarginTraderSnapshotMessage { authority: string; traderPdaIndex: number; @@ -100,7 +130,8 @@ export const buildMarketMarginInputsFromSnapshot = ( }; export const buildSubaccountMarginInputsFromSnapshot = ( - subaccount: MarginSnapshotSubaccount + subaccount: MarginSnapshotSubaccount, + options?: MarginSnapshotOptions ): SubaccountMarginInputs => { const positionsBySymbol = new Map(); for (const position of subaccount.positions ?? []) { @@ -128,15 +159,39 @@ export const buildSubaccountMarginInputsFromSnapshot = ( ); } + const spotAssetParamsByIndex = options?.spotAssetParamsByIndex; + const spotCollaterals: SpotCollateralMarginInput[] = []; + if (spotAssetParamsByIndex) { + for (const spot of subaccount.spotCollaterals ?? []) { + const assetParams = spotAssetParamsByIndex[spot.assetIndex]; + if (!assetParams) { + continue; + } + spotCollaterals.push({ + assetIndex: spot.assetIndex, + symbol: spot.symbol, + balance: spot.balance, + decimals: assetParams.decimals, + pricingMarketSymbol: assetParams.pricingMarketSymbol, + indexPriceTicks: assetParams.indexPriceTicks, + maxGlobalBalance: assetParams.maxGlobalBalance, + minMarginDiscountBps: assetParams.minMarginDiscountBps, + maxMarginDiscountBps: assetParams.maxMarginDiscountBps, + }); + } + } + return { subaccountIndex: subaccount.subaccountIndex, collateralBalanceQuoteLots: subaccount.collateral ?? "0", markets, + ...(spotCollaterals.length > 0 ? { spotCollaterals } : {}), }; }; export const buildTraderMarginInputsFromSnapshot = ( - message: MarginTraderSnapshotMessage | MarginTraderStateSnapshotMessage + message: MarginTraderSnapshotMessage | MarginTraderStateSnapshotMessage, + options?: MarginSnapshotOptions ): TraderMarginInputs => { if (message.messageType !== "snapshot") { throw new Error("TraderState message must be a snapshot to build inputs"); @@ -146,6 +201,8 @@ export const buildTraderMarginInputsFromSnapshot = ( return { authority: message.authority, traderPdaIndex: message.traderPdaIndex, - subaccounts: subaccounts.map(buildSubaccountMarginInputsFromSnapshot), + subaccounts: subaccounts.map((subaccount) => + buildSubaccountMarginInputsFromSnapshot(subaccount, options) + ), }; }; diff --git a/ts/src/margin/types.ts b/ts/src/margin/types.ts index 5b8f34a..4432ae6 100644 --- a/ts/src/margin/types.ts +++ b/ts/src/margin/types.ts @@ -94,10 +94,43 @@ export interface MarginCalculationOptions { orderLeverageLimitsBySymbol?: OrderLeverageLimitsBySymbol; } +/** + * One spot collateral asset (native SOL today) with the context needed to + * value it the way the on-chain RiskView does: balance at the pricing perp + * market's index price, discounted along the linear margin-discount curve. + * The curve parameters come from `/v1/collateral/assets`. + */ +export interface SpotCollateralMarginInput { + assetIndex: number; + /** Spot asset symbol ("SOL"), not necessarily a perp market symbol. */ + symbol: string; + /** Balance in the asset's native units (lamports for SOL). */ + balance: string; + /** Native-unit decimals of the asset (9 for SOL). */ + decimals: number; + /** + * Perp market whose price values the asset. Defaults to `symbol`. + */ + pricingMarketSymbol?: string; + /** + * Valuation price in ticks of the pricing market. Defaults to the pricing + * market's mark price (on-chain uses the index price; supply it here when + * available). + */ + indexPriceTicks?: string; + /** Global balance cap in native units — the discount curve's right endpoint. */ + maxGlobalBalance: string; + /** Margin discount at zero balance, basis points. */ + minMarginDiscountBps: number; + /** Margin discount at the global cap, basis points. */ + maxMarginDiscountBps: number; +} + export interface SubaccountMarginInputs { subaccountIndex: number; collateralBalanceQuoteLots: string; markets: MarketMarginInputs[]; + spotCollaterals?: SpotCollateralMarginInput[]; } export interface TraderMarginInputs { @@ -142,6 +175,18 @@ export interface MarginTotals { discountedPnlForWithdrawalsQuoteLots: string; unsettledFundingQuoteLots: string; accumulatedFundingQuoteLots: string; + /** + * Undiscounted spot collateral notional included in portfolioValue. + * Present only when the inputs carry spot collaterals. + */ + spotCollateralNotionalQuoteLots?: string; + /** + * Discounted spot collateral value included in effectiveCollateral (but + * never in effectiveCollateralForWithdrawals, mirroring the on-chain + * WithdrawQuoteCollateral semantics). Present only when the inputs carry + * spot collaterals. + */ + spotCollateralDiscountedQuoteLots?: string; riskState: MarginRiskState; riskTier: MarginRiskTier; } @@ -184,11 +229,25 @@ export interface OrderMarginResult { marginFactorBps: string; } +/** One spot collateral asset valued for margin. */ +export interface SpotCollateralMarginResult { + assetIndex: number; + symbol: string; + /** Balance in the asset's native units. */ + balance: string; + /** Balance valued at the pricing market's price (undiscounted). */ + notionalQuoteLots: string; + /** Notional with the margin discount applied. */ + discountedQuoteLots: string; +} + export interface SubaccountMarginResult { subaccountIndex: number; margin: MarginTotals; marketMargins: MarketMarginResult[]; limitOrders: OrderMarginResult[]; + /** Present only when the inputs carry spot collaterals. */ + spotCollaterals?: SpotCollateralMarginResult[]; } export interface TraderMarginResult { diff --git a/ts/src/trader-state/manager.ts b/ts/src/trader-state/manager.ts index 0a30ba7..85ddb30 100644 --- a/ts/src/trader-state/manager.ts +++ b/ts/src/trader-state/manager.ts @@ -293,6 +293,7 @@ const buildSubaccountSnapshotFromState = ( subaccountIndex: state.subaccountIndex, sequence: state.sequence, collateral: state.collateral, + spotCollaterals: [...(state.spotCollaterals ?? [])], capabilities: state.capabilities, cooldownStatus: state.cooldownStatus, positions, @@ -356,6 +357,7 @@ const buildSubaccountStateFromSnapshot = ( subaccountIndex: snapshot.subaccountIndex, sequence: snapshot.sequence, collateral: snapshot.collateral, + spotCollaterals: snapshot.spotCollaterals ?? [], capabilities: snapshot.capabilities, cooldownStatus: snapshot.cooldownStatus, positionSymbols, @@ -380,6 +382,7 @@ const buildInitialSubaccountState = ( subaccountIndex, sequence: 0, collateral: "0", + spotCollaterals: [], positions: [], orders: [], splines: [], @@ -625,6 +628,7 @@ const applyDeltaToSubaccount = ( subaccountIndex: delta.subaccountIndex, sequence: delta.sequence, collateral: delta.collateral, + spotCollaterals: delta.spotCollaterals ?? current.spotCollaterals ?? [], capabilities: delta.capabilities ?? current.capabilities, cooldownStatus: delta.cooldownStatus ?? current.cooldownStatus, positionSymbols: positions.positionSymbols, diff --git a/ts/src/trader-state/types.ts b/ts/src/trader-state/types.ts index 5ff99a8..d48966a 100644 --- a/ts/src/trader-state/types.ts +++ b/ts/src/trader-state/types.ts @@ -8,6 +8,7 @@ import type { TraderStateServerMessage, TraderStateSnapshotResponse, TraderStateSplineSnapshot, + TraderStateSpotCollateral, TraderStateSubaccountSnapshot, TraderStateTradeHistoryDelta, TraderStateTriggerSnapshot, @@ -78,6 +79,7 @@ export interface TraderStateSubaccountState { subaccountIndex: number; sequence: number; collateral: string; + spotCollaterals: readonly TraderStateSpotCollateral[]; capabilities?: TraderStateCapabilities; cooldownStatus?: CooldownStatus; positionSymbols: readonly string[]; diff --git a/ts/src/types/index.ts b/ts/src/types/index.ts index 0a21786..31d3ca4 100644 --- a/ts/src/types/index.ts +++ b/ts/src/types/index.ts @@ -11,6 +11,8 @@ export { type CapabilityAccess, type TraderCapabilities, TraderCapabilitiesSchema, + type SpotCollateralBalance, + SpotCollateralBalanceSchema, type TraderView, TraderViewSchema, } from "./trader"; diff --git a/ts/src/types/trader.ts b/ts/src/types/trader.ts index 82894fd..96b8653 100644 --- a/ts/src/types/trader.ts +++ b/ts/src/types/trader.ts @@ -132,6 +132,25 @@ export const TraderCapabilitiesSchema: z.ZodType = z.object( } ); +export interface SpotCollateralBalance { + assetIndex: number; + symbol: string; + balance: TokenAmount; + notional: TokenAmount; + discounted: TokenAmount; + withdrawable: TokenAmount; +} + +export const SpotCollateralBalanceSchema: z.ZodType = + z.object({ + assetIndex: z.number(), + symbol: z.string(), + balance: TokenAmountSchema, + notional: TokenAmountSchema, + discounted: TokenAmountSchema, + withdrawable: TokenAmountSchema, + }); + export interface TraderView { flags: number; state: string; @@ -143,6 +162,7 @@ export interface TraderView { traderSubaccountIndex: number; authority: Authority; collateralBalance: TokenAmount; + spotCollaterals?: SpotCollateralBalance[]; effectiveCollateral: TokenAmount; effectiveCollateralForWithdrawals: TokenAmount; unrealizedPnl: TokenAmount; @@ -178,6 +198,10 @@ export const TraderViewSchema: z.ZodType = z traderSubaccountIndex: z.number(), authority: zAuthority, collateralBalance: TokenAmountSchema, + spotCollaterals: z + .array(SpotCollateralBalanceSchema) + .optional() + .default([]), effectiveCollateral: TokenAmountSchema, effectiveCollateralForWithdrawals: TokenAmountSchema, unsettledFundingOwed: TokenAmountSchema, diff --git a/ts/test-fixtures/sdk-instruction-fixtures.json b/ts/test-fixtures/sdk-instruction-fixtures.json index 8d3e7e6..942a11b 100644 --- a/ts/test-fixtures/sdk-instruction-fixtures.json +++ b/ts/test-fixtures/sdk-instruction-fixtures.json @@ -291,6 +291,16 @@ "discriminatorHex": "9da33f1bf248fb61", "discriminatorBase64": "naM/G/JI+2E=" }, + { + "program": "phoenix-eternal", + "enumName": "PhoenixInstruction", + "instructionName": "AuthorizedTransferCollateral", + "snakeCaseName": "authorized_transfer_collateral", + "preimage": "global:authorized_transfer_collateral", + "tag": "2700316393894958814", + "discriminatorHex": "ded6450f74737925", + "discriminatorBase64": "3tZFD3RzeSU=" + }, { "program": "phoenix-eternal", "enumName": "PhoenixInstruction", diff --git a/ts/tests/exchange-metadata-client.test.ts b/ts/tests/exchange-metadata-client.test.ts index c8a0c29..2b131db 100644 --- a/ts/tests/exchange-metadata-client.test.ts +++ b/ts/tests/exchange-metadata-client.test.ts @@ -5,13 +5,15 @@ import { Direction, decodeGlobalConfiguration, flight, + OrderFlags, + SelfTradeBehavior, StopLossOrderKind, } from "@/index"; import { MarginType } from "@/primitives"; -import { baseLots, ticks } from "@/primitives/_numberTypes"; +import { baseLots, quoteLots, ticks } from "@/primitives/_numberTypes"; import { Side } from "@/primitives/Side"; import type { PerpAssetMetadata } from "@/accounts"; -import type { ExchangeSnapshotView } from "@/api/exchange/types"; +import type { ExchangeKeys, ExchangeSnapshotView } from "@/api/exchange/types"; import type { ExchangeDeltaMsg, ExchangeMsg, @@ -29,6 +31,7 @@ import * as coreHelpers from "@/core/helpers"; const TESTS_DIR = dirname(fileURLToPath(import.meta.url)); const MOCKS_DIR = resolve(TESTS_DIR, "mocks"); const ORIGINAL_ENV = { ...process.env }; +const ROOT_AUTHORITY = address("So11111111111111111111111111111111111111112"); type FixtureFile = { account: { @@ -44,7 +47,7 @@ const buildSnapshot = (): ExchangeSnapshotView => ({ programId: "program-id", globalConfig: "global-config", currentAuthorities: { - rootAuthority: "root", + rootAuthority: ROOT_AUTHORITY, riskAuthority: "risk", marketAuthority: "market", oracleAuthority: "oracle", @@ -117,6 +120,21 @@ const buildSnapshot = (): ExchangeSnapshotView => ({ ], }); +const buildExchangeKeys = (): ExchangeKeys => { + const exchange = buildSnapshot().exchange; + return { + globalConfig: exchange.globalConfig, + currentAuthorities: exchange.currentAuthorities, + pendingAuthorities: exchange.currentAuthorities, + canonicalMint: exchange.canonicalMint, + globalVault: exchange.globalVault, + perpAssetMap: exchange.perpAssetMap, + globalTraderIndex: exchange.globalTraderIndex, + activeTraderBuffer: exchange.activeTraderBuffer, + withdrawQueue: exchange.withdrawQueue, + }; +}; + const buildSnapshotMsg = ( sequenceNumber: bigint, snapshot: ExchangeSnapshotView @@ -1014,6 +1032,102 @@ describe("exchange metadata client integration", () => { client.dispose(); }); + it("derives delegated Flight transfer permission from the exchange metadata snapshot", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = new URL(String(input)).pathname; + return new Response( + stringifyWithBigints( + path === "/v1/view/exchange/keys" + ? buildExchangeKeys() + : buildSnapshot() + ), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const authority = address("11111111111111111111111111111111"); + // Distinct from `authority`: the tail is only appended for + // delegate-signed orders (positionAuthority !== authority). + const delegateSigner = address( + "F952dz4aHVUu75YdxUrGhLejhCfzXCYVDysDw6yL6uT4" + ); + const builderAuthority = address( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ); + const feeCollectorTrader = address( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + ) as never; + + const client = createPhoenixClient({ + baseUrl: "https://example.com", + ws: false, + flight: { + builderAuthority, + builderPdaIndex: 4, + builderSubaccountIndex: 7, + }, + }); + vi.spyOn(client.pda, "getTraderAddress").mockResolvedValue( + feeCollectorTrader + ); + + const buildDelegatedIx = () => + client.ixs.placeMarketOrderDelegated({ + authority, + positionAuthority: delegateSigner, + permissionAccount: authority, + symbol: "SOL-PERP", + orderPacket: { + side: Side.Ask, + priceInTicks: ticks(99n), + numBaseLots: baseLots(3n), + numQuoteLots: null, + minBaseLotsToFill: baseLots(3n), + minQuoteLotsToFill: quoteLots(1n), + selfTradeBehavior: SelfTradeBehavior.Abort, + matchLimit: null, + clientOrderId: 0n, + lastValidSlot: null, + orderFlags: OrderFlags.ReduceOnly, + cancelExisting: false, + }, + }); + + const firstIx = await buildDelegatedIx(); + const secondIx = await buildDelegatedIx(); + const expectedCollateralAuthority = + await flight.getFlightCollateralTransferAuthorityAddress( + client.pda.getProgramAddress() + ); + const expectedPermission = + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + ROOT_AUTHORITY, + client.pda.getProgramAddress() + ); + + for (const ix of [firstIx, secondIx]) { + expect(ix.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(ix.accounts[5]?.address).toBe(delegateSigner); + expect(ix.accounts.at(-2)?.address).toBe(expectedCollateralAuthority); + expect(ix.accounts.at(-1)?.address).toBe(expectedPermission); + } + // The root authority always comes from the exchange metadata snapshot; + // the keys endpoint must never be consulted. + expect( + fetchMock.mock.calls.filter( + ([input]) => + new URL(String(input)).pathname === "/v1/view/exchange/keys" + ) + ).toHaveLength(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + client.dispose(); + }); + it("routes position conditional order ixs through flight when the client is configured with a fee collector", async () => { const fetchMock = vi.fn(async () => { return new Response(stringifyWithBigints(buildSnapshot()), { diff --git a/ts/tests/flame.test.ts b/ts/tests/flame.test.ts index 8ceb9bf..e35cd28 100644 --- a/ts/tests/flame.test.ts +++ b/ts/tests/flame.test.ts @@ -1,5 +1,6 @@ import { FLAME_PROGRAM_ADDRESS, + buildFlameAtomicDepositFlow, buildFlameDepositFundingFlow, deriveFlameDepositAddress, deriveFlameDepositAddresses, @@ -17,9 +18,11 @@ import { getPhoenixTraderTokenAccountAddress } from "@/pdas"; import type { Authority, EmberStateAddress, + FlameAtomicDepositFlowParams, MintAddress, PhoenixInstructionClient, } from "@/index"; +import type { PhoenixExchangeMetadata } from "@/exchange-cache/types"; import { AccountRole, address } from "@solana/kit"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -30,6 +33,20 @@ const feePayer = address("11111111111111111111111111111111") as Authority; const vectorAuthority = address( "11111111111111111111111111111112" ) as Authority; +const marketAuthority = address( + "11111111111111111111111111111113" +) as Authority; +const globalVault = address("11111111111111111111111111111114") as Authority; +const perpAssetMap = address("11111111111111111111111111111115") as Authority; +const globalTraderIndex = address( + "11111111111111111111111111111116" +) as Authority; +const activeTraderBuffer = address( + "11111111111111111111111111111117" +) as Authority; +const withdrawQueue = address( + "So11111111111111111111111111111111111111112" +) as Authority; const vectorMint = address( "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" ) as MintAddress; @@ -61,6 +78,43 @@ const client = { }, } satisfies PhoenixInstructionClient; +const atomicClient = { + ...client, + exchange: { + ready: async () => ({ + version: 1, + slot: 1n, + slotIndex: 0, + markets: [], + exchange: { + programId: PHOENIX_PROGRAM_ADDRESS, + globalConfig: PHOENIX_GLOBAL_CONFIGURATION_ADDRESS, + currentAuthorities: { + rootAuthority: marketAuthority, + riskAuthority: marketAuthority, + marketAuthority, + oracleAuthority: marketAuthority, + adlAuthority: marketAuthority, + cancelAuthority: marketAuthority, + backstopAuthority: marketAuthority, + }, + canonicalMint: vectorMint, + usdcMint: USDC_MINT_ADDRESS, + globalVault, + perpAssetMap, + globalTraderIndex: [globalTraderIndex], + activeTraderBuffer: [activeTraderBuffer], + withdrawQueue, + exchangeStatusBits: 0, + exchangeStatusFeatures: [], + active: true, + gated: false, + withdrawalsAvailable: true, + }, + }), + } as Pick as PhoenixExchangeMetadata, +} satisfies PhoenixInstructionClient; + const originalPhoenixEnv = process.env.PHOENIX_ENV; const originalNextPublicPhoenixEnv = process.env.NEXT_PUBLIC_PHOENIX_ENV; @@ -197,4 +251,95 @@ describe("Flame deposit helpers", () => { 3, 128, 222, 128, 2, 0, 0, 0, 0, ]); }); + + it("builds an atomic sponsored Flame deposit bundle", async () => { + const result = await buildFlameAtomicDepositFlow( + { + authority, + amount: 42_000_000n, + traderPdaIndex: 7, + feePayer, + sponsorshipToken: "test-token", + }, + atomicClient + ); + + expect(result.instructions).toEqual([ + result.named.createProxyAta, + result.named.transferUsdcToProxy, + result.named.createPermission, + result.named.setPermission, + result.named.depositToPhoenix, + ]); + expect(result.traderPdaIndex).toBe(7); + expect(result.traderSubaccountIndex).toBe(0); + + expect(result.named.createPermission.accounts[2]).toEqual({ + address: feePayer, + role: AccountRole.WRITABLE_SIGNER, + }); + expect(result.named.setPermission.accounts[3]).toEqual({ + address: authority, + role: AccountRole.READONLY_SIGNER, + }); + expect(result.named.setPermission.accounts[4]).toEqual({ + address: result.proxyAuthority, + role: AccountRole.READONLY, + }); + + const depositToPhoenix = result.named.depositToPhoenix; + expect(depositToPhoenix.programAddress).toBe(FLAME_PROGRAM_ADDRESS); + expect(depositToPhoenix.accounts[2]).toEqual({ + address: feePayer, + role: AccountRole.WRITABLE_SIGNER, + }); + expect(depositToPhoenix.accounts[3]).toEqual({ + address: authority, + role: AccountRole.READONLY, + }); + expect(depositToPhoenix.accounts[4]).toEqual({ + address: result.proxyAuthority, + role: AccountRole.READONLY, + }); + expect(depositToPhoenix.accounts[5]).toEqual({ + address: result.proxyAta, + role: AccountRole.WRITABLE, + }); + expect(Array.from(depositToPhoenix.data.slice(8))).toEqual([7, 0]); + }); + + it("defaults the Flame crank to the sponsor fee payer", async () => { + const result = await buildFlameAtomicDepositFlow( + { + authority, + amount: 42_000_000n, + traderPdaIndex: 7, + feePayer, + sponsorshipToken: "test-token", + }, + atomicClient + ); + + expect(result.named.depositToPhoenix.accounts[2]).toEqual({ + address: feePayer, + role: AccountRole.WRITABLE_SIGNER, + }); + }); + + it("rejects non-zero trader subaccounts for sponsored atomic deposits", async () => { + await expect( + buildFlameAtomicDepositFlow( + { + authority, + feePayer, + sponsorshipToken: "token", + amount: 1_000_000n, + traderSubaccountIndex: 1, + } as FlameAtomicDepositFlowParams & { traderSubaccountIndex: number }, + atomicClient + ) + ).rejects.toThrow( + "Flame atomic deposit sponsorship only supports traderSubaccountIndex 0" + ); + }); }); diff --git a/ts/tests/flight-routing.test.ts b/ts/tests/flight-routing.test.ts index 3ee6def..afa62ef 100644 --- a/ts/tests/flight-routing.test.ts +++ b/ts/tests/flight-routing.test.ts @@ -1,6 +1,26 @@ +import type { + PhoenixInstructionClient, + PhoenixMarketDataClient, +} from "@/core/clientTypes"; import { DISCRIMINANTS } from "@/core/discriminants"; -import { flight } from "@/index"; -import { address } from "@solana/kit"; +import type { PhoenixExchangeMetadata } from "@/exchange-cache/types"; +import { + MarginType, + OrderFlags, + SelfTradeBehavior, + Side, + baseLots, + buildPlaceLimitOrderFlow, + buildPlaceMarketOrderFlow, + createPhoenixIxClient, + createPhoenixPdaClient, + flight, + getPhoenixTraderSubaccountAddress, + quoteLots, + ticks, +} from "@/index"; +import type { ImmediateOrCancelOrderPacket } from "@/primitives"; +import { AccountRole, address } from "@solana/kit"; import { describe, expect, it, vi } from "vitest"; describe("flight instruction routing", () => { @@ -14,14 +34,19 @@ describe("flight instruction routing", () => { const traderAuthority = address( "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" ); + const rootAuthority = address("So11111111111111111111111111111111111111112"); + const getCollateralTransferAuthority = () => + flight.getFlightCollateralTransferAuthorityAddress(phoenixProgramAddress); it("wraps Flight-routable placement instructions", async () => { const resolveFeeCollectorTraderAddress = vi.fn( async () => feeCollectorTrader ); + const resolveRootAuthority = vi.fn(async () => rootAuthority); for (const discriminant of [ DISCRIMINANTS.PLACE_MARKET_ORDER, + DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED, DISCRIMINANTS.PLACE_LIMIT_ORDER, DISCRIMINANTS.PLACE_STOP_LOSS, DISCRIMINANTS.PLACE_POSITION_CONDITIONAL_ORDER, @@ -35,22 +60,28 @@ describe("flight instruction routing", () => { accounts: [], data, }, - authority: traderAuthority, + signer: traderAuthority, phoenixProgramAddress, flight: { builderAuthority }, resolveFeeCollectorTraderAddress, + resolveRootAuthority, }); expect(wrapped.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); expect(wrapped.accounts[2]?.address).toBe(builderAuthority); expect(wrapped.accounts[3]?.address).toBe(feeCollectorTrader); expect(wrapped.accounts[5]?.address).toBe(traderAuthority); + // The collateral-transfer tail is never inferred from the inner + // instruction — delegated market orders included — so undeclared + // wraps carry exactly the six proxy accounts. + expect(wrapped.accounts.length).toBe(6); expect(Array.from(wrapped.data?.slice(8) ?? [])).toEqual( Array.from(data) ); } - expect(resolveFeeCollectorTraderAddress).toHaveBeenCalledTimes(6); + expect(resolveFeeCollectorTraderAddress).toHaveBeenCalledTimes(7); + expect(resolveRootAuthority).not.toHaveBeenCalled(); }); it("wraps Flight-routable placement instructions with a fee override", async () => { @@ -65,7 +96,7 @@ describe("flight instruction routing", () => { accounts: [], data, }, - authority: traderAuthority, + signer: traderAuthority, phoenixProgramAddress, flight: { builderAuthority, feeBpsOverride: 5n }, resolveFeeCollectorTraderAddress, @@ -113,6 +144,84 @@ describe("flight instruction routing", () => { expect(Array.from(wrapped.data?.slice(17) ?? [])).toEqual(Array.from(data)); }); + it("builds position-authority proxy instructions with transfer permission accounts", async () => { + const data = new Uint8Array([ + ...DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED, + 1, + 2, + 3, + ]); + const collateralTransferAuthority = await getCollateralTransferAuthority(); + const collateralTransferPermissionAccount = + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ); + + const wrapped = await flight.buildProxyInstructionIx({ + phoenixProgramAddress, + builderAuthority, + builderTraderAccount: feeCollectorTrader, + traderWallet: traderAuthority, + rootAuthority, + innerInstruction: { + programAddress: phoenixProgramAddress, + accounts: [], + data, + }, + }); + + expect(wrapped.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(wrapped.accounts.at(-2)?.address).toBe(collateralTransferAuthority); + expect(wrapped.accounts.at(-1)?.address).toBe( + collateralTransferPermissionAccount + ); + expect(Array.from(wrapped.data?.slice(8) ?? [])).toEqual(Array.from(data)); + }); + + it("scopes collateral-transfer authority PDAs to the target program", async () => { + const otherTargetProgramAddress = address( + "11111111111111111111111111111111" + ) as never; + + const [authority, otherAuthority] = await Promise.all([ + getCollateralTransferAuthority(), + flight.getFlightCollateralTransferAuthorityAddress( + otherTargetProgramAddress + ), + ]); + const [permission, otherPermission] = await Promise.all([ + flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ), + flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + otherTargetProgramAddress + ), + ]); + + expect(authority).not.toBe(otherAuthority); + expect(permission).not.toBe(otherPermission); + }); + + it("builds delegated market proxy instructions without the tail unless position authority is declared", async () => { + const wrapped = await flight.buildProxyInstructionIx({ + phoenixProgramAddress, + builderAuthority, + builderTraderAccount: feeCollectorTrader, + traderWallet: traderAuthority, + innerInstruction: { + programAddress: phoenixProgramAddress, + accounts: [], + data: new Uint8Array(DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED), + }, + }); + + expect(wrapped.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(wrapped.accounts.length).toBe(6); + }); + it("rejects an invalid fee override", async () => { await expect( flight.buildProxyInstructionIx({ @@ -127,7 +236,7 @@ describe("flight instruction routing", () => { data: new Uint8Array(DISCRIMINANTS.PLACE_LIMIT_ORDER), }, }) - ).rejects.toThrow("Fee bps override must be in the range 0..=10000"); + ).rejects.toThrow("Invalid fee bps override (must be in 0..=10000)"); }); it("leaves unsupported instructions unchanged", async () => { @@ -143,7 +252,7 @@ describe("flight instruction routing", () => { const wrapped = await flight.wrapInstructionWithFlight({ phoenixInstruction: instruction, - authority: traderAuthority, + signer: traderAuthority, phoenixProgramAddress, flight: { builderAuthority }, resolveFeeCollectorTraderAddress, @@ -153,10 +262,17 @@ describe("flight instruction routing", () => { expect(resolveFeeCollectorTraderAddress).not.toHaveBeenCalled(); }); - it("leaves delegated market orders unchanged", async () => { + it("wraps delegated market orders without the tail unless position authority is declared", async () => { const resolveFeeCollectorTraderAddress = vi.fn( async () => feeCollectorTrader ); + const resolveRootAuthority = vi.fn(async () => rootAuthority); + const collateralTransferAuthority = await getCollateralTransferAuthority(); + const collateralTransferPermissionAccount = + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ); const data = new Uint8Array([ ...DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED, 1, @@ -167,15 +283,635 @@ describe("flight instruction routing", () => { data, }; + // Owner-signed delegated market orders wrap plainly: nothing is + // appended and the root authority is never resolved. + const plain = await flight.wrapInstructionWithFlight({ + phoenixInstruction: instruction, + signer: traderAuthority, + phoenixProgramAddress, + flight: { builderAuthority }, + resolveFeeCollectorTraderAddress, + resolveRootAuthority, + }); + + expect(plain).not.toBe(instruction); + expect(plain.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(plain.accounts.length).toBe(6); + expect(resolveRootAuthority).not.toHaveBeenCalled(); + + const declared = await flight.wrapInstructionWithFlight({ + phoenixInstruction: instruction, + signer: traderAuthority, + phoenixProgramAddress, + flight: { builderAuthority }, + usePositionAuthority: true, + resolveFeeCollectorTraderAddress, + resolveRootAuthority, + }); + + expect(declared.accounts.slice(0, -2)).toEqual(plain.accounts); + expect(declared.accounts.at(-2)?.address).toBe(collateralTransferAuthority); + expect(declared.accounts.at(-1)?.address).toBe( + collateralTransferPermissionAccount + ); + expect(resolveRootAuthority).toHaveBeenCalledOnce(); + }); + + it("appends the collateral transfer tail to position-authority market order wraps", async () => { + const resolveFeeCollectorTraderAddress = vi.fn( + async () => feeCollectorTrader + ); + const resolveRootAuthority = vi.fn(async () => rootAuthority); + const collateralTransferAuthority = await getCollateralTransferAuthority(); + const collateralTransferPermissionAccount = + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ); + const instruction = { + programAddress: phoenixProgramAddress, + accounts: [], + data: new Uint8Array([...DISCRIMINANTS.PLACE_MARKET_ORDER, 1, 2, 3]), + }; + + const plain = await flight.wrapInstructionWithFlight({ + phoenixInstruction: instruction, + signer: traderAuthority, + phoenixProgramAddress, + flight: { builderAuthority }, + resolveFeeCollectorTraderAddress, + }); const wrapped = await flight.wrapInstructionWithFlight({ phoenixInstruction: instruction, - authority: traderAuthority, + signer: traderAuthority, phoenixProgramAddress, flight: { builderAuthority }, + usePositionAuthority: true, resolveFeeCollectorTraderAddress, + resolveRootAuthority, }); - expect(wrapped).toBe(instruction); - expect(resolveFeeCollectorTraderAddress).not.toHaveBeenCalled(); + expect(wrapped.accounts.slice(0, -2)).toEqual(plain.accounts); + expect(wrapped.accounts.length).toBe(plain.accounts.length + 2); + expect(wrapped.accounts.at(-2)?.address).toBe(collateralTransferAuthority); + expect(wrapped.accounts.at(-2)?.role).toBe(AccountRole.READONLY); + expect(wrapped.accounts.at(-1)?.address).toBe( + collateralTransferPermissionAccount + ); + expect(wrapped.accounts.at(-1)?.role).toBe(AccountRole.WRITABLE); + expect(resolveRootAuthority).toHaveBeenCalledOnce(); + }); + + it("rejects position-authority wraps without a root authority", async () => { + await expect( + flight.wrapInstructionWithFlight({ + phoenixInstruction: { + programAddress: phoenixProgramAddress, + accounts: [], + data: new Uint8Array(DISCRIMINANTS.PLACE_MARKET_ORDER), + }, + signer: traderAuthority, + phoenixProgramAddress, + flight: { builderAuthority }, + usePositionAuthority: true, + resolveFeeCollectorTraderAddress: vi.fn(async () => feeCollectorTrader), + }) + ).rejects.toThrow("Root authority is required"); + }); + + describe("PhoenixFlightClient root authority sourcing", () => { + const rotatedRootAuthority = address( + "F952dz4aHVUu75YdxUrGhLejhCfzXCYVDysDw6yL6uT4" + ); + + const createExchangeMetadataStub = (getRootAuthority: () => string) => { + const ready = vi.fn(async () => undefined); + const snapshot = vi.fn(() => ({ + exchange: { + currentAuthorities: { rootAuthority: getRootAuthority() }, + }, + })); + return { + metadata: { ready, snapshot } as unknown as PhoenixExchangeMetadata, + ready, + snapshot, + }; + }; + + const createInstructionClientStub = ( + exchange?: PhoenixExchangeMetadata + ): PhoenixInstructionClient => + ({ + addresses: { phoenixProgramAddress }, + fetchAccount: async () => ({ data: new Uint8Array() }), + exchange, + }) as unknown as PhoenixInstructionClient; + + const delegatedInstruction = () => ({ + programAddress: phoenixProgramAddress, + accounts: [], + data: new Uint8Array([ + ...DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED, + 1, + 2, + 3, + ]), + }); + + it("resolves the root authority from the exchange snapshot on every wrap", async () => { + let currentRootAuthority: string = rootAuthority; + const exchange = createExchangeMetadataStub(() => currentRootAuthority); + const flightClient = new flight.PhoenixFlightClient( + createInstructionClientStub(exchange.metadata), + { builderAuthority } + ); + + const firstWrap = await flightClient.tryWrapOrderInstruction( + delegatedInstruction(), + traderAuthority, + true + ); + currentRootAuthority = rotatedRootAuthority; + const secondWrap = await flightClient.tryWrapOrderInstruction( + delegatedInstruction(), + traderAuthority, + true + ); + + expect(firstWrap.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + expect(secondWrap.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rotatedRootAuthority, + phoenixProgramAddress + ) + ); + expect(exchange.ready).toHaveBeenCalledTimes(2); + expect(exchange.snapshot).toHaveBeenCalledTimes(2); + }); + + it("resolves position-authority wrap tails from the exchange snapshot", async () => { + const exchange = createExchangeMetadataStub(() => rootAuthority); + const flightClient = new flight.PhoenixFlightClient( + createInstructionClientStub(exchange.metadata), + { builderAuthority } + ); + + const wrapped = await flightClient.tryWrapOrderInstruction( + { + programAddress: phoenixProgramAddress, + accounts: [], + data: new Uint8Array([...DISCRIMINANTS.PLACE_MARKET_ORDER, 1]), + }, + traderAuthority, + true + ); + + expect(wrapped.accounts.at(-2)?.address).toBe( + await getCollateralTransferAuthority() + ); + expect(wrapped.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + expect(exchange.ready).toHaveBeenCalledOnce(); + expect(exchange.snapshot).toHaveBeenCalledOnce(); + }); + + it("appends the tail only when usePositionAuthority is set", async () => { + const exchange = createExchangeMetadataStub(() => rootAuthority); + const flightClient = new flight.PhoenixFlightClient( + createInstructionClientStub(exchange.metadata), + { builderAuthority } + ); + const delegateSigner = rotatedRootAuthority; + + // Default (owner-signed): plain wrap, no tail, no root-authority + // resolution. + const ownerWrap = await flightClient.tryWrapOrderInstruction( + delegatedInstruction(), + traderAuthority + ); + expect(ownerWrap.accounts.length).toBe(6); + expect(ownerWrap.accounts[5]?.address).toBe(traderAuthority); + expect(exchange.snapshot).not.toHaveBeenCalled(); + + // usePositionAuthority (derived by callers as signer !== owner): + // position-authority path with the tail appended. + const delegateWrap = await flightClient.tryWrapOrderInstruction( + delegatedInstruction(), + delegateSigner, + true + ); + expect(delegateWrap.accounts.length).toBe(8); + expect(delegateWrap.accounts[5]?.address).toBe(delegateSigner); + expect(delegateWrap.accounts.at(-2)?.address).toBe( + await getCollateralTransferAuthority() + ); + expect(delegateWrap.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + expect(exchange.snapshot).toHaveBeenCalledOnce(); + }); + + it("wraps owner-signed delegated market orders without exchange metadata", async () => { + const flightClient = new flight.PhoenixFlightClient( + createInstructionClientStub(undefined), + { builderAuthority } + ); + + const wrapped = await flightClient.tryWrapOrderInstruction( + delegatedInstruction(), + traderAuthority + ); + + expect(wrapped.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(wrapped.accounts.length).toBe(6); + }); + + it("throws for position-authority wraps without exchange metadata", async () => { + const flightClient = new flight.PhoenixFlightClient( + createInstructionClientStub(undefined), + { builderAuthority } + ); + + await expect( + flightClient.tryWrapOrderInstruction( + { + programAddress: phoenixProgramAddress, + accounts: [], + data: new Uint8Array([...DISCRIMINANTS.PLACE_MARKET_ORDER, 1]), + }, + traderAuthority, + true + ) + ).rejects.toThrow( + "Flight position-authority orders require exchange metadata to resolve the root authority" + ); + }); + + it("wraps plain orders without exchange metadata", async () => { + const flightClient = new flight.PhoenixFlightClient( + createInstructionClientStub(undefined), + { builderAuthority } + ); + const data = new Uint8Array([...DISCRIMINANTS.PLACE_LIMIT_ORDER, 1]); + + const wrapped = await flightClient.tryWrapOrderInstruction( + { + programAddress: phoenixProgramAddress, + accounts: [], + data, + }, + traderAuthority + ); + + expect(wrapped.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(wrapped.accounts[2]?.address).toBe(builderAuthority); + expect(Array.from(wrapped.data?.slice(8) ?? [])).toEqual( + Array.from(data) + ); + }); + }); + + describe("ixs client position-authority routing", () => { + const ownerAuthority = traderAuthority; + const delegateSigner = address( + "F952dz4aHVUu75YdxUrGhLejhCfzXCYVDysDw6yL6uT4" + ); + // Proxy layout: [0..5] proxy accounts with the signer at [5], inner + // instruction accounts from [6]. Inner PlaceMarketOrder puts the trader + // wallet at [3] and the trader PDA at [4]; PlaceMarketOrderDelegated puts + // the wallet at [3], the permission at [4], and the trader PDA at [5]. + const PROXY_SIGNER = 5; + const INNER = 6; + + const orderPacket: ImmediateOrCancelOrderPacket = { + side: Side.Bid, + priceInTicks: ticks(100n), + numBaseLots: baseLots(1n), + numQuoteLots: null, + minBaseLotsToFill: baseLots(1n), + minQuoteLotsToFill: quoteLots(1n), + selfTradeBehavior: SelfTradeBehavior.Abort, + matchLimit: null, + clientOrderId: 0n, + lastValidSlot: null, + orderFlags: OrderFlags.None, + cancelExisting: false, + }; + + const createIxClient = () => + createPhoenixIxClient({ + exchange: { + ready: async () => undefined, + snapshot: () => ({ + markets: [{ symbol: "SOL-PERP" }], + exchange: { currentAuthorities: { rootAuthority } }, + }), + instructionContext: (symbol: string) => + symbol === "SOL-PERP" + ? { + exchange: { + globalConfig: "global-config", + perpAssetMap: "perp-asset-map", + globalTraderIndex: ["gti-0"], + activeTraderBuffer: ["atb-0"], + }, + market: { + marketPubkey: "market-address", + splinePubkey: "spline-address", + }, + } + : undefined, + } as unknown as PhoenixExchangeMetadata, + pda: createPhoenixPdaClient({ + programAddress: phoenixProgramAddress as never, + }), + orderPackets: {} as never, + flight: { builderAuthority }, + }); + + const expectedOwnerTraderAccount = () => + getPhoenixTraderSubaccountAddress({ + authority: ownerAuthority as never, + traderPdaIndex: 0, + subaccountIndex: 0, + phoenixProgramAddress: phoenixProgramAddress as never, + }); + + it("wraps owner-signed market orders plainly, treating positionAuthority === authority as owner-signed", async () => { + const client = createIxClient(); + + const defaultIx = await client.placeMarketOrder({ + authority: ownerAuthority as never, + symbol: "SOL-PERP" as never, + orderPacket, + }); + const explicitIx = await client.placeMarketOrder({ + authority: ownerAuthority as never, + positionAuthority: ownerAuthority as never, + symbol: "SOL-PERP" as never, + orderPacket, + }); + + for (const ix of [defaultIx, explicitIx]) { + expect(ix.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(ix.accounts[PROXY_SIGNER]?.address).toBe(ownerAuthority); + expect(ix.accounts[INNER + 3]?.address).toBe(ownerAuthority); + expect(ix.accounts[INNER + 4]?.address).toBe( + await expectedOwnerTraderAccount() + ); + expect(ix.accounts.at(-2)?.address).not.toBe( + await getCollateralTransferAuthority() + ); + } + expect(explicitIx.accounts.length).toBe(defaultIx.accounts.length); + }); + + it("routes delegate-signed market orders through the position-authority path with the owner's trader PDA", async () => { + const client = createIxClient(); + + const plainIx = await client.placeMarketOrder({ + authority: ownerAuthority as never, + symbol: "SOL-PERP" as never, + orderPacket, + }); + const delegateIx = await client.placeMarketOrder({ + authority: ownerAuthority as never, + positionAuthority: delegateSigner as never, + symbol: "SOL-PERP" as never, + orderPacket, + }); + + expect(delegateIx.accounts.length).toBe(plainIx.accounts.length + 2); + expect(delegateIx.accounts[PROXY_SIGNER]?.address).toBe(delegateSigner); + expect(delegateIx.accounts[INNER + 3]?.address).toBe(delegateSigner); + // The trader PDA still derives from the owner even though the + // delegate signs. + expect(delegateIx.accounts[INNER + 4]?.address).toBe( + await expectedOwnerTraderAccount() + ); + expect(delegateIx.accounts.at(-2)?.address).toBe( + await getCollateralTransferAuthority() + ); + expect(delegateIx.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + }); + + it("auto-declares position authority for delegate-signed delegated market orders", async () => { + const client = createIxClient(); + const permissionAccount = address( + "9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E" + ); + + const ownerIx = await client.placeMarketOrderDelegated({ + authority: ownerAuthority as never, + symbol: "SOL-PERP" as never, + orderPacket, + }); + const delegateIx = await client.placeMarketOrderDelegated({ + authority: ownerAuthority as never, + positionAuthority: delegateSigner as never, + permissionAccount, + symbol: "SOL-PERP" as never, + orderPacket, + }); + + // Owner-signed delegated market orders carry no tail. + expect(ownerIx.accounts.at(-2)?.address).not.toBe( + await getCollateralTransferAuthority() + ); + expect(delegateIx.accounts.length).toBe(ownerIx.accounts.length + 2); + expect(delegateIx.accounts[PROXY_SIGNER]?.address).toBe(delegateSigner); + expect(delegateIx.accounts[INNER + 3]?.address).toBe(delegateSigner); + expect(delegateIx.accounts[INNER + 4]?.address).toBe(permissionAccount); + expect(delegateIx.accounts[INNER + 5]?.address).toBe( + await expectedOwnerTraderAccount() + ); + expect(delegateIx.accounts.at(-2)?.address).toBe( + await getCollateralTransferAuthority() + ); + expect(delegateIx.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + }); + }); + + describe("flow builders position-authority routing", () => { + const ownerAuthority = traderAuthority; + const delegateSigner = address( + "F952dz4aHVUu75YdxUrGhLejhCfzXCYVDysDw6yL6uT4" + ); + const marketAccount = address( + "9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E" + ); + // Proxy layout: [0..5] proxy accounts with the signer at [5], inner + // instruction accounts from [6]. Inner PlaceLimitOrder/PlaceMarketOrder + // put the trader wallet at [3]. + const PROXY_SIGNER = 5; + const INNER = 6; + + const exchangeSnapshot = { + markets: [{ symbol: "SOL-PERP" }], + exchange: { + globalConfig: "global-config", + currentAuthorities: { + rootAuthority, + riskAuthority: rootAuthority, + marketAuthority: rootAuthority, + oracleAuthority: rootAuthority, + adlAuthority: rootAuthority, + cancelAuthority: rootAuthority, + backstopAuthority: rootAuthority, + }, + canonicalMint: "canonical-mint", + globalVault: "global-vault", + perpAssetMap: "perp-asset-map", + globalTraderIndex: ["gti-0"], + activeTraderBuffer: ["atb-0"], + withdrawQueue: "withdraw-queue", + exchangeStatusBits: 0, + }, + }; + + const exchangeMetadata = { + ready: async () => exchangeSnapshot, + snapshot: () => exchangeSnapshot, + instructionContext: (symbol: string) => + symbol === "SOL-PERP" + ? { + market: { + marketPubkey: marketAccount, + assetId: 0, + tickSize: 0.001, + baseLotsDecimals: 3, + splinePubkey: "spline-address", + }, + } + : undefined, + } as unknown as PhoenixExchangeMetadata; + + const createFlowFlightClient = () => + new flight.PhoenixFlightClient( + { + addresses: { + phoenixProgramAddress, + logAuthorityAddress: "log-authority", + globalConfigurationAddress: "global-config", + }, + fetchAccount: async () => ({ data: new Uint8Array() }), + exchange: exchangeMetadata, + } as unknown as PhoenixInstructionClient, + { builderAuthority } + ); + + it("wraps delegate-signed limit-order flows through the position-authority path", async () => { + const client = createFlowFlightClient(); + + const ownerFlow = await buildPlaceLimitOrderFlow( + { + authority: ownerAuthority as never, + symbol: "SOL-PERP" as never, + side: Side.Bid, + priceInTicks: ticks(100n), + numBaseLots: baseLots(1n), + marginType: MarginType.Cross, + subaccountIndex: 0, + }, + client + ); + const delegateFlow = await buildPlaceLimitOrderFlow( + { + authority: ownerAuthority as never, + positionAuthority: delegateSigner as never, + symbol: "SOL-PERP" as never, + side: Side.Bid, + priceInTicks: ticks(100n), + numBaseLots: baseLots(1n), + marginType: MarginType.Cross, + subaccountIndex: 0, + }, + client + ); + + const ownerIx = ownerFlow.named.placeLimitOrder; + const delegateIx = delegateFlow.named.placeLimitOrder; + + expect(ownerIx.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(ownerIx.accounts[PROXY_SIGNER]?.address).toBe(ownerAuthority); + expect(ownerIx.accounts[INNER + 3]?.address).toBe(ownerAuthority); + expect(ownerIx.accounts.at(-1)?.address).not.toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + + // The delegate both signs the inner instruction and sits in the proxy + // trader-wallet slot, and the collateral-transfer tail is appended. + expect(delegateIx.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(delegateIx.accounts.length).toBe(ownerIx.accounts.length + 2); + expect(delegateIx.accounts[PROXY_SIGNER]?.address).toBe(delegateSigner); + expect(delegateIx.accounts[INNER + 3]?.address).toBe(delegateSigner); + expect(delegateIx.accounts.at(-2)?.address).toBe( + await getCollateralTransferAuthority() + ); + expect(delegateIx.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + }); + + it("wraps delegate-signed market-order flows through the position-authority path", async () => { + const client = createFlowFlightClient() as PhoenixInstructionClient & + PhoenixMarketDataClient; + + const delegateFlow = await buildPlaceMarketOrderFlow( + { + authority: ownerAuthority as never, + positionAuthority: delegateSigner as never, + symbol: "SOL-PERP" as never, + side: Side.Bid, + numBaseLots: baseLots(1n), + marginType: MarginType.Cross, + subaccountIndex: 0, + priceInTicksLimit: ticks(100n), + }, + client + ); + + const delegateIx = delegateFlow.named.placeMarketOrder; + expect(delegateIx.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(delegateIx.accounts[PROXY_SIGNER]?.address).toBe(delegateSigner); + expect(delegateIx.accounts[INNER + 3]?.address).toBe(delegateSigner); + expect(delegateIx.accounts.at(-2)?.address).toBe( + await getCollateralTransferAuthority() + ); + expect(delegateIx.accounts.at(-1)?.address).toBe( + await flight.getFlightAuthorizedCollateralTransferPermissionAddress( + rootAuthority, + phoenixProgramAddress + ) + ); + }); }); }); diff --git a/ts/tests/ixs.test.ts b/ts/tests/ixs.test.ts index 28554bc..be72a62 100644 --- a/ts/tests/ixs.test.ts +++ b/ts/tests/ixs.test.ts @@ -109,7 +109,8 @@ const unexpectedOperationResolver = async (): Promise => { const createTestOperationContext = ( wrapCalls: Array<{ instruction: InstructionsWithAccountsAndData; - authority: Authority; + signer: Authority; + usePositionAuthority: boolean | undefined; }> ): PhoenixIxOperationContext => ({ orderPackets: {} as never, @@ -127,15 +128,35 @@ const createTestOperationContext = ( resolvePermissionAddress: unexpectedOperationResolver, resolveStopLossAddress: unexpectedOperationResolver, maybeWrapOrderIx: async ( + instruction: TIx, + signer: Authority, + usePositionAuthority?: boolean + ) => { + wrapCalls.push({ instruction, signer, usePositionAuthority }); + return instruction; + }, + maybeWrapConditionalOrderIx: async < + TIx extends InstructionsWithAccountsAndData, + >( instruction: TIx, authority: Authority ) => { - wrapCalls.push({ instruction, authority }); + wrapCalls.push({ + instruction, + signer: authority, + usePositionAuthority: false, + }); return instruction; }, accountExists: async () => false, }); +type WrapCall = { + instruction: InstructionsWithAccountsAndData; + signer: Authority; + usePositionAuthority: boolean | undefined; +}; + describe("ix operations", () => { const createCancelByIdOperations = ( market: { @@ -158,11 +179,8 @@ describe("ix operations", () => { resolveTraderAccount: async () => "trader-account" as never, }); - it("wraps market order entrypoints with the trader account authority", async () => { - const wrapCalls: Array<{ - instruction: InstructionsWithAccountsAndData; - authority: Authority; - }> = []; + it("wraps market order entrypoints with the effective signer", async () => { + const wrapCalls: WrapCall[] = []; const operations = createPhoenixIxOperations( createTestOperationContext(wrapCalls) ); @@ -182,12 +200,16 @@ describe("ix operations", () => { expect(wrapCalls).toHaveLength(2); expect(wrapCalls[0]?.instruction).toBe(marketIx); - expect(wrapCalls[0]?.authority).toBe("trader-authority"); + expect(wrapCalls[0]?.signer).toBe("trader-authority"); + expect(wrapCalls[0]?.usePositionAuthority).toBe(false); expect(Array.from(marketIx.data.slice(0, 8))).toEqual( Array.from(DISCRIMINANTS.PLACE_MARKET_ORDER) ); + // The delegated order names a distinct signer, so the wrap is told the + // signer is a position authority. expect(wrapCalls[1]?.instruction).toBe(delegatedIx); - expect(wrapCalls[1]?.authority).toBe("trader-authority"); + expect(wrapCalls[1]?.signer).toBe("delegated-wallet"); + expect(wrapCalls[1]?.usePositionAuthority).toBe(true); expect(Array.from(delegatedIx.data.slice(0, 8))).toEqual( Array.from(DISCRIMINANTS.PLACE_MARKET_ORDER_DELEGATED) ); @@ -195,11 +217,34 @@ describe("ix operations", () => { expect(delegatedIx.accounts[4]?.address).toBe("permission-account"); }); + it("declares position authority only when the signer differs from the owner", async () => { + const wrapCalls: WrapCall[] = []; + const operations = createPhoenixIxOperations( + createTestOperationContext(wrapCalls) + ); + + await operations.placeMarketOrder({ + authority: "trader-authority" as never, + positionAuthority: "trader-authority" as never, + symbol: "BTC-PERP" as never, + orderPacket: marketOrderPacket, + }); + await operations.placeMarketOrder({ + authority: "trader-authority" as never, + positionAuthority: "delegate-signer" as never, + symbol: "BTC-PERP" as never, + orderPacket: marketOrderPacket, + }); + + expect(wrapCalls).toHaveLength(2); + expect(wrapCalls[0]?.signer).toBe("trader-authority"); + expect(wrapCalls[0]?.usePositionAuthority).toBe(false); + expect(wrapCalls[1]?.signer).toBe("delegate-signer"); + expect(wrapCalls[1]?.usePositionAuthority).toBe(true); + }); + it("builds delegated primary-position-authority market orders through the shared wrapper", async () => { - const wrapCalls: Array<{ - instruction: InstructionsWithAccountsAndData; - authority: Authority; - }> = []; + const wrapCalls: WrapCall[] = []; const operations = createPhoenixIxOperations( createTestOperationContext(wrapCalls) ); @@ -212,7 +257,8 @@ describe("ix operations", () => { expect(wrapCalls).toHaveLength(1); expect(wrapCalls[0]?.instruction).toBe(ix); - expect(wrapCalls[0]?.authority).toBe("trader-authority"); + expect(wrapCalls[0]?.signer).toBe("trader-authority"); + expect(wrapCalls[0]?.usePositionAuthority).toBe(false); expect(ix.accounts[3]?.address).toBe("position-authority"); expect(ix.accounts[4]?.address).toBe("position-authority"); }); diff --git a/ts/tests/margin-draft-orders.test.ts b/ts/tests/margin-draft-orders.test.ts new file mode 100644 index 0000000..b42237a --- /dev/null +++ b/ts/tests/margin-draft-orders.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it } from "vitest"; +import { + buildNormalizedMarketParamsBySymbol, + buildSubaccountMarginInputsFromSnapshot, + computeDraftOrderMarginRequirementFromInputs, + computeDraftOrderMarginRequirementFromSnapshot, + computeMaxDraftOrderSizeForAvailableMarginFromInputs, + computeMaxDraftOrderSizeForAvailableMarginFromSnapshot, + type DraftOrderMarginInput, + type MarginCalculationOptions, + type MarginMarketsInput, + type MarginSnapshotMarketLimitOrderRow, + type MarginSnapshotSubaccount, + type MarketParams, + type SubaccountMarginInputs, +} from "@/margin"; + +const marketParams: MarketParams = { + symbol: "SOL-PERP", + assetId: 1, + markPriceTicks: "100", + tickSize: "1", + baseLotDecimals: 0, + leverageTiers: [ + { + upperBoundSize: "1000", + maxLeverage: "20", + limitOrderRiskFactorBps: "10000", + }, + ], + riskFactors: { + maintenanceMarginFactorBps: "5000", + backstopMarginFactorBps: "2000", + highRiskMarginFactorBps: "1000", + }, + cancelOrderRiskFactorBps: "7000", + upnlRiskFactor: "10000", + upnlRiskFactorForWithdrawals: "10000", + isolatedOnly: false, +}; + +const tierBoundaryMarketParams: MarketParams = { + ...marketParams, + leverageTiers: [ + { + upperBoundSize: "10", + maxLeverage: "20", + limitOrderRiskFactorBps: "10000", + }, + { + upperBoundSize: "20", + maxLeverage: "10", + limitOrderRiskFactorBps: "10000", + }, + ], +}; + +const leverageOptions: MarginCalculationOptions = { + orderLeverageLimitsBySymbol: { "SOL-PERP": 5 }, +}; + +const emptySnapshot = (): MarginSnapshotSubaccount => ({ + subaccountIndex: 0, + collateral: "1000", +}); + +const positionSnapshot = (): MarginSnapshotSubaccount => ({ + ...emptySnapshot(), + positions: [ + { + symbol: "SOL-PERP", + basePositionLots: "10", + virtualQuotePositionLots: "-1000", + entryPriceTicks: "100", + unsettledFundingQuoteLots: "7", + accumulatedFundingQuoteLots: "11", + }, + ], +}); + +const limitOrder = ( + overrides: Partial = {} +): MarginSnapshotMarketLimitOrderRow => ({ + orderSequenceNumber: "1", + side: "bid", + priceTicks: "100", + sizeRemainingLots: "2", + initialSizeLots: "2", + reduceOnly: false, + status: "active", + ...overrides, +}); + +const snapshotWithLimitOrder = ( + order: MarginSnapshotMarketLimitOrderRow = limitOrder() +): MarginSnapshotSubaccount => ({ + ...emptySnapshot(), + orders: [{ symbol: "SOL-PERP", orders: [order] }], +}); + +const aggregateOnlyInputs = (): SubaccountMarginInputs => ({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "1000", + markets: [ + { + symbol: "SOL-PERP", + limitOrderMargin: { + numAskOrders: 0, + numBidOrders: 1, + lowestAsk: "0", + highestBid: "0", + totalNonReduceOnlyAskBaseLots: "0", + totalReduceOnlyAskBaseLots: "0", + totalNonReduceOnlyBidBaseLots: "10", + totalReduceOnlyBidBaseLots: "0", + }, + }, + ], +}); + +const draftOrder = ( + overrides: Partial = {} +): DraftOrderMarginInput => ({ + symbol: "SOL-PERP", + side: "bid", + orderType: "limit", + priceTicks: "100", + sizeBaseLots: "10", + ...overrides, +}); + +type DraftOrderWithoutSize = Omit; + +const draftOrderWithoutSize = ( + overrides: Partial = {} +): DraftOrderWithoutSize => ({ + symbol: "SOL-PERP", + side: "bid", + orderType: "limit", + priceTicks: "100", + ...overrides, +}); + +const requirementFromSnapshot = ( + params: { + subaccount?: MarginSnapshotSubaccount; + markets?: MarginMarketsInput; + draftOrder?: DraftOrderMarginInput; + options?: MarginCalculationOptions; + } = {} +) => + computeDraftOrderMarginRequirementFromSnapshot({ + subaccount: params.subaccount ?? emptySnapshot(), + markets: params.markets ?? [marketParams], + draftOrder: params.draftOrder ?? draftOrder(), + options: params.options, + }); + +const maxSizeFromSnapshot = (params: { + subaccount?: MarginSnapshotSubaccount; + markets?: MarginMarketsInput; + draftOrder?: DraftOrderWithoutSize; + maxSizeBaseLots?: string; + availableMarginQuoteLots: string; + options?: MarginCalculationOptions; +}) => + computeMaxDraftOrderSizeForAvailableMarginFromSnapshot({ + subaccount: params.subaccount ?? emptySnapshot(), + markets: params.markets ?? [marketParams], + draftOrder: params.draftOrder ?? draftOrderWithoutSize(), + maxSizeBaseLots: params.maxSizeBaseLots ?? "10", + availableMarginQuoteLots: params.availableMarginQuoteLots, + options: params.options, + }); + +describe("draft order margin helpers", () => { + it.each([ + { orderType: "limit" as const, options: undefined }, + { orderType: "limit" as const, options: leverageOptions }, + { orderType: "market" as const, options: undefined }, + { orderType: "market" as const, options: leverageOptions }, + ])( + "keeps snapshot and input margin results aligned for $orderType drafts", + ({ orderType, options }) => { + const snapshot = snapshotWithLimitOrder(); + const draft = draftOrder({ orderType }); + + expect( + computeDraftOrderMarginRequirementFromInputs({ + subaccount: buildSubaccountMarginInputsFromSnapshot(snapshot), + markets: buildNormalizedMarketParamsBySymbol([marketParams]), + draftOrder: draft, + options, + }) + ).toEqual( + requirementFromSnapshot({ + subaccount: snapshot, + draftOrder: draft, + options, + }) + ); + } + ); + + it.each(["limit", "market"] as const)( + "returns leverage-adjusted margin for %s drafts", + (orderType) => { + expect( + requirementFromSnapshot({ + draftOrder: draftOrder({ orderType }), + options: leverageOptions, + }) + ).toEqual({ + marginRequirementQuoteLots: "200", + protocolMarginRequirementQuoteLots: "50", + orderLeverageAdjustedMarginRequirementQuoteLots: "200", + }); + } + ); + + it("uses aggregate margin deltas across leverage tiers", () => { + const snapshot = snapshotWithLimitOrder( + limitOrder({ sizeRemainingLots: "10", initialSizeLots: "10" }) + ); + + expect( + requirementFromSnapshot({ + subaccount: snapshot, + markets: [tierBoundaryMarketParams], + }) + ).toEqual({ + marginRequirementQuoteLots: "150", + protocolMarginRequirementQuoteLots: "150", + }); + + expect( + maxSizeFromSnapshot({ + subaccount: snapshot, + markets: [tierBoundaryMarketParams], + availableMarginQuoteLots: "50", + }) + ).toBe(5n); + }); + + it("preserves adverse fill loss for reduce-only limit drafts", () => { + expect( + requirementFromSnapshot({ + subaccount: positionSnapshot(), + draftOrder: draftOrder({ + side: "ask", + priceTicks: "80", + reduceOnly: true, + }), + }) + ).toEqual({ + marginRequirementQuoteLots: "200", + protocolMarginRequirementQuoteLots: "200", + }); + }); + + it.each(["0", "-1"])( + "rejects non-positive price ticks (%s)", + (priceTicks) => { + expect(() => + requirementFromSnapshot({ draftOrder: draftOrder({ priceTicks }) }) + ).toThrow("Draft order priceTicks must be positive"); + } + ); + + it.each(["limit", "market"] as const)( + "rejects %s projections from aggregate-only order state", + (orderType) => { + expect(() => + computeDraftOrderMarginRequirementFromInputs({ + subaccount: aggregateOnlyInputs(), + markets: [marketParams], + draftOrder: draftOrder({ orderType }), + }) + ).toThrow(/only aggregate limitOrderMargin is provided/); + } + ); + + it("rebuilds order aggregates after a market draft changes the position", () => { + const subaccount = + buildSubaccountMarginInputsFromSnapshot(positionSnapshot()); + const market = subaccount.markets[0]!; + market.limitOrders = [ + limitOrder({ + side: "ask", + priceTicks: "80", + sizeRemainingLots: "10", + initialSizeLots: "10", + reduceOnly: true, + }), + ]; + market.limitOrderMargin = { + numAskOrders: 1, + numBidOrders: 0, + lowestAsk: "80", + highestBid: "0", + totalNonReduceOnlyAskBaseLots: "0", + totalReduceOnlyAskBaseLots: "10", + totalNonReduceOnlyBidBaseLots: "0", + totalReduceOnlyBidBaseLots: "0", + }; + + expect( + computeDraftOrderMarginRequirementFromInputs({ + subaccount, + markets: [marketParams], + draftOrder: draftOrder({ + side: "ask", + orderType: "market", + sizeBaseLots: "30", + }), + }) + ).toEqual({ + marginRequirementQuoteLots: "0", + protocolMarginRequirementQuoteLots: "0", + }); + }); + + it("caps reduce-only market drafts and max size to the open position", () => { + expect( + requirementFromSnapshot({ + subaccount: positionSnapshot(), + draftOrder: draftOrder({ + side: "ask", + orderType: "market", + sizeBaseLots: "100", + reduceOnly: true, + }), + }) + ).toEqual({ + marginRequirementQuoteLots: "0", + protocolMarginRequirementQuoteLots: "0", + }); + + expect( + maxSizeFromSnapshot({ + subaccount: positionSnapshot(), + draftOrder: draftOrderWithoutSize({ + side: "ask", + orderType: "market", + reduceOnly: true, + }), + maxSizeBaseLots: "100", + availableMarginQuoteLots: "-1", + }) + ).toBe(10n); + }); + + it("finds the largest size that fits available adjusted margin", () => { + const draft = draftOrderWithoutSize({ orderType: "market" }); + const params = { + subaccount: emptySnapshot(), + markets: [marketParams], + draftOrder: draft, + maxSizeBaseLots: "10", + availableMarginQuoteLots: "120", + options: leverageOptions, + }; + + expect(computeMaxDraftOrderSizeForAvailableMarginFromSnapshot(params)).toBe( + 6n + ); + expect( + computeMaxDraftOrderSizeForAvailableMarginFromInputs({ + ...params, + subaccount: buildSubaccountMarginInputsFromSnapshot(params.subaccount), + markets: buildNormalizedMarketParamsBySymbol(params.markets), + }) + ).toBe(6n); + }); + + it("returns the upper bound when it fits", () => { + expect( + maxSizeFromSnapshot({ + availableMarginQuoteLots: "200", + options: leverageOptions, + }) + ).toBe(10n); + }); + + it("does not mutate caller-owned margin inputs", () => { + const subaccount = buildSubaccountMarginInputsFromSnapshot( + snapshotWithLimitOrder() + ); + const before = structuredClone(subaccount); + + computeDraftOrderMarginRequirementFromInputs({ + subaccount, + markets: [marketParams], + draftOrder: draftOrder({ + side: "ask", + sizeBaseLots: "3", + }), + }); + computeMaxDraftOrderSizeForAvailableMarginFromInputs({ + subaccount, + markets: [marketParams], + draftOrder: draftOrderWithoutSize({ + orderType: "market", + }), + maxSizeBaseLots: "10", + availableMarginQuoteLots: "100", + }); + + expect(subaccount).toEqual(before); + }); + + it("returns zero for non-positive sizes and negative available margin", () => { + expect( + requirementFromSnapshot({ + draftOrder: draftOrder({ + orderType: "market", + sizeBaseLots: "0", + }), + }) + ).toEqual({ + marginRequirementQuoteLots: "0", + protocolMarginRequirementQuoteLots: "0", + }); + + expect( + maxSizeFromSnapshot({ + draftOrder: draftOrderWithoutSize({ + orderType: "market", + }), + availableMarginQuoteLots: "-1", + }) + ).toBe(0n); + }); +}); diff --git a/ts/tests/margin-spot-collateral.test.ts b/ts/tests/margin-spot-collateral.test.ts new file mode 100644 index 0000000..270908c --- /dev/null +++ b/ts/tests/margin-spot-collateral.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { + buildSubaccountMarginInputsFromSnapshot, + createMarginCalculator, + type MarketParams, +} from "@/margin"; + +// SOL market: tickSize 10, baseLotDecimals 3, mark 5000 ticks. One base lot = +// 1e6 lamports and prices at 5000 * 10 = 50_000 quote lots, so 1 SOL +// (1e9 lamports = 1000 base lots) = 50_000_000 quote lots ($50). +const solMarketParams: MarketParams = { + symbol: "SOL", + assetId: 1, + markPriceTicks: "5000", + tickSize: "10", + baseLotDecimals: 3, + leverageTiers: [ + { + upperBoundSize: "1000", + maxLeverage: "10", + limitOrderRiskFactorBps: "0", + }, + ], + riskFactors: { + maintenanceMarginFactorBps: "500", + backstopMarginFactorBps: "1000", + highRiskMarginFactorBps: "2000", + }, + cancelOrderRiskFactorBps: "500", + upnlRiskFactor: "10000", + upnlRiskFactorForWithdrawals: "10000", + isolatedOnly: false, +}; + +// Discount curve: 5% at zero balance -> 20% at the 10 SOL global cap +// (retention 9500 -> 8000 bps). +const solSpotInput = { + assetIndex: 4294901760, + symbol: "SOL", + balance: "2000000000", // 2 SOL + decimals: 9, + maxGlobalBalance: "10000000000", // 10 SOL + minMarginDiscountBps: 500, + maxMarginDiscountBps: 2000, +}; + +describe("margin spot collateral valuation", () => { + const calculator = createMarginCalculator([solMarketParams]); + + it("adds discounted spot to effective collateral and notional to portfolio value", () => { + const margin = calculator.computeSubaccountMarginFromInputs({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "1000000", + markets: [], + spotCollaterals: [solSpotInput], + }); + + // notional: 2000 base lots * 50_000 = 100_000_000. + // retention at 2/10 of the cap: 9500 - 1500 * 2/10 = 9200 bps. + expect(margin.margin.spotCollateralNotionalQuoteLots).toBe("100000000"); + expect(margin.margin.spotCollateralDiscountedQuoteLots).toBe("92000000"); + expect(margin.margin.portfolioValueQuoteLots).toBe("101000000"); + expect(margin.margin.effectiveCollateralQuoteLots).toBe("93000000"); + // Spot never backs quote withdrawals. + expect(margin.margin.effectiveCollateralForWithdrawalsQuoteLots).toBe( + "1000000" + ); + expect(margin.spotCollaterals).toEqual([ + { + assetIndex: 4294901760, + symbol: "SOL", + balance: "2000000000", + notionalQuoteLots: "100000000", + discountedQuoteLots: "92000000", + }, + ]); + }); + + it("values sub-base-lot dust with truncating division", () => { + const margin = calculator.computeSubaccountMarginFromInputs({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "0", + markets: [], + spotCollaterals: [ + { ...solSpotInput, balance: "1500000500" }, // 1.5 SOL + 500 lamports + ], + }); + + // 1500 base lots * 50_000 + floor(500 * 50_000 / 1e6) = 75_000_000 + 25. + expect(margin.margin.spotCollateralNotionalQuoteLots).toBe("75000025"); + }); + + it("uses the curve endpoints at zero balance and at the global cap", () => { + const atCap = calculator.computeSubaccountMarginFromInputs({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "0", + markets: [], + spotCollaterals: [{ ...solSpotInput, balance: "10000000000" }], + }); + // 10 SOL notional 500_000_000, retention at the cap = 8000 bps. + expect(atCap.margin.spotCollateralDiscountedQuoteLots).toBe("400000000"); + + const empty = calculator.computeSubaccountMarginFromInputs({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "0", + markets: [], + spotCollaterals: [{ ...solSpotInput, balance: "0" }], + }); + expect(empty.margin.spotCollateralNotionalQuoteLots).toBe("0"); + expect(empty.margin.spotCollateralDiscountedQuoteLots).toBe("0"); + }); + + it("prefers an explicit index price over the market mark", () => { + const margin = calculator.computeSubaccountMarginFromInputs({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "0", + markets: [], + spotCollaterals: [{ ...solSpotInput, indexPriceTicks: "6000" }], + }); + // 2000 base lots * 6000 * 10 = 120_000_000. + expect(margin.margin.spotCollateralNotionalQuoteLots).toBe("120000000"); + }); + + it("omits the spot fields when no spot collaterals are supplied", () => { + const margin = calculator.computeSubaccountMarginFromInputs({ + subaccountIndex: 0, + collateralBalanceQuoteLots: "1000000", + markets: [], + }); + expect(margin.margin.spotCollateralNotionalQuoteLots).toBeUndefined(); + expect(margin.margin.spotCollateralDiscountedQuoteLots).toBeUndefined(); + expect(margin.spotCollaterals).toBeUndefined(); + expect(margin.margin.effectiveCollateralQuoteLots).toBe("1000000"); + }); + + it("builds spot inputs from a traderState snapshot with asset params", () => { + const inputs = buildSubaccountMarginInputsFromSnapshot( + { + subaccountIndex: 0, + collateral: "1000000", + spotCollaterals: [ + { assetIndex: 4294901760, symbol: "SOL", balance: "2000000000" }, + // No params registered for this asset -> left unvalued. + { assetIndex: 4294901761, symbol: "XYZ", balance: "5" }, + ], + }, + { + spotAssetParamsByIndex: { + 4294901760: { + assetIndex: 4294901760, + decimals: 9, + maxGlobalBalance: "10000000000", + minMarginDiscountBps: 500, + maxMarginDiscountBps: 2000, + }, + }, + } + ); + + expect(inputs.spotCollaterals).toHaveLength(1); + const margin = calculator.computeSubaccountMarginFromInputs(inputs); + expect(margin.margin.effectiveCollateralQuoteLots).toBe("93000000"); + }); +}); diff --git a/ts/tests/orders-client.test.ts b/ts/tests/orders-client.test.ts index 499d786..3067f0c 100644 --- a/ts/tests/orders-client.test.ts +++ b/ts/tests/orders-client.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { PlaceAttachedConditionalOrderRequestSchema, PlaceIsolatedLimitOrderWithConditionalsRequestSchema, + PlaceIsolatedMarketOrderRequestSchema, PlacePositionConditionalOrderRequestSchema, } from "@/api/orders"; import { PhoenixHttpClient } from "@/index"; @@ -284,3 +285,33 @@ describe("conditional order request schemas", () => { ).toBe(true); }); }); + +describe("isolated market order request schema", () => { + const baseRequest = { + authority: "authority", + symbol: "SOL-PERP", + side: "buy", + numBaseLots: 25, + }; + + it("accepts a positive minimum fill and preserves omission for FOK defaults", () => { + expect( + PlaceIsolatedMarketOrderRequestSchema.parse({ + ...baseRequest, + minBaseLotsToFill: 1, + }).minBaseLotsToFill + ).toBe(1); + expect( + PlaceIsolatedMarketOrderRequestSchema.parse(baseRequest).minBaseLotsToFill + ).toBeUndefined(); + }); + + it("accepts a zero minimum fill", () => { + expect( + PlaceIsolatedMarketOrderRequestSchema.parse({ + ...baseRequest, + minBaseLotsToFill: 0, + }).minBaseLotsToFill + ).toBe(0); + }); +}); diff --git a/ts/tests/phoenix-address-overrides.test.ts b/ts/tests/phoenix-address-overrides.test.ts index a862345..bbbf4b7 100644 --- a/ts/tests/phoenix-address-overrides.test.ts +++ b/ts/tests/phoenix-address-overrides.test.ts @@ -1,4 +1,5 @@ import { + BETA_USDC_MINT_ADDRESS, PHOENIX_GLOBAL_CONFIGURATION_ADDRESS, PHOENIX_LOG_AUTHORITY_ADDRESS, PHOENIX_PROGRAM_ADDRESS, @@ -7,11 +8,13 @@ import { type PlaceMarketOrderParams, baseLots, quoteLots, + resolvePhoenixBuilderAddresses, resolvePhoenixInstructionAddresses, ticks, SelfTradeBehavior, Side, } from "@/index"; +import { getEmberStateAddress } from "@/pdas"; import type { ActiveTraderBufferAddressArray, Authority, @@ -19,6 +22,7 @@ import type { GlobalTraderIndexAddressArray, LogAuthorityAddress, MarketAddress, + MintAddress, PerpAssetMapAddress, PhoenixProgramAddress, SplineCollectionAddress, @@ -193,3 +197,55 @@ describe("phoenix instruction address overrides", () => { ); }); }); + +describe("phoenix builder address resolution", () => { + it("resolves prod builder addresses by default, including the USDC mint", async () => { + const addresses = resolvePhoenixBuilderAddresses(); + + expect(addresses.phoenixProgramAddress).toBe(PHOENIX_PROGRAM_ADDRESS); + expect(addresses.usdcMintAddress).toBe( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + ); + expect(addresses.emberStateAddress).toBe( + await getEmberStateAddress(PHOENIX_PROGRAM_ADDRESS) + ); + }); + + it("resolves the beta USDC mint and ember state under PHOENIX_ENV=beta", async () => { + process.env.PHOENIX_ENV = "beta"; + + const addresses = resolvePhoenixBuilderAddresses(); + + expect(addresses.phoenixProgramAddress).toBe(BETA_PROGRAM_ADDRESS); + expect(addresses.logAuthorityAddress).toBe(BETA_LOG_AUTHORITY_ADDRESS); + expect(addresses.globalConfigurationAddress).toBe( + BETA_GLOBAL_CONFIGURATION_ADDRESS + ); + expect(addresses.usdcMintAddress).toBe( + "DPTSTVhvfzQhY8pAJL5EeqfZxf9aNKTmHErfG4R3Z1SE" + ); + expect(addresses.emberStateAddress).toBe( + await getEmberStateAddress(BETA_PROGRAM_ADDRESS) + ); + }); + + it("infers the beta mint from the known beta program id without env vars", () => { + const addresses = resolvePhoenixBuilderAddresses({ + phoenixProgramAddress: BETA_PROGRAM_ADDRESS, + }); + + expect(addresses.usdcMintAddress).toBe(BETA_USDC_MINT_ADDRESS); + }); + + it("keeps explicit mint overrides ahead of env defaults", () => { + process.env.PHOENIX_ENV = "beta"; + + const usdcMintAddress = address( + "So11111111111111111111111111111111111111112" + ) as MintAddress; + const addresses = resolvePhoenixBuilderAddresses({ usdcMintAddress }); + + expect(addresses.usdcMintAddress).toBe(usdcMintAddress); + expect(addresses.phoenixProgramAddress).toBe(BETA_PROGRAM_ADDRESS); + }); +}); diff --git a/ts/tests/public-surface.manifest.ts b/ts/tests/public-surface.manifest.ts index 1ae50df..afe1de9 100644 --- a/ts/tests/public-surface.manifest.ts +++ b/ts/tests/public-surface.manifest.ts @@ -153,7 +153,13 @@ export const ROOT_PRESENT_PROPERTIES = [ "simulatePositionFillFromInputs", "buildLimitOrderPacketFromMarketParams", "buildMarketOrderPacketFromMarketParams", + "computeDraftOrderMarginRequirementFromInputs", + "computeDraftOrderMarginRequirementFromSnapshot", + "computeMaxDraftOrderSizeForAvailableMarginFromInputs", + "computeMaxDraftOrderSizeForAvailableMarginFromSnapshot", "buildDepositFundsIxResolved", + "buildFlameAtomicDepositFlow", + "buildFlameDepositToPhoenixIx", "buildFlameDepositFundingFlow", "buildWithdrawFundsIxResolved", "buildTransferCollateralIxResolved", @@ -196,10 +202,15 @@ export const ROOT_PRESENT_PROPERTIES = [ "buildOnboardTraderDelegated", "buildOnboardTraderDelegatedIxResolved", "buildSyncParentToChildIxResolved", + "deriveFlameDepositToPhoenixAddresses", "deriveFlameDepositAddress", "deriveFlameDepositAddresses", + "deriveFlameGlobalStateAddress", "deriveFlameProxyAuthorityAddress", "FLAME_PROGRAM_ADDRESS", + "BETA_USDC_MINT_ADDRESS", + "EMBER_STATE_ADDRESS", + "resolvePhoenixBuilderAddresses", "getPhoenixConditionalOrdersAddress", "createAllMidsAdapter", "createCandlesAdapter", diff --git a/ts/tests/sdk-generated-fixtures.test.ts b/ts/tests/sdk-generated-fixtures.test.ts index 9b5afbb..358621c 100644 --- a/ts/tests/sdk-generated-fixtures.test.ts +++ b/ts/tests/sdk-generated-fixtures.test.ts @@ -240,7 +240,7 @@ describe("generated SDK fixtures", () => { "sdk-instruction-fixtures.json" ); expect(fixture.schemaVersion).toBe(1); - expect(fixture.instructions).toHaveLength(111); + expect(fixture.instructions).toHaveLength(112); for (const instruction of fixture.instructions) { const discriminant = sha2_const(instruction.preimage); diff --git a/ts/tests/sdk-localnet-vm.test.ts b/ts/tests/sdk-localnet-vm.test.ts index 55f9cd4..92dfd33 100644 --- a/ts/tests/sdk-localnet-vm.test.ts +++ b/ts/tests/sdk-localnet-vm.test.ts @@ -1,26 +1,68 @@ -import { address } from "@solana/kit"; +import { + address, + createKeyPairSignerFromPrivateKeyBytes, + getConstantEncoder, + getHiddenPrefixEncoder, + getStructEncoder, + getU64Encoder, +} from "@solana/kit"; +import type { TransactionSigner } from "@solana/kit"; import { describe, expect, test } from "vitest"; import { + buildCreatePermissionIx, buildDepositIxsResolved, buildMarketOrderPacketFromMarketParams, buildPlaceMarketOrderIxResolved, + buildSetPermissionIx, buildWithdrawIxsResolved, + createPhoenixIxClient, + createPhoenixPdaClient, + flight, + generateReadonlyAccount, + generateWritableAccount, + generateWritableSignerAccount, + getPhoenixPermissionAddress, Side, } from "../src"; -import type { SdkLocalnetContext } from "./test-harness/litesvm"; +import type { + Authority, + GlobalConfigurationAddress, + LogAuthorityAddress, + PhoenixProgramAddress, +} from "../src"; +import type { PhoenixExchangeMetadata } from "../src/exchange-cache/types"; +import type { + FixtureActor, + SdkLocalnetContext, + SdkLocalnetInstruction, + SdkLocalnetSigners, +} from "./test-harness/litesvm"; import { buildSdkLocalnetDepositInput, buildSdkLocalnetMarketParams, buildSdkLocalnetPlaceOrderContext, + buildSdkLocalnetTransaction, buildSdkLocalnetWithdrawInput, createSdkLocalnetContext, findSdkLocalnetProgramPaths, + getSdkLocalnetActionTemplate, isSdkLocalnetVmRequired, + readSdkLocalnetEffectiveTraderPosition, + readSdkLocalnetEffectiveTraderState, + sdkLocalnetSeedBytes, + sendSdkLocalnetInstructions, } from "./test-harness/litesvm"; const programPaths = findSdkLocalnetProgramPaths(); const vmTest = programPaths || isSdkLocalnetVmRequired() ? test : test.skip; +const flightVmTest = + programPaths?.flight || isSdkLocalnetVmRequired() ? test : test.skip; const WITHDRAW_COOLDOWN_SLOTS = 150n; +const POSITION_AUTHORITY_PERMISSION = 1n << 13n; +const AUTHORIZED_COLLATERAL_TRANSFER_PERMISSION = 1n << 15n; +const FLIGHT_MAX_FEE_CAP_BPS = 1_000n; +const FLIGHT_BUILDER_FEE_BPS = 100n; +const FLIGHT_DELEGATE_SEED = "flight-position-authority-delegate"; describe("SDK localnet VM harness", () => { vmTest( @@ -154,6 +196,429 @@ describe("SDK localnet VM harness", () => { ); }); +describe("Flight delegated market order proxy", () => { + flightVmTest( + "flight proxy collects builder fee for delegated market order signed by a secondary position authority", + async () => { + const { context, signers, user, builder, delegate, proxyInstruction } = + await setUpFlightDelegatedProxy(); + + const transaction = await buildSdkLocalnetTransaction( + context.vm, + signers, + [proxyInstruction], + { feePayerSeed: FLIGHT_DELEGATE_SEED } + ); + const requiredSigners = Object.keys(transaction.signatures); + expect(requiredSigners).toContain(delegate.address); + expect(requiredSigners).not.toContain(user.pubkey); + + const userCollateralBefore = readTraderCollateral( + context, + user.traderAccount + ); + const builderCollateralBefore = readTraderCollateral( + context, + builder.traderAccount + ); + expect( + readSdkLocalnetEffectiveTraderPosition(context, user.traderAccount, 0) + ).toBeNull(); + + const execution = await sendSdkLocalnetInstructions( + context.vm, + signers, + [proxyInstruction], + { + feePayerSeed: FLIGHT_DELEGATE_SEED, + label: "flight:proxy-delegated-market-order", + } + ); + expect(execution.metadata.computeUnitsConsumed()).toBeGreaterThan(0n); + + const position = readSdkLocalnetEffectiveTraderPosition( + context, + user.traderAccount, + 0 + ); + expect(position).not.toBeNull(); + expect(position?.position.baseLotPosition ?? 0n).toBeGreaterThan(0n); + + const collectedFee = parseFlightCollectedFee(execution.metadata.logs()); + expect(collectedFee).toBeGreaterThan(0n); + expect( + readTraderCollateral(context, builder.traderAccount) - + builderCollateralBefore + ).toBe(collectedFee); + expect( + userCollateralBefore - readTraderCollateral(context, user.traderAccount) + ).toBeGreaterThanOrEqual(collectedFee); + }, + 120_000 + ); + + flightVmTest( + "flight proxy rejects delegated market order missing the collateral transfer tail", + async () => { + const { + context, + signers, + user, + builder, + proxyInstruction, + collateralTransferAuthority, + } = await setUpFlightDelegatedProxy(); + + expect(proxyInstruction.accounts.at(-2)?.address).toBe( + collateralTransferAuthority + ); + const withoutTail: SdkLocalnetInstruction = { + ...proxyInstruction, + accounts: proxyInstruction.accounts.slice(0, -2), + }; + + const userCollateralBefore = readTraderCollateral( + context, + user.traderAccount + ); + const builderCollateralBefore = readTraderCollateral( + context, + builder.traderAccount + ); + + await expect( + sendSdkLocalnetInstructions(context.vm, signers, [withoutTail], { + feePayerSeed: FLIGHT_DELEGATE_SEED, + label: "flight:proxy-delegated-market-order-without-tail", + }) + ).rejects.toThrow( + /Position-authority orders require the collateral transfer authority and permission accounts appended/ + ); + + expect(readTraderCollateral(context, user.traderAccount)).toBe( + userCollateralBefore + ); + expect(readTraderCollateral(context, builder.traderAccount)).toBe( + builderCollateralBefore + ); + expect( + readSdkLocalnetEffectiveTraderPosition(context, user.traderAccount, 0) + ).toBeNull(); + }, + 120_000 + ); +}); + +interface FlightDelegatedProxySetup { + context: SdkLocalnetContext; + signers: SdkLocalnetSigners; + user: FixtureActor; + builder: FixtureActor; + delegate: TransactionSigner; + proxyInstruction: SdkLocalnetInstruction; + collateralTransferAuthority: string; +} + +/** + * Boots the fixture exchange and prepares the Flight delegated + * place-market-order flow: initializes the Flight global state, registers + * taker1 as the fee-collecting builder, has taker0 grant a distinct delegate + * key POSITION_AUTHORITY_PERMISSION (bit 13), and has the exchange root + * authority (the fixture payer) grant the Flight collateral-transfer PDA + * AUTHORIZED_COLLATERAL_TRANSFER_PERMISSION (bit 14). Returns the proxied + * delegate-signed PlaceMarketOrderDelegated instruction ready to send. + */ +const setUpFlightDelegatedProxy = + async (): Promise => { + if (!programPaths?.flight) { + throw new Error( + "Missing local Flight SBF artifact; run `mise build-sbf` or set RISE_SDK_LOCALNET_FLIGHT_SO" + ); + } + + const context = await createSdkLocalnetContext({ programPaths }); + const phoenixProgramAddress = phoenixProgram(context); + const user = context.getActor("taker0"); + const builder = context.getActor("taker1"); + const rootAuthority = context.getSigner("payer").address as Authority; + + // The builder fee leaves the user's trader account through a collateral + // transfer, which respects the fixture's 150-slot withdraw cooldown that + // started at the setup deposits. Warping makes the setup-time mark price + // stale, so replay the oracle and spline price mock actions afterwards. + context.vm.warpToSlot(context.vm.getClock().slot + WITHDRAW_COOLDOWN_SLOTS); + await context.sendFixtureTransaction( + getSdkLocalnetActionTemplate(context.fixture, "oracleSetPrices") + ); + await context.sendFixtureTransaction( + getSdkLocalnetActionTemplate(context.fixture, "splineUpdatePrices") + ); + + const delegate = await createExtraLocalnetSigner( + context, + FLIGHT_DELEGATE_SEED + ); + const signers = withExtraLocalnetSigner( + context.signers, + delegate, + FLIGHT_DELEGATE_SEED + ); + + await context.sendInstructions([await buildFlightInitIx(context)], { + feePayerSeed: "payer", + label: "flight:init-global-state", + }); + + const registerBuilderIx = await flight.buildRegisterBuilderIx({ + phoenixProgramAddress, + traderAuthority: builder.pubkey as Authority, + feeBps: FLIGHT_BUILDER_FEE_BPS, + }); + expect(registerBuilderIx.accounts[3]?.address).toBe(builder.traderAccount); + await context.sendInstructions([registerBuilderIx], { + feePayerSeed: builder.seed, + label: "flight:register-builder", + }); + + // taker0 (the user wallet) grants the delegate position authority. + const positionPermission = await getPhoenixPermissionAddress( + user.pubkey as Authority, + delegate.address as Authority, + phoenixProgramAddress + ); + await context.sendInstructions( + [ + buildCreatePermissionIx({ + ...phoenixOverrides(context), + payer: user.pubkey as Authority, + permissionAuthority: user.pubkey as Authority, + delegatedKey: delegate.address as Authority, + permissionPda: positionPermission, + }), + buildSetPermissionIx({ + ...phoenixOverrides(context), + permissionAuthority: user.pubkey as Authority, + delegatedKey: delegate.address as Authority, + permissionPda: positionPermission, + permission: POSITION_AUTHORITY_PERMISSION, + expiresAtTimestamp: null, + allowedSignerActions: null, + }), + ], + { + feePayerSeed: user.seed, + label: "flight:grant-position-authority", + } + ); + + // The exchange root authority grants the Flight collateral-transfer PDA + // the authorized collateral transfer permission. + const collateralTransferAuthority = + await flight.getFlightCollateralTransferAuthorityAddress( + phoenixProgramAddress + ); + const delegationPermission = await getPhoenixPermissionAddress( + rootAuthority, + collateralTransferAuthority, + phoenixProgramAddress + ); + await context.sendInstructions( + [ + buildCreatePermissionIx({ + ...phoenixOverrides(context), + payer: rootAuthority, + permissionAuthority: rootAuthority, + delegatedKey: collateralTransferAuthority, + permissionPda: delegationPermission, + }), + buildSetPermissionIx({ + ...phoenixOverrides(context), + permissionAuthority: rootAuthority, + delegatedKey: collateralTransferAuthority, + permissionPda: delegationPermission, + permission: AUTHORIZED_COLLATERAL_TRANSFER_PERMISSION, + expiresAtTimestamp: null, + allowedSignerActions: null, + }), + ], + { + feePayerSeed: "payer", + label: "flight:grant-authorized-collateral-transfer", + } + ); + + // Delegate-signed PlaceMarketOrderDelegated on taker0's trader account, + // filling against the fixture maker ask at 100500 (mirrors the plain + // market-order test above). Built through the public high-level ix + // client: `authority` is the owner (the trader PDA derives from it), + // `positionAuthority` is the delegate that signs, and the wrap takes the + // position-authority path purely from the effective signer differing + // from the owner. + const orderPacket = buildMarketOrderPacketFromMarketParams( + { + side: Side.Bid, + priceLimitUsd: "101000", + baseUnits: "0.01", + minBaseUnitsToFill: "0.01", + }, + buildSdkLocalnetMarketParams(context, "BTC") + ); + const ixClient = createPhoenixIxClient({ + exchange: createFixtureExchangeMetadata(context, rootAuthority), + pda: createPhoenixPdaClient({ + programAddress: phoenixProgramAddress, + }), + orderPackets: {} as never, + flight: { builderAuthority: builder.pubkey as Authority }, + }); + const proxyInstruction = (await ixClient.placeMarketOrderDelegated({ + authority: user.pubkey as Authority, + positionAuthority: delegate.address as Authority, + permissionAccount: positionPermission, + symbol: "BTC" as never, + orderPacket, + })) as SdkLocalnetInstruction; + + // The public path must target the fixture accounts: the builder fee + // collector and the user trader PDA both derive from their owners. + expect(proxyInstruction.programAddress).toBe(flight.FLIGHT_PROGRAM_ADDRESS); + expect(proxyInstruction.accounts[3]?.address).toBe(builder.traderAccount); + expect(proxyInstruction.accounts[5]?.address).toBe(delegate.address); + // Inner PlaceMarketOrderDelegated starts at proxy index 6: wallet at + // +3, permission at +4, trader PDA (derived from the owner) at +5. + expect(proxyInstruction.accounts[9]?.address).toBe(delegate.address); + expect(proxyInstruction.accounts[10]?.address).toBe(positionPermission); + expect(proxyInstruction.accounts[11]?.address).toBe(user.traderAccount); + + return { + context, + signers, + user, + builder, + delegate, + proxyInstruction, + collateralTransferAuthority, + }; + }; + +/** + * Minimal `PhoenixExchangeMetadata` view over the localnet fixture: enough + * for the public ix client to resolve the BTC market accounts and the + * current root authority (the fixture payer) at wrap time. + */ +const createFixtureExchangeMetadata = ( + context: SdkLocalnetContext, + rootAuthority: Authority +): PhoenixExchangeMetadata => { + const market = context.getMarket("BTC"); + return { + ready: async () => undefined, + snapshot: () => ({ + markets: [{ symbol: "BTC" }], + exchange: { currentAuthorities: { rootAuthority } }, + }), + instructionContext: (symbol: string) => + symbol === "BTC" + ? { + exchange: { + globalConfig: context.fixture.addresses.globalConfig, + perpAssetMap: context.fixture.addresses.perpAssetMap, + globalTraderIndex: context.fixture.addresses.globalTraderIndex, + activeTraderBuffer: context.fixture.addresses.activeTraderBuffer, + }, + market: { + marketPubkey: market.orderbook, + splinePubkey: market.spline, + }, + } + : undefined, + } as unknown as PhoenixExchangeMetadata; +}; + +const buildFlightInitIx = async ( + context: SdkLocalnetContext +): Promise => { + const phoenixProgramAddress = phoenixProgram(context); + const globalStateAccount = await flight.getFlightGlobalStateAddress( + phoenixProgramAddress + ); + + return { + programAddress: flight.FLIGHT_PROGRAM_ADDRESS, + accounts: [ + generateWritableAccount(globalStateAccount), + generateReadonlyAccount(phoenixProgramAddress), + generateWritableSignerAccount(context.getSigner("payer").address), + generateReadonlyAccount(address(context.fixture.programs.system)), + ], + data: getFlightInitInstructionEncoder().encode({ + maxFeeCapBps: FLIGHT_MAX_FEE_CAP_BPS, + }), + }; +}; + +const getFlightInitInstructionEncoder = () => + getHiddenPrefixEncoder( + getStructEncoder([["maxFeeCapBps", getU64Encoder()]]), + [getConstantEncoder(flight.FLIGHT_DISCRIMINANTS.INIT)] + ); + +const parseFlightCollectedFee = (logs: readonly string[]): bigint => { + const feeLog = logs.find((log) => log.includes("collected fee:")); + const match = + feeLog === undefined ? null : /collected fee: (\d+)/.exec(feeLog); + if (!match?.[1]) { + throw new Error( + `Missing flight collected-fee log line in:\n${logs.join("\n")}` + ); + } + return BigInt(match[1]); +}; + +const readTraderCollateral = ( + context: Pick, + traderAccount: string +): bigint => + readSdkLocalnetEffectiveTraderState(context, traderAccount).state + .quoteLotCollateral; + +const createExtraLocalnetSigner = async ( + context: SdkLocalnetContext, + seed: string +): Promise => { + const signer = await createKeyPairSignerFromPrivateKeyBytes( + sdkLocalnetSeedBytes(seed, context.fixture) + ); + const result = context.vm.airdrop( + address(signer.address) as never, + 10_000_000_000n as never + ); + expect(result).not.toBeNull(); + return signer; +}; + +const withExtraLocalnetSigner = ( + signers: SdkLocalnetSigners, + signer: TransactionSigner, + seed: string +): SdkLocalnetSigners => ({ + all: [...signers.all, signer], + bySeed: new Map(signers.bySeed).set(seed, signer), + byName: new Map(signers.byName).set(seed, signer), + byAddress: new Map(signers.byAddress).set(signer.address, signer), +}); + +const phoenixProgram = (context: SdkLocalnetContext): PhoenixProgramAddress => + context.fixture.programs.phoenixEternal as PhoenixProgramAddress; + +const phoenixOverrides = (context: SdkLocalnetContext) => ({ + programAddress: phoenixProgram(context), + logAuthorityAddress: context.fixture.addresses + .logAuthority as LogAuthorityAddress, + globalConfigurationAddress: context.fixture.addresses + .globalConfig as GlobalConfigurationAddress, +}); + const readSplTokenAmount = ( context: Pick, tokenAccount: string diff --git a/ts/tests/test-harness/localnet.ts b/ts/tests/test-harness/localnet.ts index 9e7c9ae..c1d97e3 100644 --- a/ts/tests/test-harness/localnet.ts +++ b/ts/tests/test-harness/localnet.ts @@ -27,6 +27,7 @@ import type { TransactionSigner, } from "@solana/kit"; import { decodeOrderbook, decodeTrader } from "../../src/accounts"; +import { FLIGHT_PROGRAM_ADDRESS } from "../../src/flight"; import { HAWKEYE_PROGRAM_ADDRESS } from "../../src/hawkeye"; import type { Orderbook, @@ -81,6 +82,7 @@ export interface SdkLocalnetProgramPaths { phoenixEternal: string; ember: string; hawkeye?: string; + flight?: string; } export interface FindSdkLocalnetProgramPathsOptions { @@ -193,6 +195,7 @@ const PHOENIX_REPO_ROOT_ENV = "PHOENIX_REPO_ROOT"; const ETERNAL_PROGRAM_ENV = "RISE_SDK_LOCALNET_ETERNAL_SO"; const EMBER_PROGRAM_ENV = "RISE_SDK_LOCALNET_EMBER_SO"; const HAWKEYE_PROGRAM_ENV = "RISE_SDK_LOCALNET_HAWKEYE_SO"; +const FLIGHT_PROGRAM_ENV = "RISE_SDK_LOCALNET_FLIGHT_SO"; const DEFAULT_LAST_VALID_BLOCK_HEIGHT = 1_000_000n; export const isSdkLocalnetVmRequired = (): boolean => @@ -672,6 +675,12 @@ export const loadSdkLocalnetPrograms = ( programPaths.hawkeye ); } + if (programPaths.flight) { + vm.addProgramFromFile( + toLiteSvmAddress(FLIGHT_PROGRAM_ADDRESS), + programPaths.flight + ); + } }; export const fundSdkLocalnetSigners = ( @@ -1096,8 +1105,9 @@ const explicitProgramPaths = ( programPaths?.phoenixEternal ?? process.env[ETERNAL_PROGRAM_ENV]; const ember = programPaths?.ember ?? process.env[EMBER_PROGRAM_ENV]; const hawkeye = programPaths?.hawkeye ?? process.env[HAWKEYE_PROGRAM_ENV]; + const flight = programPaths?.flight ?? process.env[FLIGHT_PROGRAM_ENV]; - if (!phoenixEternal && !ember && !hawkeye) { + if (!phoenixEternal && !ember && !hawkeye && !flight) { return null; } @@ -1105,6 +1115,7 @@ const explicitProgramPaths = ( phoenixEternal: resolveRequiredPath(phoenixEternal, ETERNAL_PROGRAM_ENV), ember: resolveRequiredPath(ember, EMBER_PROGRAM_ENV), ...(hawkeye ? { hawkeye: resolve(hawkeye) } : {}), + ...(flight ? { flight: resolve(flight) } : {}), }; }; @@ -1121,7 +1132,8 @@ const resolveRequiredPath = ( const allProgramPathsExist = (programPaths: SdkLocalnetProgramPaths): boolean => existsSync(programPaths.phoenixEternal) && existsSync(programPaths.ember) && - (programPaths.hawkeye === undefined || existsSync(programPaths.hawkeye)); + (programPaths.hawkeye === undefined || existsSync(programPaths.hawkeye)) && + (programPaths.flight === undefined || existsSync(programPaths.flight)); const defaultProgramPathCandidates = ( repoRoot?: string @@ -1149,14 +1161,29 @@ const defaultProgramPathCandidates = ( ), ember: resolve(root, "programs/target/deploy/phoenix_ember_program.so"), ...optionalProgramPath( + "hawkeye", root, "programs/target/deploy/phoenix_hawkeye.so" ), + ...optionalProgramPath( + "flight", + root, + "programs/target/deploy/phoenix_flight.so" + ), }, { phoenixEternal: resolve(root, "target/deploy/phoenix_eternal.so"), ember: resolve(root, "target/deploy/phoenix_ember_program.so"), - ...optionalProgramPath(root, "target/deploy/phoenix_hawkeye.so"), + ...optionalProgramPath( + "hawkeye", + root, + "target/deploy/phoenix_hawkeye.so" + ), + ...optionalProgramPath( + "flight", + root, + "target/deploy/phoenix_flight.so" + ), }, { phoenixEternal: resolve( @@ -1168,9 +1195,15 @@ const defaultProgramPathCandidates = ( "programs/ember/target/deploy/phoenix_ember_program.so" ), ...optionalProgramPath( + "hawkeye", root, "programs/phoenix-hawkeye/target/deploy/phoenix_hawkeye.so" ), + ...optionalProgramPath( + "flight", + root, + "programs/flight/target/deploy/phoenix_flight.so" + ), } ); } @@ -1178,12 +1211,15 @@ const defaultProgramPathCandidates = ( return candidates; }; -const optionalProgramPath = ( +const optionalProgramPath = ( + key: Key, root: string, path: string -): Pick => { +): Partial> => { const resolved = resolve(root, path); - return existsSync(resolved) ? { hawkeye: resolved } : {}; + return existsSync(resolved) + ? ({ [key]: resolved } as Pick) + : {}; }; const moduleDirectory = (): string => dirname(fileURLToPath(import.meta.url));