@exodus/solana-api 3.20.10 → 3.22.0

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.22.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.21.0...@exodus/solana-api@3.22.0) (2025-10-15)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: solana update fee as zero with usedFeePayer (#6702)
13
+
14
+
15
+
16
+ ## [3.21.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.10...@exodus/solana-api@3.21.0) (2025-10-14)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: integrate Solana fee payer service (#6615)
23
+
24
+
25
+
6
26
  ## [3.20.10](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.8...@exodus/solana-api@3.20.10) (2025-10-14)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "3.20.10",
3
+ "version": "3.22.0",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Solana",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -26,12 +26,14 @@
26
26
  "@exodus/asset-json-rpc": "^1.0.0",
27
27
  "@exodus/asset-lib": "^5.0.0",
28
28
  "@exodus/assets": "^11.0.0",
29
+ "@exodus/auth-client-base": "^2.2.0",
29
30
  "@exodus/basic-utils": "^3.0.1",
31
+ "@exodus/crypto": "^1.0.0-rc.16",
30
32
  "@exodus/currency": "^6.0.1",
31
33
  "@exodus/fetch": "^1.7.3",
32
34
  "@exodus/models": "^12.0.1",
33
35
  "@exodus/simple-retry": "^0.0.6",
34
- "@exodus/solana-lib": "^3.11.1",
36
+ "@exodus/solana-lib": "^3.13.0",
35
37
  "@exodus/solana-meta": "^2.0.2",
36
38
  "@exodus/timer": "^1.1.1",
37
39
  "debug": "^4.1.1",
@@ -47,7 +49,7 @@
47
49
  "@exodus/assets-testing": "^1.0.0",
48
50
  "@exodus/solana-web3.js": "^1.63.1-exodus.9-rc3"
49
51
  },
50
- "gitHead": "b0f1b6b69b7a6f7e70eee7e03db7bf71ba6be92a",
52
+ "gitHead": "5b5f2c1945d66ef1f0481e1822afc730dc5261d4",
51
53
  "bugs": {
52
54
  "url": "https://github.com/ExodusMovement/assets/issues?q=is%3Aissue+is%3Aopen+label%3Asolana-api"
53
55
  },
