@exponent-labs/exponent-sdk 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/build/client/vaults/index.d.ts +2 -0
  2. package/build/client/vaults/index.js +2 -0
  3. package/build/client/vaults/index.js.map +1 -1
  4. package/build/client/vaults/types/index.d.ts +2 -0
  5. package/build/client/vaults/types/index.js +2 -0
  6. package/build/client/vaults/types/index.js.map +1 -1
  7. package/build/client/vaults/types/kaminoFarmEntry.d.ts +15 -0
  8. package/build/client/vaults/types/kaminoFarmEntry.js +17 -0
  9. package/build/client/vaults/types/kaminoFarmEntry.js.map +1 -0
  10. package/build/client/vaults/types/kaminoObligationEntry.d.ts +21 -4
  11. package/build/client/vaults/types/kaminoObligationEntry.js +2 -1
  12. package/build/client/vaults/types/kaminoObligationEntry.js.map +1 -1
  13. package/build/client/vaults/types/positionUpdate.d.ts +9 -0
  14. package/build/client/vaults/types/positionUpdate.js +23 -0
  15. package/build/client/vaults/types/positionUpdate.js.map +1 -1
  16. package/build/client/vaults/types/proposalAction.js +0 -3
  17. package/build/client/vaults/types/proposalAction.js.map +1 -1
  18. package/build/client/vaults/types/reserveFarmMapping.d.ts +19 -0
  19. package/build/client/vaults/types/reserveFarmMapping.js +18 -0
  20. package/build/client/vaults/types/reserveFarmMapping.js.map +1 -0
  21. package/build/client/vaults/types/strategyPosition.d.ts +5 -0
  22. package/build/client/vaults/types/strategyPosition.js +5 -0
  23. package/build/client/vaults/types/strategyPosition.js.map +1 -1
  24. package/build/exponentVaults/aumCalculator.d.ts +25 -4
  25. package/build/exponentVaults/aumCalculator.js +236 -15
  26. package/build/exponentVaults/aumCalculator.js.map +1 -1
  27. package/build/exponentVaults/fetcher.d.ts +52 -0
  28. package/build/exponentVaults/fetcher.js +199 -0
  29. package/build/exponentVaults/fetcher.js.map +1 -0
  30. package/build/exponentVaults/index.d.ts +10 -9
  31. package/build/exponentVaults/index.js +26 -8
  32. package/build/exponentVaults/index.js.map +1 -1
  33. package/build/exponentVaults/kamino-farms.d.ts +144 -0
  34. package/build/exponentVaults/kamino-farms.js +396 -0
  35. package/build/exponentVaults/kamino-farms.js.map +1 -0
  36. package/build/exponentVaults/loopscale/client.d.ts +240 -0
  37. package/build/exponentVaults/loopscale/client.js +590 -0
  38. package/build/exponentVaults/loopscale/client.js.map +1 -0
  39. package/build/exponentVaults/loopscale/client.test.d.ts +1 -0
  40. package/build/exponentVaults/loopscale/client.test.js +183 -0
  41. package/build/exponentVaults/loopscale/client.test.js.map +1 -0
  42. package/build/exponentVaults/loopscale/helpers.d.ts +29 -0
  43. package/build/exponentVaults/loopscale/helpers.js +119 -0
  44. package/build/exponentVaults/loopscale/helpers.js.map +1 -0
  45. package/build/exponentVaults/loopscale/index.d.ts +3 -0
  46. package/build/exponentVaults/loopscale/index.js +12 -0
  47. package/build/exponentVaults/loopscale/index.js.map +1 -0
  48. package/build/exponentVaults/loopscale/prepared-transactions.d.ts +13 -0
  49. package/build/exponentVaults/loopscale/prepared-transactions.js +271 -0
  50. package/build/exponentVaults/loopscale/prepared-transactions.js.map +1 -0
  51. package/build/exponentVaults/loopscale/prepared-transactions.test.d.ts +1 -0
  52. package/build/exponentVaults/loopscale/prepared-transactions.test.js +400 -0
  53. package/build/exponentVaults/loopscale/prepared-transactions.test.js.map +1 -0
  54. package/build/exponentVaults/loopscale/prepared-types.d.ts +62 -0
  55. package/build/exponentVaults/loopscale/prepared-types.js +3 -0
  56. package/build/exponentVaults/loopscale/prepared-types.js.map +1 -0
  57. package/build/exponentVaults/loopscale/response-plan.d.ts +69 -0
  58. package/build/exponentVaults/loopscale/response-plan.js +141 -0
  59. package/build/exponentVaults/loopscale/response-plan.js.map +1 -0
  60. package/build/exponentVaults/loopscale/response-plan.test.d.ts +1 -0
  61. package/build/exponentVaults/loopscale/response-plan.test.js +139 -0
  62. package/build/exponentVaults/loopscale/response-plan.test.js.map +1 -0
  63. package/build/exponentVaults/loopscale/send-plan.d.ts +75 -0
  64. package/build/exponentVaults/loopscale/send-plan.js +235 -0
  65. package/build/exponentVaults/loopscale/send-plan.js.map +1 -0
  66. package/build/exponentVaults/loopscale/types.d.ts +443 -0
  67. package/build/exponentVaults/loopscale/types.js +3 -0
  68. package/build/exponentVaults/loopscale/types.js.map +1 -0
  69. package/build/exponentVaults/loopscale-client.d.ts +113 -524
  70. package/build/exponentVaults/loopscale-client.js +296 -539
  71. package/build/exponentVaults/loopscale-client.js.map +1 -1
  72. package/build/exponentVaults/loopscale-client.test.d.ts +1 -0
  73. package/build/exponentVaults/loopscale-client.test.js +162 -0
  74. package/build/exponentVaults/loopscale-client.test.js.map +1 -0
  75. package/build/exponentVaults/loopscale-client.types.d.ts +425 -0
  76. package/build/exponentVaults/loopscale-client.types.js +3 -0
  77. package/build/exponentVaults/loopscale-client.types.js.map +1 -0
  78. package/build/exponentVaults/loopscale-execution.d.ts +125 -0
  79. package/build/exponentVaults/loopscale-execution.js +341 -0
  80. package/build/exponentVaults/loopscale-execution.js.map +1 -0
  81. package/build/exponentVaults/loopscale-execution.test.d.ts +1 -0
  82. package/build/exponentVaults/loopscale-execution.test.js +139 -0
  83. package/build/exponentVaults/loopscale-execution.test.js.map +1 -0
  84. package/build/exponentVaults/loopscale-vault.d.ts +115 -0
  85. package/build/exponentVaults/loopscale-vault.js +275 -0
  86. package/build/exponentVaults/loopscale-vault.js.map +1 -0
  87. package/build/exponentVaults/loopscale-vault.test.d.ts +1 -0
  88. package/build/exponentVaults/loopscale-vault.test.js +102 -0
  89. package/build/exponentVaults/loopscale-vault.test.js.map +1 -0
  90. package/build/exponentVaults/policyBuilders.d.ts +62 -0
  91. package/build/exponentVaults/policyBuilders.js +119 -2
  92. package/build/exponentVaults/policyBuilders.js.map +1 -1
  93. package/build/exponentVaults/pricePathResolver.d.ts +45 -0
  94. package/build/exponentVaults/pricePathResolver.js +198 -0
  95. package/build/exponentVaults/pricePathResolver.js.map +1 -0
  96. package/build/exponentVaults/pricePathResolver.test.d.ts +1 -0
  97. package/build/exponentVaults/pricePathResolver.test.js +369 -0
  98. package/build/exponentVaults/pricePathResolver.test.js.map +1 -0
  99. package/build/exponentVaults/syncTransaction.js +4 -1
  100. package/build/exponentVaults/syncTransaction.js.map +1 -1
  101. package/build/exponentVaults/titan-quote.js +170 -36
  102. package/build/exponentVaults/titan-quote.js.map +1 -1
  103. package/build/exponentVaults/vault-instruction-types.d.ts +363 -0
  104. package/build/exponentVaults/vault-instruction-types.js +128 -0
  105. package/build/exponentVaults/vault-instruction-types.js.map +1 -0
  106. package/build/exponentVaults/vault-interaction.d.ts +203 -343
  107. package/build/exponentVaults/vault-interaction.js +1894 -426
  108. package/build/exponentVaults/vault-interaction.js.map +1 -1
  109. package/build/exponentVaults/vault-interaction.kamino-vault.test.d.ts +1 -0
  110. package/build/exponentVaults/vault-interaction.kamino-vault.test.js +143 -0
  111. package/build/exponentVaults/vault-interaction.kamino-vault.test.js.map +1 -0
  112. package/build/exponentVaults/vault.d.ts +51 -2
  113. package/build/exponentVaults/vault.js +324 -48
  114. package/build/exponentVaults/vault.js.map +1 -1
  115. package/build/exponentVaults/vaultTransactionBuilder.d.ts +100 -134
  116. package/build/exponentVaults/vaultTransactionBuilder.js +383 -285
  117. package/build/exponentVaults/vaultTransactionBuilder.js.map +1 -1
  118. package/build/exponentVaults/vaultTransactionBuilder.test.d.ts +1 -0
  119. package/build/exponentVaults/vaultTransactionBuilder.test.js +297 -0
  120. package/build/exponentVaults/vaultTransactionBuilder.test.js.map +1 -0
  121. package/build/marketThree.d.ts +6 -2
  122. package/build/marketThree.js +10 -8
  123. package/build/marketThree.js.map +1 -1
  124. package/package.json +34 -32
  125. package/src/client/vaults/index.ts +2 -0
  126. package/src/client/vaults/types/index.ts +2 -0
  127. package/src/client/vaults/types/kaminoFarmEntry.ts +32 -0
  128. package/src/client/vaults/types/kaminoObligationEntry.ts +6 -3
  129. package/src/client/vaults/types/positionUpdate.ts +62 -0
  130. package/src/client/vaults/types/proposalAction.ts +0 -3
  131. package/src/client/vaults/types/reserveFarmMapping.ts +35 -0
  132. package/src/client/vaults/types/strategyPosition.ts +18 -1
  133. package/src/exponentVaults/aumCalculator.ts +353 -16
  134. package/src/exponentVaults/fetcher.ts +257 -0
  135. package/src/exponentVaults/index.ts +65 -40
  136. package/src/exponentVaults/kamino-farms.ts +538 -0
  137. package/src/exponentVaults/loopscale/client.ts +808 -0
  138. package/src/exponentVaults/loopscale/helpers.ts +172 -0
  139. package/src/exponentVaults/loopscale/index.ts +57 -0
  140. package/src/exponentVaults/loopscale/prepared-transactions.ts +435 -0
  141. package/src/exponentVaults/loopscale/prepared-types.ts +73 -0
  142. package/src/exponentVaults/loopscale/types.ts +466 -0
  143. package/src/exponentVaults/policyBuilders.ts +170 -0
  144. package/src/exponentVaults/pricePathResolver.test.ts +466 -0
  145. package/src/exponentVaults/pricePathResolver.ts +273 -0
  146. package/src/exponentVaults/syncTransaction.ts +6 -1
  147. package/src/exponentVaults/titan-quote.ts +231 -45
  148. package/src/exponentVaults/vault-instruction-types.ts +493 -0
  149. package/src/exponentVaults/vault-interaction.kamino-vault.test.ts +149 -0
  150. package/src/exponentVaults/vault-interaction.ts +2818 -799
  151. package/src/exponentVaults/vault.ts +474 -63
  152. package/src/exponentVaults/vaultTransactionBuilder.test.ts +349 -0
  153. package/src/exponentVaults/vaultTransactionBuilder.ts +581 -433
  154. package/src/marketThree.ts +14 -6
  155. package/src/exponentVaults/loopscale-client.ts +0 -1373
