@exodus/solana-api 3.20.10 → 3.21.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 +10 -0
- package/package.json +5 -3
- package/src/auth.js +23 -0
- package/src/create-unsigned-tx-for-send.js +224 -243
- package/src/fee-payer.js +174 -0
- package/src/get-fees.js +4 -7
- package/src/index.js +2 -1
- package/src/tx-send.js +9 -18
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.21.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-api@3.20.10...@exodus/solana-api@3.21.0) (2025-10-14)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
* feat: integrate Solana fee payer service (#6615)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
## [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
17
|
|
|
8
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/solana-api",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.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.
|
|
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": "
|
|
52
|
+
"gitHead": "5acad66ba9ec02ffaee656516f72a24ff8a247fb",
|
|
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
|
|
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(
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
const unsignedTx = createUnsignedTx({
|
|
152
|
-
asset,
|
|
153
|
-
from: fromAddress,
|
|
154
|
-
to: toAddress,
|
|
155
|
-
amount,
|
|
156
|
-
recentBlockhash,
|
|
157
|
-
reference,
|
|
158
|
-
memo,
|
|
52
|
+
// </MagicEden>
|
|
53
|
+
isExchange,
|
|
159
54
|
useFeePayer,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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)
|
|
169
80
|
}
|
|
170
81
|
|
|
171
|
-
const
|
|
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
|
|
82
|
+
const isToken = asset.name !== asset.baseAsset.name
|
|
179
83
|
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
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
|
+
}
|
|
119
|
+
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/fee-payer.js
ADDED
|
@@ -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 = ({
|
|
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,
|
|
@@ -53,13 +51,12 @@ export const getFeeAsyncFactory = ({ api }) => {
|
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
try {
|
|
56
|
-
unsignedTx = await
|
|
54
|
+
unsignedTx = await createTx({
|
|
57
55
|
asset,
|
|
56
|
+
walletAccount,
|
|
58
57
|
feeData,
|
|
59
|
-
api,
|
|
60
58
|
amount: amount ?? asset.currency.baseUnit(1),
|
|
61
59
|
toAddress: toAddress ?? rest.fromAddress,
|
|
62
|
-
useFeePayer: false,
|
|
63
60
|
...rest,
|
|
64
61
|
})
|
|
65
62
|
|
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 {
|
|
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 {
|
|
3
|
+
import { extractTxLogData } from './create-unsigned-tx-for-send.js'
|
|
4
4
|
|
|
5
|
-
export const createAndBroadcastTXFactory =
|
|
6
|
-
(
|
|
7
|
-
|
|
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
|
-
|
|
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
|
+
}
|