package/src/auth.js ADDED
@@ -0,0 +1,23 @@
1
+ import { randomBytes } from '@exodus/crypto/randomBytes'
2
+ import { generateKeyPair } from '@exodus/solana-lib'
3
+
4
+ const authKeyPairCache = new Map()
5
+
6
+ const normalizeServiceUrl = (serviceUrl) => {
7
+ const u = new URL(serviceUrl)
8
+ return u.origin
9
+ }
10
+
11
+ export const getAuthKeyPair = ({ assetName, apiUrl, network }) => {
12
+ const cacheKey = `auth_keypair:v1:${normalizeServiceUrl(apiUrl)}:${assetName}:${network || 'mainnet'}`
13
+
14
+ if (!authKeyPairCache.has(cacheKey)) {
15
+ const keyPair = generateKeyPair(randomBytes(32))
16
+ authKeyPairCache.set(cacheKey, {
17
+ publicKey: keyPair.publicKey.toBuffer().toString('hex'),
18
+ privateKey: keyPair.privateKey.toString('hex'),
19
+ })
20
+ }
21
+
22
+ return authKeyPairCache.get(cacheKey)
23
+ }
@@ -1,4 +1,3 @@
1
- import { fetchival } from '@exodus/fetch'
2
1
  import {
3
2
  createUnsignedTx,
4
3
  deserializeTransaction,
@@ -7,134 +6,38 @@ import {
7
6
  prepareForSigning,
8
7
  TOKEN_2022_PROGRAM_ID,
9
8
  TOKEN_PROGRAM_ID,
10
- verifyOnlyFeePayerChanged,
11
9
  } from '@exodus/solana-lib'
12
10
  import assert from 'minimalistic-assert'
13
11
 
12
+ import { maybeAddFeePayerWithAuth } from './fee-payer.js'
13
+
14
14
  const CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS = 300
15
15
  const TOKEN_ACCOUNT_CREATION_SIZE = 165 // size of the token account
16
16
 
17
- export const createUnsignedTxForSend = async ({
18
- api,
19
- asset,
20
- feeData,
21
- toAddress,
22
- fromAddress,
23
- amount,
24
- reference,
25
- memo,
26
- nft,
27
- feePayerApiUrl,
28
- useFeePayer = true,
29
- // token related
30
- tokenStandard,
31
- customMintAddress,
32
- // staking
33
- method,
34
- stakeAddresses,
35
- accounts,
36
- seed,
37
- pool,
38
- // <MagicEden>
39
- initializerAddress,
40
- initializerDepositTokenAddress,
41
- takerAmount,
42
- escrowAddress,
43
- escrowBump,
44
- pdaAddress,
45
- takerAddress,
46
- expectedTakerAmount,
47
- expectedMintAddress,
48
- metadataAddress,
49
- creators,
50
- // </MagicEden>
51
- isExchange,
52
- }) => {
17
+ export const createTxFactory = ({ assetClientInterface, api, feePayerClient }) => {
53
18
  assert(api, 'api is required')
54
- assert(asset, 'asset is required')
55
- assert(feeData, 'feeData is required')
56
- let tokenParams = Object.create(null)
57
- const baseAsset = asset.baseAsset
58
-
59
- if (nft) {
60
- const [, nftAddress] = nft.id.split(':')
61
- customMintAddress = nftAddress
62
- tokenStandard = nft.tokenStandard
63
- method = tokenStandard === 4 ? 'metaplexTransfer' : undefined
64
- amount = asset.currency.baseUnit(1)
65
- }
66
-
67
- const isToken = asset.name !== asset.baseAsset.name
68
-
69
- // Check if receiver has address active when sending tokens.
70
- if (isToken) {
71
- // check address mint is the same
72
- const targetMint = await api.getAddressMint(toAddress) // null if it's a SOL address
73
- if (targetMint && targetMint !== asset.mintAddress) {
74
- const err = new Error('Wrong Destination Wallet')
75
- err.mintAddressMismatch = true
76
- throw err
77
- }
78
- } else {
79
- // sending SOL
80
- const addressType = await api.getAddressType(toAddress)
81
- if (addressType === 'token') {
82
- const err = new Error('Destination Wallet is a Token address')
83
- err.wrongAddressType = true
84
- throw err
85
- }
86
- }
19
+ assert(assetClientInterface, 'assetClientInterface is required')
87
20
 
88
- if (isToken || customMintAddress) {
89
- const tokenMintAddress = customMintAddress || asset.mintAddress
90
- const tokenProgramPublicKey =
91
- (await api.getAddressType(tokenMintAddress)) === 'token-2022'
92
- ? TOKEN_2022_PROGRAM_ID
93
- : TOKEN_PROGRAM_ID
94
-
95
- const tokenProgram = tokenProgramPublicKey.toBase58()
96
- const tokenAddress = findAssociatedTokenAddress(toAddress, tokenMintAddress, tokenProgram)
97
-
98
- const [destinationAddressType, isAssociatedTokenAccountActive, fromTokenAccountAddresses] =
99
- await Promise.all([
100
- api.getAddressType(toAddress),
101
- api.isAssociatedTokenAccountActive(tokenAddress),
102
- api.getTokenAccountsByOwner(fromAddress),
103
- ])
104
-
105
- const changedOwnership = await api.ataOwnershipChangedCached(toAddress, tokenAddress)
106
- if (changedOwnership) {
107
- const err = new Error('Destination ATA changed ownership')
108
- err.ownershipChanged = true
109
- throw err
110
- }
111
-
112
- const fromTokenAddresses = fromTokenAccountAddresses.filter(
113
- ({ mintAddress }) => mintAddress === tokenMintAddress
114
- )
115
-
116
- tokenParams = {
117
- tokenMintAddress,
118
- destinationAddressType,
119
- isAssociatedTokenAccountActive,
120
- fromTokenAddresses,
121
- tokenStandard,
122
- tokenProgram,
123
- }
124
- }
125
-
126
- const stakingParams = {
21
+ return async ({
22
+ asset,
23
+ walletAccount,
24
+ feeData: providedFeeData,
25
+ fromAddress: providedFromAddress,
26
+ toAddress,
27
+ amount,
28
+ reference,
29
+ memo,
30
+ nft,
31
+ // token related
32
+ tokenStandard,
33
+ customMintAddress,
34
+ // staking
127
35
  method,
128
36
  stakeAddresses,
129
37
  accounts,
130
38
  seed,
131
39
  pool,
132
- }
133
-
134
- const recentBlockhash = await api.getRecentBlockHash()
135
-
136
- const magicEdenParams = {
137
- method,
40
+ // <MagicEden>
138
41
  initializerAddress,
139
42
  initializerDepositTokenAddress,
140
43
  takerAmount,
@@ -146,111 +49,225 @@ export const createUnsignedTxForSend = async ({
146
49
  expectedMintAddress,
147
50
  metadataAddress,
148
51
  creators,
149
- }
52
+ // </MagicEden>
53
+ isExchange,
54
+ useFeePayer = true,
55
+ }) => {
56
+ assert(asset, 'asset is required')
57
+ assert(walletAccount, 'walletAccount is required')
58
+
59
+ let tokenParams = Object.create(null)
60
+
61
+ const baseAsset = asset.baseAsset
62
+ const baseAssetName = baseAsset.name
63
+
64
+ const feeData =
65
+ providedFeeData ?? (await assetClientInterface.getFeeConfig({ assetName: baseAssetName }))
66
+
67
+ const fromAddress =
68
+ providedFromAddress ??
69
+ (await assetClientInterface.getReceiveAddress({
70
+ assetName: baseAssetName,
71
+ walletAccount,
72
+ }))
73
+
74
+ if (nft) {
75
+ const [, nftAddress] = nft.id.split(':')
76
+ customMintAddress = nftAddress
77
+ tokenStandard = nft.tokenStandard
78
+ method = tokenStandard === 4 ? 'metaplexTransfer' : undefined
79
+ amount = asset.currency.baseUnit(1)
80
+ }
150
81
 
151
- const unsignedTx = createUnsignedTx({
152
- asset,
153
- from: fromAddress,
154
- to: toAddress,
155
- amount,
156
- recentBlockhash,
157
- reference,
158
- memo,
159
- useFeePayer,
160
- ...tokenParams,
161
- ...stakingParams,
162
- ...magicEdenParams,
163
- })
164
-
165
- const resolveUnitConsumed = async () => {
166
- // this avoids unnecessary simulations. Also the simulation fails with InsufficientFundsForRent when sending all.
167
- if (asset.name === asset.baseAsset.name && amount && !nft && !method) {
168
- return 150 + CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS
82
+ const isToken = asset.name !== asset.baseAsset.name
83
+
84
+ // Check if receiver has address active when sending tokens.
85
+ if (isToken) {
86
+ // check address mint is the same
87
+ const targetMint = await api.getAddressMint(toAddress) // null if it's a SOL address
88
+ if (targetMint && targetMint !== asset.mintAddress) {
89
+ const err = new Error('Wrong Destination Wallet')
90
+ err.mintAddressMismatch = true
91
+ throw err
92
+ }
93
+ } else {
94
+ // sending SOL
95
+ const addressType = await api.getAddressType(toAddress)
96
+ if (addressType === 'token') {
97
+ const err = new Error('Destination Wallet is a Token address')
98
+ err.wrongAddressType = true
99
+ throw err
100
+ }
169
101
  }
170
102
 
171
- const transactionForFeeEstimation = await maybeAddFeePayer({
172
- unsignedTx,
173
- feePayerApiUrl,
174
- assetName: asset.baseAsset.name,
175
- })
176
- const message = transactionForFeeEstimation.txMeta.usedFeePayer
177
- ? deserializeTransaction(transactionForFeeEstimation.txData.transactionBuffer).message
178
- : prepareForSigning(transactionForFeeEstimation).message
103
+ if (isToken || customMintAddress) {
104
+ const tokenMintAddress = customMintAddress || asset.mintAddress
105
+ const tokenProgramPublicKey =
106
+ (await api.getAddressType(tokenMintAddress)) === 'token-2022'
107
+ ? TOKEN_2022_PROGRAM_ID
108
+ : TOKEN_PROGRAM_ID
109
+
110
+ const tokenProgram = tokenProgramPublicKey.toBase58()
111
+ const tokenAddress = findAssociatedTokenAddress(toAddress, tokenMintAddress, tokenProgram)
112
+
113
+ const changedOwnership = await api.ataOwnershipChangedCached(toAddress, tokenAddress)
114
+ if (changedOwnership) {
115
+ const err = new Error('Destination ATA changed ownership')
116
+ err.ownershipChanged = true
117
+ throw err
118
+ }
179
119
 
180
- const { unitsConsumed, err } = await api.simulateUnsignedTransaction({
181
- message,
120
+ const [destinationAddressType, isAssociatedTokenAccountActive, fromTokenAccountAddresses] =
121
+ await Promise.all([
122
+ api.getAddressType(toAddress),
123
+ api.isAssociatedTokenAccountActive(tokenAddress),
124
+ api.getTokenAccountsByOwner(fromAddress),
125
+ ])
126
+
127
+ const fromTokenAddresses = fromTokenAccountAddresses.filter(
128
+ ({ mintAddress }) => mintAddress === tokenMintAddress
129
+ )
130
+
131
+ tokenParams = {
132
+ tokenMintAddress,
133
+ destinationAddressType,
134
+ isAssociatedTokenAccountActive,
135
+ fromTokenAddresses,
136
+ tokenStandard,
137
+ tokenProgram,
138
+ }
139
+ }
140
+
141
+ const stakingParams = {
142
+ method,
143
+ stakeAddresses,
144
+ accounts,
145
+ seed,
146
+ pool,
147
+ }
148
+
149
+ const recentBlockhash = await api.getRecentBlockHash()
150
+
151
+ const magicEdenParams = {
152
+ method,
153
+ initializerAddress,
154
+ initializerDepositTokenAddress,
155
+ takerAmount,
156
+ escrowAddress,
157
+ escrowBump,
158
+ pdaAddress,
159
+ takerAddress,
160
+ expectedTakerAmount,
161
+ expectedMintAddress,
162
+ metadataAddress,
163
+ creators,
164
+ }
165
+
166
+ const unsignedTx = createUnsignedTx({
167
+ asset,
168
+ from: fromAddress,
169
+ to: toAddress,
170
+ amount,
171
+ recentBlockhash,
172
+ reference,
173
+ memo,
174
+ // Effective: platform enable AND per-tx intent
175
+ useFeePayer,
176
+ ...tokenParams,
177
+ ...stakingParams,
178
+ ...magicEdenParams,
182
179
  })
183
- if (err) {
184
- // we use this method to compute unitsConsumed
185
- // we can throw error here and fallback to ~0.025 SOL or estimate fee based on the method
186
- console.log('error getting units consumed:', err)
187
- if (!unitsConsumed) throw new Error(err)
180
+
181
+ const resolveUnitConsumed = async () => {
182
+ // this avoids unnecessary simulations. Also the simulation fails with InsufficientFundsForRent when sending all.
183
+ if (asset.name === asset.baseAsset.name && amount && !nft && !method) {
184
+ return 150 + CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS
185
+ }
186
+
187
+ const transactionForFeeEstimation = await maybeAddFeePayerWithAuth({
188
+ unsignedTx,
189
+ feePayerClient,
190
+ enableFeePayer: feeData.enableFeePayer,
191
+ })
192
+ const message = transactionForFeeEstimation.txMeta.usedFeePayer
193
+ ? deserializeTransaction(transactionForFeeEstimation.txData.transactionBuffer).message
194
+ : prepareForSigning(transactionForFeeEstimation).message
195
+
196
+ const { unitsConsumed, err } = await api.simulateUnsignedTransaction({
197
+ message,
198
+ })
199
+ if (err) {
200
+ // we use this method to compute unitsConsumed
201
+ // we can throw error here and fallback to ~0.025 SOL or estimate fee based on the method
202
+ console.log('error getting units consumed:', err)
203
+ if (!unitsConsumed) throw new Error(err)
204
+ }
205
+
206
+ return unitsConsumed + CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS
188
207
  }
189
208
 
190
- return unitsConsumed + CU_FOR_COMPUTE_BUDGET_INSTRUCTIONS
191
- }
209
+ const priorityFee = feeData.priorityFee
210
+ let computeUnits
211
+ if (priorityFee) {
212
+ const unitsConsumed = await resolveUnitConsumed()
213
+ computeUnits = unitsConsumed * feeData.computeUnitsMultiplier
214
+ unsignedTx.txData.priorityFee = priorityFee
215
+ unsignedTx.txData.computeUnits = computeUnits
216
+ }
192
217
 
193
- const priorityFee = feeData.priorityFee
194
- let computeUnits
195
- if (priorityFee) {
196
- const unitsConsumed = await resolveUnitConsumed()
197
- computeUnits = unitsConsumed * feeData.computeUnitsMultiplier
198
- unsignedTx.txData.priorityFee = priorityFee
199
- unsignedTx.txData.computeUnits = computeUnits
200
- }
218
+ unsignedTx.txMeta.stakingParams = stakingParams
201
219
 
202
- unsignedTx.txMeta.stakingParams = stakingParams
220
+ // we add token account creation fee
221
+ let tokenCreationFee = asset.feeAsset.currency.ZERO
222
+ if (isToken && (!unsignedTx.txData.isAssociatedTokenAccountActive || isExchange)) {
223
+ tokenCreationFee = asset.feeAsset.currency.baseUnit(
224
+ await api.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_CREATION_SIZE)
225
+ )
226
+ }
203
227
 
204
- // we add token account creation fee
205
- let tokenCreationFee = asset.feeAsset.currency.ZERO
206
- if (isToken && (!unsignedTx.txData.isAssociatedTokenAccountActive || isExchange)) {
207
- tokenCreationFee = asset.feeAsset.currency.baseUnit(
208
- await api.getMinimumBalanceForRentExemption(TOKEN_ACCOUNT_CREATION_SIZE)
209
- )
210
- }
228
+ const fee = feeData.baseFee
229
+ .add(
230
+ asset.feeAsset.currency
231
+ .baseUnit(unsignedTx.txData.priorityFee ?? 0)
232
+ .mul(unsignedTx.txData.computeUnits ?? 0)
233
+ .div(1_000_000) // micro lamports to lamports
234
+ )
235
+ .add(tokenCreationFee)
236
+
237
+ // serialization friendlier
238
+ unsignedTx.txMeta.fee = fee.toBaseNumber()
239
+
240
+ const rentExemptValue = await api.getRentExemptionMinAmount(toAddress)
241
+ const rentExemptAmount = baseAsset.currency.baseUnit(rentExemptValue)
242
+
243
+ // differentiate between SOL and Solana token
244
+ let isEnoughForRent = false
245
+ if (asset.name === baseAsset.name && !nft) {
246
+ // sending SOL
247
+ isEnoughForRent = amount.gte(rentExemptAmount)
248
+ } else {
249
+ // sending token/nft
250
+ const baseAssetBalance = await api.getBalance(fromAddress)
251
+ isEnoughForRent = baseAsset.currency
252
+ .baseUnit(baseAssetBalance)
253
+ .sub(fee || asset.feeAsset.currency.ZERO)
254
+ .gte(rentExemptAmount)
255
+ }
211
256
 
212
- const fee = feeData.baseFee
213
- .add(
214
- asset.feeAsset.currency
215
- .baseUnit(unsignedTx.txData.priorityFee ?? 0)
216
- .mul(unsignedTx.txData.computeUnits ?? 0)
217
- .div(1_000_000) // micro lamports to lamports
218
- )
219
- .add(tokenCreationFee)
220
-
221
- // serialization friendlier
222
- unsignedTx.txMeta.fee = fee.toBaseNumber()
223
-
224
- const rentExemptValue = await api.getRentExemptionMinAmount(toAddress)
225
- const rentExemptAmount = baseAsset.currency.baseUnit(rentExemptValue)
226
-
227
- // differentiate between SOL and Solana token
228
- let isEnoughForRent = false
229
- if (asset.name === baseAsset.name && !nft) {
230
- // sending SOL
231
- isEnoughForRent = amount.gte(rentExemptAmount)
232
- } else {
233
- // sending token/nft
234
- const baseAssetBalance = await api.getBalance(fromAddress)
235
- isEnoughForRent = baseAsset.currency
236
- .baseUnit(baseAssetBalance)
237
- .sub(fee || asset.feeAsset.currency.ZERO)
238
- .gte(rentExemptAmount)
239
- }
257
+ const tx = await maybeAddFeePayerWithAuth({
258
+ unsignedTx,
259
+ feePayerClient,
260
+ enableFeePayer: feeData.enableFeePayer,
261
+ })
240
262
 
241
- const tx = await maybeAddFeePayer({
242
- unsignedTx,
243
- feePayerApiUrl,
244
- assetName: asset.baseAsset.name,
245
- })
263
+ if (!isEnoughForRent && !tx.txMeta.usedFeePayer) {
264
+ const err = new Error('Sending SOL amount is too low to cover the rent exemption fee.')
265
+ err.rentExemptAmount = true
266
+ throw err
267
+ }
246
268
 
247
- if (!isEnoughForRent && !tx.txMeta.usedFeePayer) {
248
- const err = new Error('Sending SOL amount is too low to cover the rent exemption fee.')
249
- err.rentExemptAmount = true
250
- throw err
269
+ return tx
251
270
  }
252
-
253
- return tx
254
271
  }
255
272
 
256
273
  export const extractTxLogData = async ({ unsignedTx, api }) => {
@@ -274,39 +291,3 @@ export const extractTxLogData = async ({ unsignedTx, api }) => {
274
291
  fee: unsignedTx.txMeta.fee,
275
292
  }
276
293
  }
277
-
278
- export const maybeAddFeePayer = async ({ unsignedTx, feePayerApiUrl, assetName }) => {
279
- let unsignedTxWithFeePayer = unsignedTx
280
- let newFeePayer = false
281
- if (feePayerApiUrl && unsignedTx.txMeta.useFeePayer !== false) {
282
- try {
283
- const unsignedTxVersionedTransaction = prepareForSigning(unsignedTx)
284
-
285
- const { transaction: newTransactionString } = await fetchival(
286
- new URL(feePayerApiUrl).toString()
287
- ).post({
288
- assetName,
289
- transaction: Buffer.from(unsignedTxVersionedTransaction.serialize()).toString('base64'),
290
- })
291
-
292
- const newTransactionBuffer = Buffer.from(newTransactionString, 'base64')
293
- const newTransaction = deserializeTransaction(newTransactionBuffer)
294
-
295
- verifyOnlyFeePayerChanged(unsignedTxVersionedTransaction, newTransaction)
296
-
297
- unsignedTxWithFeePayer = {
298
- txData: {
299
- transactionBuffer: newTransactionBuffer,
300
- },
301
- txMeta: unsignedTx.txMeta,
302
- }
303
- newFeePayer = true
304
- } catch (err) {
305
- console.log('error adding a new fee payer, sending original transaction', err)
306
- }
307
- }
308
-
309
- unsignedTxWithFeePayer.txMeta.usedFeePayer = newFeePayer
310
-
311
- return unsignedTxWithFeePayer
312
- }
@@ -0,0 +1,174 @@
1
+ import { createClient as createAuthClient } from '@exodus/auth-client-base'
2
+ import { signAttached } from '@exodus/crypto/ed25519'
3
+ import { fetchival } from '@exodus/fetch'
4
+ import {
5
+ deserializeTransaction,
6
+ prepareForSigning,
7
+ verifyOnlyFeePayerChanged,
8
+ } from '@exodus/solana-lib'
9
+ import assert from 'minimalistic-assert'
10
+ import ms from 'ms'
11
+
12
+ import { getAuthKeyPair } from './auth.js'
13
+
14
+ /**
15
+ * Creates a fee payer client that can sponsor Solana transactions
16
+ *
17
+ * This factory creates a client configured to interact with fee payer services
18
+ * that can sponsor transaction fees on behalf of users. It supports both:
19
+ * - Base services: Simple fee sponsorship without authentication
20
+ * - Authenticated services: Require BinAuth and transaction eligibility checks
21
+ *
22
+ * @param {Object} config - Configuration for the fee payer client
23
+ * @param {string} config.assetName - The asset from the transaction
24
+ * @param {string} config.serviceUrl - The fee payer service URL
25
+ * @param {boolean} [config.requireAuthentication=false] - Whether the service requires authentication
26
+ * @param {Function} [config.isEligibleForSponsorship] - Eligibility checker function (defaults to allowing all)
27
+ */
28
+ export const feePayerClientFactory = ({
29
+ assetName = 'solana', // baseAsset name
30
+ feePayerApiUrl,
31
+ requireAuthentication = false,
32
+ authKeyPair: customAuthKeyPair = null,
33
+ } = {}) => {
34
+ assert(feePayerApiUrl, 'feePayerApiUrl is required')
35
+
36
+ // Create auth client if authentication is required
37
+ let authClient = null
38
+ if (requireAuthentication) {
39
+ const authKeyPair = customAuthKeyPair || getAuthKeyPair({ assetName, apiUrl: feePayerApiUrl })
40
+ const privateKey = Buffer.from(authKeyPair.privateKey, 'hex').subarray(0, 32)
41
+
42
+ const keyPair = {
43
+ publicKey: authKeyPair.publicKey,
44
+ sign: async (message) => signAttached({ message, privateKey, format: 'buffer' }),
45
+ }
46
+
47
+ authClient = createAuthClient({
48
+ config: {
49
+ keyPair,
50
+ baseUrl: feePayerApiUrl,
51
+ authTokenUrl: `${feePayerApiUrl}/auth/token`,
52
+ authChallengeUrl: `${feePayerApiUrl}/auth/challenge`,
53
+ },
54
+ })
55
+ }
56
+
57
+ const makeSponsorRequest = async ({ encodedTransaction }) => {
58
+ const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }
59
+
60
+ if (requireAuthentication && authClient) {
61
+ await authClient._awaitAuthenticated()
62
+ if (authClient.token) {
63
+ headers.Authorization = `Bearer ${authClient.token}`
64
+ }
65
+ }
66
+
67
+ return fetchival(`${feePayerApiUrl}/transactions/sponsor`, {
68
+ mode: 'cors',
69
+ cache: 'no-cache',
70
+ timeout: ms('10s'),
71
+ headers,
72
+ }).post({
73
+ transaction: encodedTransaction,
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Request transaction sponsorship
79
+ */
80
+ const sponsorTransaction = async ({ transaction }) => {
81
+ const encodedTransaction = Buffer.from(transaction.serialize()).toString('base64')
82
+
83
+ let response
84
+ try {
85
+ response = await makeSponsorRequest({ encodedTransaction })
86
+ } catch (error) {
87
+ if (
88
+ requireAuthentication &&
89
+ (error.response?.status === 401 || error.response?.status === 403)
90
+ ) {
91
+ console.warn('Authentication failed, retrying...')
92
+ if (authClient) {
93
+ await authClient._authenticate()
94
+ }
95
+
96
+ response = await makeSponsorRequest({ encodedTransaction })
97
+ } else {
98
+ throw error
99
+ }
100
+ }
101
+
102
+ if (!response.transaction) {
103
+ throw new Error(response.error || 'Sponsorship request failed')
104
+ }
105
+
106
+ const usedAuthentication = requireAuthentication
107
+ if (usedAuthentication) {
108
+ console.log(
109
+ `Transaction sponsored (authenticated). Cost: ${response.estimatedCost || 'N/A'} lamports`
110
+ )
111
+ }
112
+
113
+ return {
114
+ transaction: response.transaction,
115
+ feePayerPublicKey: response.feePayerPublicKey,
116
+ estimatedCost: response.estimatedCost,
117
+ requestId: response.requestId,
118
+ usedAuthentication,
119
+ }
120
+ }
121
+
122
+ return {
123
+ sponsorTransaction,
124
+ requireAuthentication,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Attempts to add a fee payer to an unsigned transaction
130
+ *
131
+ * @param {Object} params
132
+ * @param {Object} params.unsignedTx - The unsigned transaction
133
+ * @param {Object} params.feePayerClient - The fee payer client instance
134
+ */
135
+ export const maybeAddFeePayerWithAuth = async ({ unsignedTx, feePayerClient, enableFeePayer }) => {
136
+ let unsignedTxWithFeePayer = unsignedTx
137
+
138
+ // Skip if no client or explicitly disabled
139
+ if (!feePayerClient || !enableFeePayer || !unsignedTx.txMeta.useFeePayer) {
140
+ unsignedTxWithFeePayer.txMeta.usedFeePayer = false
141
+ return unsignedTxWithFeePayer
142
+ }
143
+
144
+ const unsignedTxVersionedTransaction = prepareForSigning(unsignedTx)
145
+
146
+ try {
147
+ const result = await feePayerClient.sponsorTransaction({
148
+ transaction: unsignedTxVersionedTransaction,
149
+ })
150
+
151
+ const newTransactionBuffer = Buffer.from(result.transaction, 'base64')
152
+ const sponsoredTransaction = deserializeTransaction(newTransactionBuffer)
153
+ verifyOnlyFeePayerChanged(unsignedTxVersionedTransaction, sponsoredTransaction)
154
+
155
+ unsignedTxWithFeePayer = {
156
+ txData: {
157
+ transactionBuffer: newTransactionBuffer,
158
+ },
159
+ txMeta: {
160
+ ...unsignedTx.txMeta,
161
+ feePayerPublicKey: result.feePayerPublicKey,
162
+ sponsorshipRequestId: result.requestId,
163
+ estimatedSponsorCost: result.estimatedCost,
164
+ usedAuthentication: result.usedAuthentication || false,
165
+ usedFeePayer: true,
166
+ },
167
+ }
168
+ } catch (err) {
169
+ console.log('Fee payer service error, sending original transaction:', err.message)
170
+ unsignedTxWithFeePayer.txMeta.usedFeePayer = false
171
+ }
172
+
173
+ return unsignedTxWithFeePayer
174
+ }
package/src/get-fees.js CHANGED
@@ -1,14 +1,12 @@
1
1
  /* eslint-disable @exodus/mutable/no-param-reassign-prop-only */
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import { createUnsignedTxForSend } from './create-unsigned-tx-for-send.js'
5
-
6
4
  const DEFAULT_SAFE_FEE = '0.0025' // SOL (enough for rent exemption)
7
5
 
8
- export const getFeeAsyncFactory = ({ api }) => {
9
- assert(api, 'api is required')
6
+ export const getFeeAsyncFactory = ({ createTx }) => {
10
7
  return async ({
11
8
  asset,
9
+ walletAccount,
12
10
  method,
13
11
  feeData,
14
12
  unsignedTx: providedUnsignedTx,
@@ -21,12 +19,10 @@ export const getFeeAsyncFactory = ({ api }) => {
21
19
 
22
20
  if (providedUnsignedTx) {
23
21
  unsignedTx = providedUnsignedTx
24
- fee = asset.feeAsset.currency.baseUnit(unsignedTx.txMeta.fee)
25
22
  } else {
26
23
  if (['delegate', 'undelegate', 'withdraw'].includes(method)) {
27
24
  assert(stakingInfo, 'stakingInfo is required for staking txs')
28
25
  assert(rest.fromAddress, 'fromAddress is required for staking txs')
29
- assert(feeData, 'feeData is required for staking txs')
30
26
 
31
27
  // staking params
32
28
  rest.method = method
@@ -53,25 +49,29 @@ export const getFeeAsyncFactory = ({ api }) => {
53
49
  }
54
50
 
55
51
  try {
56
- unsignedTx = await createUnsignedTxForSend({
52
+ unsignedTx = await createTx({
57
53
  asset,
54
+ walletAccount,
58
55
  feeData,
59
- api,
60
56
  amount: amount ?? asset.currency.baseUnit(1),
61
57
  toAddress: toAddress ?? rest.fromAddress,
62
- useFeePayer: false,
63
58
  ...rest,
64
59
  })
65
-
66
- fee = asset.feeAsset.currency.baseUnit(unsignedTx.txMeta.fee)
67
60
  } catch (err) {
68
61
  console.log('error computing right SOL fee:', err)
69
62
  // simulating a tx will fail if the user has not enough balance
70
63
  // we fallback to a default fee (but we could leave some dust)
71
- fee = asset.feeAsset.currency.defaultUnit(DEFAULT_SAFE_FEE)
72
64
  }
73
65
  }
74
66
 
67
+ if (unsignedTx?.txMeta?.fee) {
68
+ fee = unsignedTx.txMeta.usedFeePayer
69
+ ? asset.feeAsset.currency.ZERO
70
+ : asset.feeAsset.currency.baseUnit(unsignedTx.txMeta.fee)
71
+ } else {
72
+ fee = asset.feeAsset.currency.defaultUnit(DEFAULT_SAFE_FEE)
73
+ }
74
+
75
75
  return { fee, unsignedTx }
76
76
  }
77
77
  }
package/src/index.js CHANGED
@@ -18,7 +18,8 @@ export { createAndBroadcastTXFactory } from './tx-send.js'
18
18
  export { getBalancesFactory } from './get-balances.js'
19
19
  export { getFeeAsyncFactory } from './get-fees.js'
20
20
  export { stakingProviderClientFactory } from './staking-provider-client.js'
21
- export { createUnsignedTxForSend } from './create-unsigned-tx-for-send.js'
21
+ export { createTxFactory } from './create-unsigned-tx-for-send.js'
22
+ export { feePayerClientFactory } from './fee-payer.js'
22
23
 
23
24
  // These are not the same asset objects as the wallet creates, so they should never be returned to the wallet.
24
25
  // Initially this may be violated by the Solana code until the first monitor tick updates assets with setTokens()
package/src/tx-send.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import assert from 'minimalistic-assert'
2
2
 
3
- import { createUnsignedTxForSend, extractTxLogData } from './create-unsigned-tx-for-send.js'
3
+ import { extractTxLogData } from './create-unsigned-tx-for-send.js'
4
4
 
5
- export const createAndBroadcastTXFactory =
6
- ({ api, assetClientInterface, feePayerApiUrl }) =>
7
- async ({ asset, walletAccount, unsignedTx: predefinedUnsignedTx, ...legacyParams }) => {
8
- const assetName = asset.name
9
- assert(assetClientInterface, `assetClientInterface must be supplied in sendTx for ${assetName}`)
5
+ export const createAndBroadcastTXFactory = ({ api, assetClientInterface }) => {
6
+ assert(assetClientInterface, 'assetClientInterface is required')
7
+ assert(api, 'api is required')
10
8
 
9
+ return async ({ asset, walletAccount, unsignedTx: predefinedUnsignedTx, ...legacyParams }) => {
10
+ const assetName = asset.name
11
11
  const baseAsset = asset.baseAsset
12
12
 
13
13
  const resolveTxs = async () => {
@@ -15,21 +15,11 @@ export const createAndBroadcastTXFactory =
15
15
  return predefinedUnsignedTx
16
16
  }
17
17
 
18
- // handle legacy mode
19
- const feeData = await assetClientInterface.getFeeData({ assetName })
20
- const fromAddress = await assetClientInterface.getReceiveAddress({
21
- assetName: baseAsset.name,
22
- walletAccount,
23
- })
24
-
25
- return createUnsignedTxForSend({
26
- api,
18
+ return baseAsset.api.createTx({
27
19
  asset,
28
- feeData,
29
- fromAddress,
30
- feePayerApiUrl,
31
20
  amount: legacyParams.amount,
32
21
  toAddress: legacyParams.address,
22
+ ...legacyParams,
33
23
  ...legacyParams.options,
34
24
  })
35
25
  }
@@ -136,3 +126,4 @@ export const createAndBroadcastTXFactory =
136
126
 
137
127
  return { txId }
138
128
  }
129
+ }