@@ -1,20 +1,28 @@
1
1
  import {
2
+ AddressLookupTableProgram,
2
3
  Connection,
3
4
  PublicKey,
4
5
  TransactionInstruction,
5
6
  TransactionMessage,
6
7
  VersionedTransaction,
7
8
  AddressLookupTableAccount,
8
- AddressLookupTableProgram,
9
9
  ComputeBudgetProgram,
10
- SystemProgram,
11
10
  } from "@solana/web3.js"
12
11
  import type * as web3 from "@solana/web3.js"
13
12
  import type { ExponentPrices } from "@exponent-labs/exponent-vaults-fetcher"
14
13
  import type { ExponentVault } from "./vault"
15
- import { LoopscaleClient } from "./loopscale-client"
16
- import { createVaultSyncTransaction, type VaultInstruction } from "./vault-interaction"
14
+ import { LoopscaleClient } from "./loopscale"
15
+ import type { LoopscaleTransactionResponse } from "./loopscale"
16
+ import {
17
+ buildSetupStatePriceRefreshInstructions,
18
+ createVaultSyncTransactions,
19
+ createStrategySetupContext,
20
+ type VaultInstruction,
21
+ } from "./vault-interaction"
17
22
  import { SQUADS_PROGRAM_ID } from "./syncTransaction"
23
+ import { LOCAL_ENV } from "../environment"
24
+
25
+ const SCOPE_PROGRAM_ID = new PublicKey("HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ")
18
26
 
19
27
  // ============================================================================
20
28
  // Types
@@ -46,6 +54,20 @@ export interface StepOptions {
46
54
  priorityFee?: number
47
55
  }
48
56
 
57
+ /** Label-only options for raw Loopscale response steps. */
58
+ export interface LoopscaleStepOptions {
59
+ /** Human-readable label for this Loopscale step. Auto-generated if omitted. */
60
+ label?: string
61
+ }
62
+
63
+ /** Optional Loopscale client config used by the builder for preparation and MPC co-signing. */
64
+ export interface VaultTransactionBuilderLoopscaleConfig {
65
+ /** Override the Loopscale API base URL. */
66
+ baseUrl?: string
67
+ /** Enable verbose Loopscale request/response logging. */
68
+ debug?: boolean
69
+ }
70
+
49
71
  /**
50
72
  * Configuration for creating a {@link VaultTransactionBuilder}.
51
73
  *
@@ -55,8 +77,8 @@ export interface StepOptions {
55
77
  * vault, // loaded ExponentVault instance
56
78
  * connection, // Solana RPC connection
57
79
  * signer: manager.publicKey, // strategy manager's public key
58
- * vaultPda: squadsVault, // Squads vault PDA
59
- * pricesAccount, // fetched ExponentPrices for oracle refresh
80
+ * autoManagePositions: true, // optional, defaults to true
81
+ * pricesAccount, // optional prefetched ExponentPrices snapshot
60
82
  * })
61
83
  * ```
62
84
  */
@@ -67,57 +89,55 @@ export interface VaultTransactionBuilderConfig {
67
89
  connection: Connection
68
90
  /** The strategy manager's public key. Used as the signer for policy matching and as the fee payer. */
69
91
  signer: PublicKey
70
- /** The Squads multisig vault PDA that owns the strategy's assets. */
71
- vaultPda: PublicKey
92
+ /** Optional vault PDA override. Defaults to `vault.state.squadsVault`. */
93
+ vaultPda?: PublicKey
94
+ /** Optional extra lookup tables to include alongside the vault-managed tables. */
95
+ lookupTableAddresses?: PublicKey[]
72
96
  /**
73
- * Fetched `ExponentPrices` account snapshot.
97
+ * Fetched `ExponentPrices` account snapshot (optional).
74
98
  *
75
- * Used to build price refresh instructions (via `vault.ixsUpdateStrategyVaultPrices()`).
76
- * The price refresh transaction is always emitted as the first TransactionSet, ensuring
77
- * oracle prices are fresh when the sync transaction executes in the same slot.
78
- *
79
- * Fetch this via `ExponentVaultsFetcher.fetchPricesDeserialized()` before creating the builder.
99
+ * When provided, the builder reuses this snapshot to assemble same-transaction
100
+ * `UpdatePrice` instructions for any setup or sync transaction that can hit
101
+ * AUM recalculation. When omitted, the builder fetches the current prices
102
+ * account automatically.
80
103
  */
81
- pricesAccount: ExponentPrices
104
+ pricesAccount?: ExponentPrices
82
105
  /** Default compute unit limit for all steps (default: 1,400,000). Override per-step via {@link StepOptions}. */
83
106
  computeUnitLimit?: number
84
107
  /** Default heap frame allocation in bytes for all steps (default: 262,144 = 256 KB). Override per-step via {@link StepOptions}. */
85
108
  heapFrameBytes?: number
109
+ /** Automatically manage new Kamino/CLMM/yield position tracking for manager flows. Defaults to `true`. */
110
+ autoManagePositions?: boolean
86
111
  /** Squads program ID override. Defaults to the standard Squads V4 program. */
87
112
  squadsProgram?: PublicKey
88
- /**
89
- * Compute budget overrides for the price refresh transaction.
90
- *
91
- * The price refresh tx runs oracle update instructions which are typically lighter
92
- * than sync transactions. You can lower the CU limit here to save on fees, or
93
- * set a priority fee to ensure the refresh lands quickly.
94
- *
95
- * @example
96
- * ```ts
97
- * VaultTransactionBuilder.create({
98
- * // ...
99
- * priceRefreshOptions: { computeUnitLimit: 600_000, priorityFee: 100 },
100
- * })
101
- * ```
102
- */
103
- priceRefreshOptions?: StepOptions
113
+ /** Optional Loopscale client config used when preparing and co-signing Loopscale transactions. */
114
+ loopscale?: VaultTransactionBuilderLoopscaleConfig
104
115
  }
105
116
 
106
- interface StepConfig {
107
- label: string
108
- instructions: VaultInstruction[]
109
- isLoopscale: boolean
110
- options: StepOptions
111
- }
117
+ type StepConfig =
118
+ | {
119
+ kind: "instructions"
120
+ label: string
121
+ instructions: VaultInstruction[]
122
+ options: StepOptions
123
+ }
124
+ | {
125
+ kind: "loopscale-response"
126
+ label: string
127
+ response: LoopscaleTransactionResponse
128
+ options: LoopscaleStepOptions
129
+ }
112
130
 
113
131
  /**
114
132
  * A group of instructions that will be assembled into a single VersionedTransaction.
115
133
  *
116
- * The builder produces an ordered array of TransactionSets: price refresh first,
117
- * then optional setup transactions, then one sync transaction per step.
134
+ * The builder produces an ordered array of TransactionSets: optional setup
135
+ * transactions, then one sync transaction per step. Same-transaction Exponent
136
+ * price refreshes are injected directly into any set that can hit AUM
137
+ * recalculation.
118
138
  */
119
139
  export interface TransactionSet {
120
- /** Human-readable label identifying this transaction (e.g. "price-refresh", "step-1", "step-1-setup"). */
140
+ /** Human-readable label identifying this transaction (e.g. "step-1", "step-1-setup"). */
121
141
  label: string
122
142
  /** All instructions in execution order, including compute budget instructions. */
123
143
  instructions: TransactionInstruction[]
@@ -125,121 +145,73 @@ export interface TransactionSet {
125
145
  signers: web3.Signer[]
126
146
  /** Address lookup table addresses that should be loaded for this transaction's V0 message. */
127
147
  addressLookupTableAddresses: PublicKey[]
148
+ /** Whether this transaction needs Loopscale MPC co-signing before broadcast. */
149
+ coSignWithLoopscale?: boolean
128
150
  }
129
151
 
130
152
  /**
131
- * Options for {@link VaultTransactionBuildResult.sendJitoBundle}.
153
+ * Options for {@link VaultTransactionBuildResult.send}.
132
154
  */
133
- export interface JitoBundleSendOptions {
134
- /** Keypair signers. The first signer is used as the fee payer and Jito tip payer. */
155
+ export interface SendOptions {
156
+ /** Keypair signers. The first signer is used as the fee payer. */
135
157
  signers: web3.Signer[]
136
- /** Jito tip amount in lamports. Appended as a transfer instruction to the last transaction. */
137
- tipLamports: number
138
- /** Commitment level for bundle confirmation (default: "confirmed"). */
158
+ /** Commitment level for transaction confirmation (default: "confirmed"). */
139
159
  commitment?: web3.Commitment
140
- /** Jito auth key for authenticated block engine endpoints. */
141
- jitoAuthKey?: string
142
- /**
143
- * Custom send function that replaces the default Jito bundle send.
144
- *
145
- * Receives signed (and co-signed) VersionedTransactions and their labels.
146
- * Must return an array of transaction signatures in the same order.
147
- * Use this for Surfpool, local validators, or any non-Jito send flow.
148
- *
149
- * @example
150
- * ```ts
151
- * result.sendJitoBundle({
152
- * signers: [manager],
153
- * tipLamports: 0,
154
- * customSend: async (txs, labels) => {
155
- * return Promise.all(txs.map(tx => surfpool.sendTransaction(tx)))
156
- * },
157
- * })
158
- * ```
159
- */
160
- customSend?: (transactions: VersionedTransaction[], labels: string[]) => Promise<string[]>
161
160
  }
162
161
 
163
- /** Result from {@link VaultTransactionBuildResult.sendJitoBundle}. */
164
- export interface JitoBundleResult {
162
+ /** Result from {@link VaultTransactionBuildResult.send}. */
163
+ export interface SendResult {
165
164
  /** Transaction signatures in the same order as the TransactionSets. */
166
165
  signatures: string[]
167
- /** Jito bundle ID. Empty string if using customSend or if the mainnet endpoint did not return one. */
168
- bundleId: string
166
+ /**
167
+ * Non-null when an ephemeral address lookup table was created to fit all
168
+ * accounts into the transactions. The caller can deactivate and close it
169
+ * to reclaim rent when they no longer need it.
170
+ *
171
+ * Cleanup requires two steps (Solana constraint):
172
+ * 1. `AddressLookupTableProgram.deactivateLookupTable({ lookupTable: address, authority })`
173
+ * 2. After ~one epoch, `AddressLookupTableProgram.closeLookupTable({ lookupTable: address, authority, recipient: authority })`
174
+ */
175
+ ephemeralAlt: { address: PublicKey; authority: PublicKey } | null
169
176
  }
170
177
 
171
178
  /**
172
179
  * The result of calling {@link VaultTransactionBuilder.build}.
173
180
  *
174
- * Contains the assembled TransactionSets and provides `sendJitoBundle()` to send them.
175
- * Inspect `transactionSets` to review instructions before sending.
176
- *
177
- * Price refresh and sync transactions must land in the same slot — use `sendJitoBundle()`
178
- * for atomic same-slot execution via Jito, or `customSend` for testing environments.
181
+ * Contains the assembled TransactionSets and a `send()` method for sequential execution.
182
+ * Loopscale co-signing is handled automatically.
179
183
  *
180
184
  * @example
181
185
  * ```ts
182
186
  * const result = await builder.build()
183
187
  *
184
188
  * // Inspect
185
- * console.log(result.labels) // ["price-refresh", "step-1", "loopscale-step-2"]
186
- *
187
- * // Send as Jito bundle
188
- * await result.sendJitoBundle({ signers: [manager], tipLamports: 10_000 })
189
+ * console.log(result.labels) // ["step-1", "loopscale-step-2"]
189
190
  *
190
- * // Or for testing (Surfpool, local validator):
191
- * await result.sendJitoBundle({
192
- * signers: [manager], tipLamports: 0,
193
- * customSend: async (txs) => surfpool.sendAll(txs),
194
- * })
191
+ * // Send sequentially
192
+ * await result.send({ signers: [manager] })
195
193
  * ```
196
194
  */
197
195
  export interface VaultTransactionBuildResult {
198
- /** Ordered TransactionSets: price refresh (index 0), then setup + sync per step. */
196
+ /** Ordered TransactionSets: setup + sync per step, with same-tx price refresh injected as needed. */
199
197
  transactionSets: TransactionSet[]
200
198
  /** Deduplicated address lookup table addresses across all TransactionSets. */
201
199
  lookupTableAddresses: PublicKey[]
202
200
  /** Labels for each TransactionSet, in order. */
203
201
  labels: string[]
204
202
  /**
205
- * Sign all transactions, append a Jito tip to the last one, and send as an atomic Jito bundle.
203
+ * Send transactions sequentially via regular RPC.
206
204
  *
207
- * All transactions share the same blockhash and are executed in the same slot.
208
- * Loopscale steps are automatically co-signed via an internally created LoopscaleClient.
209
- * Non-Loopscale steps are signed normally.
205
+ * Each transaction is built, signed (and co-signed for Loopscale steps),
206
+ * sent via `connection.sendTransaction()`, and confirmed before proceeding
207
+ * to the next one. Suitable for most operations.
210
208
  *
211
- * Use `customSend` for testing environments where Jito is not available.
209
+ * **Note:** Does not guarantee atomicity if a later transaction fails,
210
+ * earlier ones have already landed.
212
211
  */
213
- sendJitoBundle(options: JitoBundleSendOptions): Promise<JitoBundleResult>
212
+ send(options: SendOptions): Promise<SendResult>
214
213
  }
215
214
 
216
- // ============================================================================
217
- // Jito constants
218
- // ============================================================================
219
-
220
- const JITO_TIP_ADDRESSES = [
221
- new PublicKey("96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5"),
222
- new PublicKey("HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe"),
223
- new PublicKey("Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY"),
224
- new PublicKey("ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49"),
225
- new PublicKey("DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh"),
226
- new PublicKey("ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt"),
227
- new PublicKey("DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL"),
228
- new PublicKey("3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT"),
229
- ]
230
-
231
- const JITO_REGIONAL_URLS = [
232
- "https://amsterdam.mainnet.block-engine.jito.wtf",
233
- "https://frankfurt.mainnet.block-engine.jito.wtf",
234
- "https://ny.mainnet.block-engine.jito.wtf",
235
- "https://tokyo.mainnet.block-engine.jito.wtf",
236
- ]
237
-
238
- const JITO_MAINNET_URL = "https://mainnet.block-engine.jito.wtf"
239
- const JITO_BUNDLES_PATH = "/api/v1/bundles"
240
- const JITO_MAX_RETRIES = 10
241
- const JITO_RETRY_DELAY_MS = 1000
242
-
243
215
  // ============================================================================
244
216
  // VaultTransactionBuilder
245
217
  // ============================================================================
@@ -248,42 +220,44 @@ const JITO_RETRY_DELAY_MS = 1000
248
220
  * Orchestrates strategy vault transaction construction.
249
221
  *
250
222
  * Replaces the per-recipe boilerplate of manually assembling compute budgets,
251
- * price refresh instructions, sync transaction wrapping, ALT gathering, and
252
- * setup transaction splitting.
223
+ * sync transaction wrapping, ALT gathering, setup transaction splitting, and
224
+ * forward-looking Kamino/CLMM/yield position tracking.
253
225
  *
254
- * **Lifecycle:** create → add actions → build → sendJitoBundle
226
+ * **Lifecycle:** create → add actions → build → send()
255
227
  *
256
228
  * @example Single action
257
229
  * ```ts
258
230
  * const result = await VaultTransactionBuilder
259
- * .create({ vault, connection, signer: manager.publicKey, vaultPda, pricesAccount })
231
+ * .create({ vault, connection, signer: manager.publicKey })
260
232
  * .addActions([clmmAction.buyPt({ market, ptOutMin, syInMax })])
261
233
  * .build()
262
234
  *
263
- * await result.sendJitoBundle({ signers: [manager], tipLamports: 10_000 })
235
+ * await result.send({ signers: [manager] })
264
236
  * ```
265
237
  *
266
238
  * @example Multi-step — each addActions() becomes its own sync transaction
267
239
  * ```ts
268
240
  * const result = await VaultTransactionBuilder
269
- * .create({ vault, connection, signer: manager.publicKey, vaultPda, pricesAccount })
241
+ * .create({ vault, connection, signer: manager.publicKey })
270
242
  * .addActions([kaminoAction.deposit("MAIN", "USDC", amount)]) // sync tx 1
271
243
  * .addActions([kaminoAction.borrow("MAIN", "SOL", amount)]) // sync tx 2
272
244
  * .build()
273
245
  *
274
- * await result.sendJitoBundle({ signers: [manager], tipLamports: 10_000 })
246
+ * await result.send({ signers: [manager] })
275
247
  * ```
276
248
  *
277
249
  * @example Mixed Kamino + Loopscale — co-signing handled automatically
278
250
  * ```ts
251
+ * const loopscaleDeposit = await loopscale.depositStrategy({ ... })
252
+ *
279
253
  * const result = await VaultTransactionBuilder
280
- * .create({ vault, connection, signer: manager.publicKey, vaultPda, pricesAccount })
254
+ * .create({ vault, connection, signer: manager.publicKey })
281
255
  * .addActions([kaminoAction.deposit(...)]) // signed normally
282
- * .addLoopscaleActions([loopscaleAction.createStrategy({ ... })]) // co-signed automatically
256
+ * .addLoopscaleResponse(loopscaleDeposit) // co-signed automatically
283
257
  * .build()
284
258
  *
285
- * // sendJitoBundle auto-creates a LoopscaleClient and co-signs Loopscale steps
286
- * await result.sendJitoBundle({ signers: [manager], tipLamports: 10_000 })
259
+ * // send() auto-creates a LoopscaleClient for Loopscale co-signing
260
+ * await result.send({ signers: [manager] })
287
261
  * ```
288
262
  */
289
263
  export class VaultTransactionBuilder {
@@ -298,7 +272,7 @@ export class VaultTransactionBuilder {
298
272
  /**
299
273
  * Create a new builder instance.
300
274
  *
301
- * @param config - Builder configuration including the vault, connection, signer, and prices.
275
+ * @param config - Builder configuration including the vault, connection, and signer.
302
276
  * @returns A new {@link VaultTransactionBuilder} ready for action chaining.
303
277
  */
304
278
  static create(config: VaultTransactionBuilderConfig): VaultTransactionBuilder {
@@ -331,30 +305,29 @@ export class VaultTransactionBuilder {
331
305
  addActions(instructions: VaultInstruction[], options?: StepOptions): this {
332
306
  this.stepCounter++
333
307
  const label = options?.label ?? `step-${this.stepCounter}`
334
- this.steps.push({ label, instructions: [...instructions], isLoopscale: false, options: options ?? {} })
308
+ this.steps.push({ kind: "instructions", label, instructions: [...instructions], options: options ?? {} })
335
309
  return this
336
310
  }
337
311
 
338
312
  /**
339
- * Add Loopscale vault instructions as a new step.
313
+ * Add a raw Loopscale response as a new step.
340
314
  *
341
- * Each call creates a separate Squads sync transaction, marked for Loopscale co-signing.
342
- * At send time, these steps are automatically co-signed via an internally created
343
- * LoopscaleClient no separate configuration needed.
315
+ * The builder will flatten the response into manager-facing transactions,
316
+ * preserving Loopscale-signed segments while keeping local setup/top-level
317
+ * transactions outside the Loopscale signing domain.
344
318
  *
345
- * **Warning:** Passing multiple Loopscale actions in a single array packs them into one
346
- * sync transaction. This can exceed the 1232-byte limit the builder will throw at
347
- * build time if oversized. Loopscale transactions are particularly account-heavy;
348
- * prefer one action per call.
319
+ * @param response - Raw Loopscale response returned by {@link LoopscaleClient}.
320
+ * Only `label` is supported for Loopscale steps. Compute overrides are
321
+ * preserved on the raw Loopscale response and are not applied by the
322
+ * builder.
349
323
  *
350
- * @param instructions - Array of Loopscale VaultInstruction descriptors (from loopscaleAction).
351
- * @param options - Optional per-step compute budget overrides and label.
324
+ * @param options - Optional step label.
352
325
  * @returns `this` for chaining.
353
326
  */
354
- addLoopscaleActions(instructions: VaultInstruction[], options?: StepOptions): this {
327
+ addLoopscaleResponse(response: LoopscaleTransactionResponse, options?: LoopscaleStepOptions): this {
355
328
  this.stepCounter++
356
329
  const label = options?.label ?? `loopscale-step-${this.stepCounter}`
357
- this.steps.push({ label, instructions: [...instructions], isLoopscale: true, options: options ?? {} })
330
+ this.steps.push({ kind: "loopscale-response", label, response, options: options ?? {} })
358
331
  return this
359
332
  }
360
333
 
@@ -362,51 +335,145 @@ export class VaultTransactionBuilder {
362
335
  * Build TransactionSets from the configured steps.
363
336
  *
364
337
  * Produces an ordered array of TransactionSets:
365
- * 1. **Price refresh** (index 0) oracle price update instructions
366
- * 2. **Setup transactions** — token account creation (only if needed)
367
- * 3. **Sync transactions** — one per step, wrapped in Squads sync transaction
338
+ * 1. **Setup transactions** token account creation / builder-managed setup (only if needed)
339
+ * 2. **Sync transactions** — one per step, wrapped in Squads sync transaction
340
+ *
341
+ * Same-transaction `UpdatePrice` instructions are injected directly into any
342
+ * setup or sync set that can hit AUM recalculation.
368
343
  *
369
- * The price refresh must land in the same slot as the sync transactions.
370
- * Use `result.sendJitoBundle()` for atomic same-slot execution.
344
+ * Use `result.send()` for sequential execution.
371
345
  *
372
- * @returns A {@link VaultTransactionBuildResult} with TransactionSets and a sendJitoBundle method.
346
+ * @returns A {@link VaultTransactionBuildResult} with TransactionSets and a send method.
373
347
  * @throws If no steps have been configured (call addActions first).
374
348
  * @throws If any assembled transaction exceeds the 1232-byte Solana packet limit.
375
349
  */
376
350
  async build(): Promise<VaultTransactionBuildResult> {
377
351
  if (this.steps.length === 0) {
378
- throw new Error("No steps configured. Call addActions() or addLoopscaleActions() before build().")
352
+ throw new Error("No steps configured. Call addActions() or addLoopscaleResponse() before build().")
379
353
  }
380
354
 
381
- const { vault, connection, signer, vaultPda, pricesAccount } = this.config
355
+ const { vault, connection, signer, pricesAccount } = this.config
356
+ const vaultPda = this.config.vaultPda ?? vault.state.squadsVault
357
+ const extraLookupTableAddresses = this.config.lookupTableAddresses ?? []
358
+ const autoManagePositions = this.config.autoManagePositions ?? true
382
359
  const squadsProgram = this.config.squadsProgram ?? SQUADS_PROGRAM_ID
383
360
  const defaultCU = this.config.computeUnitLimit ?? 1_400_000
384
361
  const defaultHeap = this.config.heapFrameBytes ?? 256 * 1024
385
362
 
386
- // 1. Price refresh TransactionSet
387
- const priceOpts = this.config.priceRefreshOptions ?? {}
388
- const priceIxs = await vault.ixsUpdateStrategyVaultPrices(pricesAccount)
389
- const priceSet: TransactionSet = {
390
- label: priceOpts.label ?? "price-refresh",
391
- instructions: buildComputeBudgetIxs(
392
- priceOpts.computeUnitLimit ?? defaultCU,
393
- priceOpts.heapFrameBytes ?? defaultHeap,
394
- priceOpts.priorityFee,
395
- ).concat(priceIxs),
396
- signers: [],
397
- addressLookupTableAddresses: vault.state.addressLookupTable
398
- ? [vault.state.addressLookupTable]
399
- : [],
400
- }
401
-
402
- // 2. Per-step sync TransactionSets
403
- const transactionSets: TransactionSet[] = [priceSet]
363
+ const transactionSets: TransactionSet[] = []
404
364
  const allAltAddresses = new Set<string>(
405
- priceSet.addressLookupTableAddresses.map((a) => a.toBase58()),
365
+ [
366
+ ...(vault.state.addressLookupTable ? [vault.state.addressLookupTable.toBase58()] : []),
367
+ ...extraLookupTableAddresses.map((address) => address.toBase58()),
368
+ ],
406
369
  )
407
370
 
371
+ // Share a single setup context across all steps so that tracked accounts,
372
+ // positions, and orderbooks carry over — preventing duplicate setup txs.
373
+ const sharedSetupContext = createStrategySetupContext({
374
+ connection,
375
+ env: LOCAL_ENV,
376
+ owner: vaultPda,
377
+ signer,
378
+ vaultAddress: vault.selfAddress,
379
+ vaultPda,
380
+ accountIndex: 0,
381
+ squadsProgram,
382
+ autoManagePositions,
383
+ pricesAccount,
384
+ })
385
+ let loopscaleClient: LoopscaleClient | undefined
386
+
408
387
  for (const step of this.steps) {
409
- const syncResult = await createVaultSyncTransaction({
388
+ if (step.kind === "loopscale-response") {
389
+ loopscaleClient ??= createBuilderLoopscaleClient({
390
+ connection,
391
+ vaultPda,
392
+ loopscale: this.config.loopscale,
393
+ })
394
+
395
+ const preparedTransactions = await loopscaleClient.prepareVaultTransactions({
396
+ response: step.response,
397
+ context: {
398
+ connection,
399
+ signer,
400
+ vaultPda,
401
+ vaultAddress: vault.selfAddress,
402
+ squadsProgram,
403
+ },
404
+ })
405
+ const stepPriceRefreshIxs = await buildSetupStatePriceRefreshInstructions(sharedSetupContext)
406
+
407
+ for (const [preparedIndex, preparedTransaction] of preparedTransactions.entries()) {
408
+ const txLabel = preparedTransactions.length === 1
409
+ ? step.label
410
+ : `${step.label}-${preparedIndex + 1}`
411
+
412
+ if (preparedTransaction.setupInstructions.length > 0) {
413
+ const setupAltAddresses = mergeAddressLookupTableAddresses(
414
+ extraLookupTableAddresses,
415
+ vault.state.addressLookupTable ? [vault.state.addressLookupTable] : [],
416
+ )
417
+ const setupSet: TransactionSet = {
418
+ label: `${txLabel}-setup`,
419
+ instructions: [
420
+ ComputeBudgetProgram.setComputeUnitLimit({ units: defaultCU }),
421
+ ...preparedTransaction.setupInstructions,
422
+ ],
423
+ signers: [],
424
+ addressLookupTableAddresses: setupAltAddresses,
425
+ coSignWithLoopscale: false,
426
+ }
427
+ if (touchesManagedVaultPrograms(setupSet.instructions, vault.programId, squadsProgram)) {
428
+ setupSet.instructions = insertAfterLeadingComputeBudgetAndScopeInstructions(
429
+ setupSet.instructions,
430
+ stepPriceRefreshIxs,
431
+ )
432
+ }
433
+ transactionSets.push(setupSet)
434
+ for (const alt of setupSet.addressLookupTableAddresses) {
435
+ allAltAddresses.add(alt.toBase58())
436
+ }
437
+ }
438
+
439
+ const shouldInjectLocalLoopscalePriceRefresh = (
440
+ !preparedTransaction.requiresLoopscaleCoSign
441
+ && touchesManagedVaultPrograms(preparedTransaction.instructions, vault.programId, squadsProgram)
442
+ )
443
+ const loopscaleSet: TransactionSet = {
444
+ label: txLabel,
445
+ instructions: preparedTransaction.instructions,
446
+ signers: preparedTransaction.signers,
447
+ addressLookupTableAddresses: shouldInjectLocalLoopscalePriceRefresh
448
+ ? mergeAddressLookupTableAddresses(
449
+ preparedTransaction.addressLookupTableAddresses,
450
+ extraLookupTableAddresses,
451
+ vault.state.addressLookupTable ? [vault.state.addressLookupTable] : [],
452
+ )
453
+ : mergeAddressLookupTableAddresses(
454
+ preparedTransaction.addressLookupTableAddresses,
455
+ extraLookupTableAddresses,
456
+ ),
457
+ coSignWithLoopscale: preparedTransaction.requiresLoopscaleCoSign,
458
+ }
459
+ if (shouldInjectLocalLoopscalePriceRefresh) {
460
+ loopscaleSet.instructions = insertAfterLeadingComputeBudgetAndScopeInstructions(
461
+ loopscaleSet.instructions,
462
+ stepPriceRefreshIxs,
463
+ )
464
+ }
465
+ transactionSets.push(loopscaleSet)
466
+
467
+ for (const alt of loopscaleSet.addressLookupTableAddresses) {
468
+ allAltAddresses.add(alt.toBase58())
469
+ }
470
+ }
471
+ continue
472
+ }
473
+
474
+ const stepCU = step.options.computeUnitLimit ?? defaultCU
475
+ const stepHeap = step.options.heapFrameBytes ?? defaultHeap
476
+ const syncResults = await createVaultSyncTransactions({
410
477
  instructions: step.instructions,
411
478
  owner: vaultPda,
412
479
  connection,
@@ -414,215 +481,219 @@ export class VaultTransactionBuilder {
414
481
  signer,
415
482
  vaultAddress: vault.selfAddress,
416
483
  squadsProgram,
484
+ autoManagePositions,
485
+ setupContext: sharedSetupContext,
417
486
  })
418
487
 
419
- if (syncResult.setupInstructions.length > 0) {
420
- transactionSets.push({
421
- label: `${step.label}-setup`,
488
+ const stepPriceRefreshIxs = await buildSetupStatePriceRefreshInstructions(sharedSetupContext)
489
+
490
+ for (const [syncIndex, syncResult] of syncResults.entries()) {
491
+ const txLabel = syncResults.length === 1
492
+ ? step.label
493
+ : `${step.label}-${syncIndex + 1}`
494
+
495
+ if (syncResult.setupInstructions.length > 0) {
496
+ const setupSet: TransactionSet = {
497
+ label: `${txLabel}-setup`,
498
+ instructions: [
499
+ ComputeBudgetProgram.setComputeUnitLimit({ units: defaultCU }),
500
+ ...syncResult.setupInstructions,
501
+ ],
502
+ signers: [],
503
+ addressLookupTableAddresses: mergeAddressLookupTableAddresses(
504
+ syncResult.addressLookupTableAddresses,
505
+ extraLookupTableAddresses,
506
+ vault.state.addressLookupTable ? [vault.state.addressLookupTable] : [],
507
+ ),
508
+ coSignWithLoopscale: false,
509
+ }
510
+ if (shouldInjectPriceRefresh(setupSet, vault.programId, squadsProgram)) {
511
+ setupSet.instructions = insertAfterLeadingComputeBudgetAndScopeInstructions(
512
+ setupSet.instructions,
513
+ stepPriceRefreshIxs,
514
+ )
515
+ }
516
+ transactionSets.push(setupSet)
517
+ for (const alt of setupSet.addressLookupTableAddresses) {
518
+ allAltAddresses.add(alt.toBase58())
519
+ }
520
+ }
521
+
522
+ const syncSet: TransactionSet = {
523
+ label: txLabel,
422
524
  instructions: [
423
- ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }),
424
- ...syncResult.setupInstructions,
525
+ ...buildComputeBudgetIxs(stepCU, stepHeap, step.options.priorityFee),
526
+ ...syncResult.preInstructions,
527
+ syncResult.instruction,
528
+ ...syncResult.postInstructions,
425
529
  ],
426
- signers: [],
427
- addressLookupTableAddresses: [],
428
- })
429
- }
430
-
431
- const stepCU = step.options.computeUnitLimit ?? defaultCU
432
- const stepHeap = step.options.heapFrameBytes ?? defaultHeap
433
-
434
- const syncSet: TransactionSet = {
435
- label: step.label,
436
- instructions: [
437
- ...buildComputeBudgetIxs(stepCU, stepHeap, step.options.priorityFee),
438
- ...syncResult.preInstructions,
439
- syncResult.instruction,
440
- ...syncResult.postInstructions,
441
- ],
442
- signers: syncResult.signers,
443
- addressLookupTableAddresses: [
444
- ...syncResult.addressLookupTableAddresses,
445
- ...(vault.state.addressLookupTable ? [vault.state.addressLookupTable] : []),
446
- ],
447
- }
448
- transactionSets.push(syncSet)
530
+ signers: syncResult.signers,
531
+ addressLookupTableAddresses: mergeAddressLookupTableAddresses(
532
+ syncResult.addressLookupTableAddresses,
533
+ extraLookupTableAddresses,
534
+ vault.state.addressLookupTable ? [vault.state.addressLookupTable] : [],
535
+ ),
536
+ coSignWithLoopscale: false,
537
+ }
538
+ if (shouldInjectPriceRefresh(syncSet, vault.programId, squadsProgram)) {
539
+ syncSet.instructions = insertAfterLeadingComputeBudgetAndScopeInstructions(
540
+ syncSet.instructions,
541
+ stepPriceRefreshIxs,
542
+ )
543
+ }
544
+ transactionSets.push(syncSet)
449
545
 
450
- for (const alt of syncSet.addressLookupTableAddresses) {
451
- allAltAddresses.add(alt.toBase58())
546
+ for (const alt of syncSet.addressLookupTableAddresses) {
547
+ allAltAddresses.add(alt.toBase58())
548
+ }
452
549
  }
453
550
  }
454
551
 
455
552
  const lookupTableAddresses = [...allAltAddresses].map((a) => new PublicKey(a))
456
553
 
457
- // Validate transaction sizes catch oversized txs early with a clear error
458
- const altAccounts = await resolveAltAccounts(connection, lookupTableAddresses)
459
- for (const txSet of transactionSets) {
460
- const txAltAccounts = altAccounts.filter((alt) =>
461
- txSet.addressLookupTableAddresses.some((addr) => addr.equals(alt.key)),
462
- )
463
- try {
464
- const testMsg = new TransactionMessage({
465
- payerKey: signer,
466
- recentBlockhash: "1".repeat(44),
467
- instructions: txSet.instructions,
468
- }).compileToV0Message(txAltAccounts)
469
- const testTx = new VersionedTransaction(testMsg)
470
- const serialized = testTx.serialize()
471
- if (serialized.length > 1232) {
472
- throw new Error(
473
- `Transaction "${txSet.label}" is oversized (${serialized.length} bytes, limit 1232). ` +
474
- `This usually means too many actions were packed into a single addActions() call. ` +
475
- `Split them into separate addActions() calls so each gets its own sync transaction.`,
476
- )
477
- }
478
- } catch (e) {
479
- if (e instanceof Error && e.message.includes("oversized")) throw e
554
+ // Resolve the vault's persistent ALT to determine which account keys it
555
+ // already covers. Any keys NOT covered will need an ephemeral ALT.
556
+ const persistentAltAccounts = await resolveAltAccounts(connection, lookupTableAddresses)
557
+ const persistentAltKeys = new Set<string>()
558
+ for (const alt of persistentAltAccounts) {
559
+ for (const addr of alt.state.addresses) {
560
+ persistentAltKeys.add(addr.toBase58())
480
561
  }
481
562
  }
482
563
 
564
+ // Collect every unique account key referenced by any transaction set.
565
+ const allUniqueKeys = collectUniqueAccountKeys(transactionSets, signer)
566
+ const ephemeralAltEntries = allUniqueKeys.filter((k) => !persistentAltKeys.has(k.toBase58()))
567
+
568
+ // Validate transaction sizes. When an ephemeral ALT will be created,
569
+ // skip validation here — send() will validate after ALT activation.
570
+ if (ephemeralAltEntries.length === 0) {
571
+ validateTransactionSizes(transactionSets, persistentAltAccounts, signer)
572
+ }
573
+
483
574
  const labels = transactionSets.map((ts) => ts.label)
484
575
 
485
- // Capture state for sendJitoBundle closure
576
+ // Capture state for send() closure
486
577
  const closedConnection = connection
487
578
  const closedSigner = signer
579
+ const closedVaultPda = vaultPda
488
580
  const closedLookupTableAddresses = lookupTableAddresses
489
- const closedSteps = this.steps
581
+ const closedLoopscaleConfig = this.config.loopscale
490
582
 
491
583
  return {
492
584
  transactionSets,
493
585
  lookupTableAddresses,
494
586
  labels,
495
587
 
496
- async sendJitoBundle(options: JitoBundleSendOptions): Promise<JitoBundleResult> {
588
+ async send(options: SendOptions): Promise<SendResult> {
589
+ const commitment = options.commitment ?? "confirmed"
590
+ const payer = options.signers[0]
591
+
592
+ // ── Ephemeral ALT creation ───────────────────────────────────────
593
+ let ephemeralAlt: { address: PublicKey; authority: PublicKey } | null = null
594
+ let ephemeralAltAccount: AddressLookupTableAccount | undefined
595
+
596
+ if (ephemeralAltEntries.length > 0) {
597
+ const slot = await closedConnection.getSlot("processed")
598
+ const recentSlot = Math.max(slot - 1, 0)
599
+ const [createIx, altAddress] = AddressLookupTableProgram.createLookupTable({
600
+ authority: payer.publicKey,
601
+ payer: payer.publicKey,
602
+ recentSlot,
603
+ })
604
+
605
+ // Chunk extend instructions — each can hold ~20 addresses safely
606
+ const extendChunks = chunkArray(ephemeralAltEntries, 20)
607
+ const extendIxs = extendChunks.map((chunk) =>
608
+ AddressLookupTableProgram.extendLookupTable({
609
+ lookupTable: altAddress,
610
+ authority: payer.publicKey,
611
+ payer: payer.publicKey,
612
+ addresses: chunk,
613
+ }),
614
+ )
615
+
616
+ // Send create + first extend in one transaction
617
+ const altSetupIxs: TransactionInstruction[] = [createIx, extendIxs[0]!]
618
+ await sendAndConfirmTransaction(closedConnection, altSetupIxs, [payer], commitment)
619
+
620
+ // Send remaining extend chunks if any
621
+ for (let i = 1; i < extendIxs.length; i++) {
622
+ await sendAndConfirmTransaction(closedConnection, [extendIxs[i]!], [payer], commitment)
623
+ }
624
+
625
+ // Wait for ALT entries to become active (usable in the slot AFTER extension)
626
+ await waitForSlotAdvance(closedConnection, slot, "processed")
627
+
628
+ // Resolve the ephemeral ALT account for use in transactions
629
+ const resolved = await closedConnection.getAddressLookupTable(altAddress)
630
+ if (!resolved.value) {
631
+ throw new Error(`Ephemeral ALT ${altAddress.toBase58()} not found after creation`)
632
+ }
633
+ ephemeralAltAccount = resolved.value
634
+ ephemeralAlt = { address: altAddress, authority: payer.publicKey }
635
+ }
636
+
637
+ // ── Resolve all ALT accounts (persistent + ephemeral) ────────────
497
638
  const resolvedAltAccounts = await resolveAltAccounts(closedConnection, closedLookupTableAddresses)
498
- const { blockhash, lastValidBlockHeight } = await closedConnection.getLatestBlockhash()
639
+ if (ephemeralAltAccount) {
640
+ resolvedAltAccounts.push(ephemeralAltAccount)
641
+ }
642
+
643
+ // If we skipped build-time validation, validate now with the full ALT set
644
+ if (ephemeralAltEntries.length > 0) {
645
+ validateTransactionSizes(transactionSets, resolvedAltAccounts, closedSigner, ephemeralAltAccount)
646
+ }
499
647
 
500
- // Auto-create LoopscaleClient if any step needs co-signing
501
- const hasLoopscale = closedSteps.some((s) => s.isLoopscale)
648
+ // ── Auto-create LoopscaleClient if any step needs co-signing ─────
649
+ const hasLoopscale = transactionSets.some((txSet) => txSet.coSignWithLoopscale)
502
650
  let loopscaleClient: LoopscaleClient | undefined
503
651
  if (hasLoopscale) {
504
- loopscaleClient = new LoopscaleClient({
652
+ loopscaleClient = createBuilderLoopscaleClient({
505
653
  connection: closedConnection,
506
- userWallet: closedSigner,
654
+ vaultPda: closedVaultPda,
655
+ loopscale: closedLoopscaleConfig,
507
656
  })
508
657
  }
509
658
 
510
- const allTxs: VersionedTransaction[] = []
511
- for (let i = 0; i < transactionSets.length; i++) {
512
- const txSet = transactionSets[i]
513
- const isLast = i === transactionSets.length - 1
514
-
515
- const instructions = [...txSet.instructions]
516
- if (isLast && options.tipLamports > 0) {
517
- instructions.push(createJitoTipIx(options.tipLamports, options.signers[0].publicKey))
518
- }
659
+ // ── Send transactions ────────────────────────────────────────────
660
+ const signatures: string[] = []
661
+ for (const txSet of transactionSets) {
662
+ const { blockhash, lastValidBlockHeight } = await closedConnection.getLatestBlockhash()
519
663
 
664
+ // Include the ephemeral ALT for every transaction set
520
665
  const txAltAccounts = resolvedAltAccounts.filter((alt) =>
521
- txSet.addressLookupTableAddresses.some((addr) => addr.equals(alt.key)),
666
+ txSet.addressLookupTableAddresses.some((addr) => addr.equals(alt.key))
667
+ || (ephemeralAltAccount && alt.key.equals(ephemeralAltAccount.key)),
522
668
  )
523
669
 
524
670
  const msg = new TransactionMessage({
525
- payerKey: options.signers[0].publicKey,
671
+ payerKey: payer.publicKey,
526
672
  recentBlockhash: blockhash,
527
- instructions,
673
+ instructions: txSet.instructions,
528
674
  }).compileToV0Message(txAltAccounts)
529
675
 
530
676
  let tx = new VersionedTransaction(msg)
531
-
532
- // Co-sign Loopscale steps, sign everything else normally
533
- const step = closedSteps.find((s) => s.label === txSet.label)
534
- if (step?.isLoopscale && loopscaleClient) {
677
+ if (txSet.coSignWithLoopscale && loopscaleClient) {
535
678
  tx.sign([...options.signers, ...txSet.signers])
536
679
  tx = await loopscaleClient.coSign(tx)
537
680
  } else {
538
681
  tx.sign([...options.signers, ...txSet.signers])
539
682
  }
540
683
 
541
- allTxs.push(tx)
542
- }
543
-
544
- // Custom send escape hatch (for testing)
545
- if (options.customSend) {
546
- const sigs = await options.customSend(allTxs, labels)
547
- return { signatures: sigs, bundleId: "" }
548
- }
549
-
550
- const serialized = allTxs.map((tx) => Buffer.from(tx.serialize()))
551
- const result = await sendJitoBundleRpc(serialized, options.jitoAuthKey)
552
-
553
- const commitment = options.commitment ?? "confirmed"
554
- if (result.signatures.length > 0) {
684
+ const sig = await closedConnection.sendTransaction(tx)
555
685
  await closedConnection.confirmTransaction(
556
- { signature: result.signatures[0], blockhash, lastValidBlockHeight },
686
+ { signature: sig, blockhash, lastValidBlockHeight },
557
687
  commitment,
558
- ).catch(() => {})
688
+ )
689
+ signatures.push(sig)
559
690
  }
560
691
 
561
- return result
692
+ return { signatures, ephemeralAlt }
562
693
  },
563
- }
564
- }
565
- }
566
-
567
- // ============================================================================
568
- // bundleForJito — standalone utility
569
- // ============================================================================
570
694
 
571
- /**
572
- * Package TransactionSets into Jito-compatible unsigned VersionedTransactions.
573
- *
574
- * All transactions share the same blockhash and a Jito tip instruction is appended
575
- * to the last transaction. The returned transactions are **unsigned** — the caller
576
- * is responsible for signing (useful for web app flows where a wallet adapter handles signing).
577
- *
578
- * For a fully managed sign-and-send flow, use {@link VaultTransactionBuildResult.sendJitoBundle} instead.
579
- *
580
- * @param transactionSets - Ordered TransactionSets from `builder.build()`.
581
- * @param params.connection - Solana RPC connection for ALT resolution and blockhash fetching.
582
- * @param params.payer - Public key of the fee payer (used in the V0 message header).
583
- * @param params.tipLamports - Jito tip amount in lamports, appended to the last transaction.
584
- * @param params.lookupTableAddresses - All ALT addresses from the build result.
585
- * @returns Unsigned VersionedTransactions ready for wallet signing.
586
- */
587
- export async function bundleForJito(
588
- transactionSets: TransactionSet[],
589
- params: {
590
- connection: Connection
591
- payer: PublicKey
592
- tipLamports: number
593
- lookupTableAddresses: PublicKey[]
594
- },
595
- ): Promise<VersionedTransaction[]> {
596
- const { connection, payer, tipLamports, lookupTableAddresses } = params
597
-
598
- const altAccounts = await resolveAltAccounts(connection, lookupTableAddresses)
599
- const { blockhash } = await connection.getLatestBlockhash()
600
-
601
- const transactions: VersionedTransaction[] = []
602
-
603
- for (let i = 0; i < transactionSets.length; i++) {
604
- const txSet = transactionSets[i]
605
- const isLast = i === transactionSets.length - 1
606
-
607
- const instructions = [...txSet.instructions]
608
- if (isLast && tipLamports > 0) {
609
- instructions.push(createJitoTipIx(tipLamports, payer))
610
695
  }
611
-
612
- const txAltAccounts = altAccounts.filter((alt) =>
613
- txSet.addressLookupTableAddresses.some((addr) => addr.equals(alt.key)),
614
- )
615
-
616
- const messageV0 = new TransactionMessage({
617
- payerKey: payer,
618
- recentBlockhash: blockhash,
619
- instructions,
620
- }).compileToV0Message(txAltAccounts)
621
-
622
- transactions.push(new VersionedTransaction(messageV0))
623
696
  }
624
-
625
- return transactions
626
697
  }
627
698
 
628
699
  // ============================================================================
@@ -645,6 +716,91 @@ function buildComputeBudgetIxs(
645
716
  return ixs
646
717
  }
647
718
 
719
+ function insertAfterLeadingComputeBudgetAndScopeInstructions(
720
+ instructions: TransactionInstruction[],
721
+ extraInstructions: TransactionInstruction[],
722
+ ): TransactionInstruction[] {
723
+ if (extraInstructions.length === 0) {
724
+ return instructions
725
+ }
726
+
727
+ let insertIndex = 0
728
+ while (
729
+ insertIndex < instructions.length
730
+ && instructions[insertIndex]?.programId.equals(ComputeBudgetProgram.programId)
731
+ ) {
732
+ insertIndex += 1
733
+ }
734
+
735
+ while (
736
+ insertIndex < instructions.length
737
+ && instructions[insertIndex]?.programId.equals(SCOPE_PROGRAM_ID)
738
+ ) {
739
+ insertIndex += 1
740
+ }
741
+
742
+ return [
743
+ ...instructions.slice(0, insertIndex),
744
+ ...extraInstructions,
745
+ ...instructions.slice(insertIndex),
746
+ ]
747
+ }
748
+
749
+ function shouldInjectPriceRefresh(
750
+ txSet: TransactionSet,
751
+ programId: PublicKey,
752
+ squadsProgram: PublicKey,
753
+ ): boolean {
754
+ if (!txSet.label.endsWith("-setup")) {
755
+ return true
756
+ }
757
+
758
+ return txSet.instructions.some((instruction) =>
759
+ instruction.programId.equals(programId) || instruction.programId.equals(squadsProgram),
760
+ )
761
+ }
762
+
763
+ function touchesManagedVaultPrograms(
764
+ instructions: TransactionInstruction[],
765
+ programId: PublicKey,
766
+ squadsProgram: PublicKey,
767
+ ): boolean {
768
+ return instructions.some((instruction) =>
769
+ instruction.programId.equals(programId) || instruction.programId.equals(squadsProgram),
770
+ )
771
+ }
772
+
773
+ function mergeAddressLookupTableAddresses(
774
+ ...addressGroups: Array<readonly PublicKey[]>
775
+ ): PublicKey[] {
776
+ const seen = new Set<string>()
777
+ const merged: PublicKey[] = []
778
+
779
+ for (const group of addressGroups) {
780
+ for (const address of group) {
781
+ const key = address.toBase58()
782
+ if (seen.has(key)) continue
783
+ seen.add(key)
784
+ merged.push(address)
785
+ }
786
+ }
787
+
788
+ return merged
789
+ }
790
+
791
+ function createBuilderLoopscaleClient(params: {
792
+ connection: Connection
793
+ vaultPda: PublicKey
794
+ loopscale?: VaultTransactionBuilderLoopscaleConfig
795
+ }): LoopscaleClient {
796
+ return new LoopscaleClient({
797
+ connection: params.connection,
798
+ userWallet: params.vaultPda,
799
+ baseUrl: params.loopscale?.baseUrl,
800
+ debug: params.loopscale?.debug,
801
+ })
802
+ }
803
+
648
804
  /** Fetch and resolve AddressLookupTableAccounts from on-chain, filtering nulls. */
649
805
  async function resolveAltAccounts(
650
806
  connection: Connection,
@@ -658,128 +814,120 @@ async function resolveAltAccounts(
658
814
  .filter((v): v is AddressLookupTableAccount => v !== null)
659
815
  }
660
816
 
661
- /** Create a Jito tip transfer instruction to a random tip address. */
662
- function createJitoTipIx(tipLamports: number, payer: PublicKey): TransactionInstruction {
663
- const tipReceiver = JITO_TIP_ADDRESSES[Math.floor(Math.random() * JITO_TIP_ADDRESSES.length)]
664
- return SystemProgram.transfer({
665
- fromPubkey: payer,
666
- toPubkey: tipReceiver,
667
- lamports: BigInt(Math.floor(tipLamports)),
668
- })
669
- }
670
-
671
- // ── Jito bundle sending ──
672
-
673
- type JitoRpcResponse = { result?: string; error?: unknown }
817
+ /** Collect all unique account keys referenced across all transaction sets. */
818
+ function collectUniqueAccountKeys(
819
+ transactionSets: TransactionSet[],
820
+ signer: PublicKey,
821
+ ): PublicKey[] {
822
+ const seen = new Set<string>()
823
+ const keys: PublicKey[] = []
674
824
 
675
- /**
676
- * Send a bundle of serialized transactions to Jito block engine endpoints.
677
- * Fans out to regional endpoints (best-effort) and retries the mainnet endpoint.
678
- */
679
- async function sendJitoBundleRpc(
680
- serializedTransactions: Buffer[],
681
- authKey?: string,
682
- ): Promise<JitoBundleResult> {
683
- if (serializedTransactions.length > 5) {
684
- throw new Error("Jito bundles support at most 5 transactions")
825
+ const add = (pubkey: PublicKey) => {
826
+ const str = pubkey.toBase58()
827
+ if (!seen.has(str)) {
828
+ seen.add(str)
829
+ keys.push(pubkey)
830
+ }
685
831
  }
686
832
 
687
- const signatures = serializedTransactions.map((tx) => {
688
- const vtx = VersionedTransaction.deserialize(tx)
689
- return Buffer.from(vtx.signatures[0]).toString("base64")
690
- })
691
-
692
- const requestBody = {
693
- jsonrpc: "2.0",
694
- id: 1,
695
- method: "sendBundle",
696
- params: [serializedTransactions.map((tx) => tx.toString("base64")), { encoding: "base64" }],
833
+ add(signer)
834
+ for (const txSet of transactionSets) {
835
+ for (const ix of txSet.instructions) {
836
+ add(ix.programId)
837
+ for (const key of ix.keys) {
838
+ add(key.pubkey)
839
+ }
840
+ }
697
841
  }
698
842
 
699
- const headers: Record<string, string> = { "Content-Type": "application/json" }
700
- if (authKey) headers["x-jito-auth"] = authKey
701
-
702
- // Fan out to regional endpoints (best-effort, don't await)
703
- const regionalPromises = JITO_REGIONAL_URLS.map((url) =>
704
- fetch(`${url}${JITO_BUNDLES_PATH}`, { method: "POST", headers, body: JSON.stringify(requestBody) })
705
- .catch(() => {}),
706
- )
707
- Promise.allSettled(regionalPromises)
843
+ return keys
844
+ }
708
845
 
709
- // Retry mainnet endpoint
710
- for (let attempt = 0; attempt < JITO_MAX_RETRIES; attempt++) {
846
+ /** Validate that every transaction set can serialize within the 1232-byte limit. */
847
+ function validateTransactionSizes(
848
+ transactionSets: TransactionSet[],
849
+ altAccounts: AddressLookupTableAccount[],
850
+ signer: PublicKey,
851
+ extraAlt?: AddressLookupTableAccount,
852
+ ): void {
853
+ for (const txSet of transactionSets) {
854
+ const txAltAccounts = altAccounts.filter((alt) =>
855
+ txSet.addressLookupTableAddresses.some((addr) => addr.equals(alt.key)),
856
+ )
857
+ if (extraAlt && !txAltAccounts.some((a) => a.key.equals(extraAlt.key))) {
858
+ txAltAccounts.push(extraAlt)
859
+ }
711
860
  try {
712
- const response = await fetch(`${JITO_MAINNET_URL}${JITO_BUNDLES_PATH}`, {
713
- method: "POST",
714
- headers,
715
- body: JSON.stringify(requestBody),
716
- })
717
-
718
- const data = (await response.json().catch(() => undefined)) as JitoRpcResponse | undefined
719
-
720
- if (response.ok && !data?.error && typeof data?.result === "string") {
721
- return { bundleId: data.result, signatures }
861
+ const testMsg = new TransactionMessage({
862
+ payerKey: signer,
863
+ recentBlockhash: PublicKey.default.toBase58(),
864
+ instructions: txSet.instructions,
865
+ }).compileToV0Message(txAltAccounts)
866
+ const testTx = new VersionedTransaction(testMsg)
867
+ const serialized = testTx.serialize()
868
+ if (serialized.length > 1232) {
869
+ throw new Error(
870
+ `Transaction "${txSet.label}" is oversized (${serialized.length} bytes, limit 1232). ` +
871
+ `This usually means too many actions were packed into a single addActions() call. ` +
872
+ `Split them into separate addActions() calls so each gets its own sync transaction.`,
873
+ )
722
874
  }
723
-
724
- if (response.status === 400) {
725
- return { bundleId: "", signatures }
875
+ } catch (e) {
876
+ if (e instanceof Error) {
877
+ const ixCount = txSet.instructions.length
878
+ const altCount = txAltAccounts.length
879
+ const accountCount = txSet.instructions.reduce((sum, ix) => sum + ix.keys.length, 0)
880
+ throw new Error(
881
+ `Transaction "${txSet.label}" failed to serialize: ${e.message}. ` +
882
+ `Transaction has ${ixCount} instructions, ${accountCount} account keys, ` +
883
+ `and ${altCount} address lookup table(s). ` +
884
+ `Try splitting actions into separate addActions() calls.`,
885
+ )
726
886
  }
727
- } catch {}
728
-
729
- await new Promise((r) => setTimeout(r, JITO_RETRY_DELAY_MS))
887
+ throw e
888
+ }
730
889
  }
731
-
732
- return { bundleId: "", signatures }
733
890
  }
734
891
 
735
- // ── ALT auto-extension (reserved for future use) ──
892
+ /** Split an array into chunks of the given size. */
893
+ function chunkArray<T>(arr: T[], size: number): T[][] {
894
+ const chunks: T[][] = []
895
+ for (let i = 0; i < arr.length; i += size) {
896
+ chunks.push(arr.slice(i, i + size))
897
+ }
898
+ return chunks
899
+ }
736
900
 
737
- /**
738
- * Auto-extend an address lookup table with all unique accounts from the given instructions.
739
- * Batches in groups of 30 addresses (Solana limit per extend instruction).
740
- * Waits 2 seconds after extension for ALT propagation.
741
- *
742
- * @internal Not currently wired into any flow. Reserved for when ALT management
743
- * needs to be handled by the builder (e.g., oversized Loopscale transactions).
744
- */
745
- async function autoExtendAlt(
901
+ /** Build, sign, send, and confirm a simple transaction. */
902
+ async function sendAndConfirmTransaction(
746
903
  connection: Connection,
747
- lookupTable: PublicKey,
748
- authority: web3.Signer,
749
904
  instructions: TransactionInstruction[],
750
- ): Promise<void> {
751
- const seen = new Set<string>()
752
- const addresses: PublicKey[] = []
753
- for (const ix of instructions) {
754
- for (const key of [ix.programId, ...ix.keys.map((k) => k.pubkey)]) {
755
- const keyStr = key.toBase58()
756
- if (!seen.has(keyStr)) {
757
- seen.add(keyStr)
758
- addresses.push(key)
759
- }
760
- }
761
- }
762
-
763
- for (let i = 0; i < addresses.length; i += 30) {
764
- const batch = addresses.slice(i, i + 30)
765
- const extendIx = AddressLookupTableProgram.extendLookupTable({
766
- payer: authority.publicKey,
767
- authority: authority.publicKey,
768
- lookupTable,
769
- addresses: batch,
770
- })
771
-
772
- const { blockhash } = await connection.getLatestBlockhash()
773
- const msg = new TransactionMessage({
774
- payerKey: authority.publicKey,
775
- recentBlockhash: blockhash,
776
- instructions: [extendIx],
777
- }).compileToV0Message()
905
+ signers: web3.Signer[],
906
+ commitment: web3.Commitment,
907
+ ): Promise<string> {
908
+ const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(commitment)
909
+ const msg = new TransactionMessage({
910
+ payerKey: signers[0]!.publicKey,
911
+ recentBlockhash: blockhash,
912
+ instructions,
913
+ }).compileToV0Message()
914
+ const tx = new VersionedTransaction(msg)
915
+ tx.sign(signers)
916
+ const sig = await connection.sendTransaction(tx)
917
+ await connection.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, commitment)
918
+ return sig
919
+ }
778
920
 
779
- const tx = new VersionedTransaction(msg)
780
- tx.sign([authority])
781
- await connection.sendRawTransaction(tx.serialize())
921
+ /** Poll until the current slot advances past the target slot. */
922
+ async function waitForSlotAdvance(
923
+ connection: Connection,
924
+ targetSlot: number,
925
+ commitment: web3.Commitment = "processed",
926
+ ): Promise<void> {
927
+ for (let i = 0; i < 60; i++) {
928
+ const currentSlot = await connection.getSlot(commitment)
929
+ if (currentSlot > targetSlot) return
930
+ await new Promise((r) => setTimeout(r, 400))
782
931
  }
783
-
784
- await new Promise((r) => setTimeout(r, 2000))
932
+ throw new Error("Timed out waiting for slot to advance after ephemeral ALT creation")
785
933
  }