@ember-finance/sdk 2.39.0 → 2.40.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/dist/src/ember-vaults.d.ts +14 -6
- package/dist/src/ember-vaults.js +20 -3
- package/dist/src/solana-vaults/index.d.ts +1 -0
- package/dist/src/solana-vaults/index.js +1 -0
- package/dist/src/solana-vaults/on-chain-calls/index.d.ts +2 -0
- package/dist/src/solana-vaults/on-chain-calls/index.js +2 -0
- package/dist/src/solana-vaults/on-chain-calls/tx-builder.d.ts +62 -0
- package/dist/src/solana-vaults/on-chain-calls/tx-builder.js +190 -0
- package/dist/src/solana-vaults/on-chain-calls/user.d.ts +150 -0
- package/dist/src/solana-vaults/on-chain-calls/user.js +288 -0
- package/dist/src/solana-vaults/signers/index.d.ts +1 -0
- package/dist/src/solana-vaults/signers/index.js +1 -0
- package/dist/src/solana-vaults/signers/solana-signer.d.ts +44 -0
- package/dist/src/solana-vaults/signers/solana-signer.js +61 -0
- package/dist/src/solana-vaults/utils/pdas.d.ts +9 -0
- package/dist/src/solana-vaults/utils/pdas.js +21 -0
- package/package.json +1 -1
|
@@ -22,6 +22,8 @@ import type { Connection as SolanaConnection } from "@solana/web3.js";
|
|
|
22
22
|
import type { ISolanaDeployment } from "./solana-vaults/interfaces/index.js";
|
|
23
23
|
import { SolanaDeploymentParser } from "./solana-vaults/utils/deployment-parser.js";
|
|
24
24
|
import { SolanaVaultReader } from "./solana-vaults/on-chain-calls/vault-reader.js";
|
|
25
|
+
import { SolanaUserCalls } from "./solana-vaults/on-chain-calls/user.js";
|
|
26
|
+
import type { SolanaSigner } from "./solana-vaults/signers/solana-signer.js";
|
|
25
27
|
import { ChainType, PlatformEnv } from "./common/constants.js";
|
|
26
28
|
/**
|
|
27
29
|
* SUI-specific initialization options
|
|
@@ -54,13 +56,16 @@ export interface EvmInitOptions {
|
|
|
54
56
|
* Solana-specific initialization options.
|
|
55
57
|
*
|
|
56
58
|
* `connection` rather than a provider: web3.js has no provider abstraction,
|
|
57
|
-
* and every read
|
|
58
|
-
*
|
|
59
|
-
*
|
|
59
|
+
* and every read goes through it.
|
|
60
|
+
*
|
|
61
|
+
* `signer` takes either shape - a raw Keypair for scripts and ops tooling, or
|
|
62
|
+
* a connected browser wallet - and is optional so a page can read before
|
|
63
|
+
* anyone connects. A write with no signer anywhere throws.
|
|
60
64
|
*/
|
|
61
65
|
export interface SolanaInitOptions {
|
|
62
66
|
chainType: "solana";
|
|
63
67
|
connection: SolanaConnection;
|
|
68
|
+
signer?: SolanaSigner;
|
|
64
69
|
/**
|
|
65
70
|
* Unused on Solana and accepted only so the constructor can read it
|
|
66
71
|
* uniformly. The chain identifier is "solana" on every cluster; which
|
|
@@ -76,8 +81,8 @@ export type EmberVaultsInitOptions = SuiInitOptions | EvmInitOptions | SolanaIni
|
|
|
76
81
|
/**
|
|
77
82
|
* Solana-specific EmberVaults instance type.
|
|
78
83
|
*
|
|
79
|
-
* Narrower than its Sui and EVM counterparts
|
|
80
|
-
* admin, operator, vaultAdmin
|
|
84
|
+
* Narrower than its Sui and EVM counterparts: reads plus the user writes.
|
|
85
|
+
* The admin, operator, vaultAdmin and txBuilder surfaces follow, and the
|
|
81
86
|
* getters for them throw rather than returning undefined until they do.
|
|
82
87
|
*/
|
|
83
88
|
export interface SolanaEmberVaults {
|
|
@@ -86,6 +91,7 @@ export interface SolanaEmberVaults {
|
|
|
86
91
|
parser: SolanaDeploymentParser;
|
|
87
92
|
walletAddress?: string;
|
|
88
93
|
reader: SolanaVaultReader;
|
|
94
|
+
user: SolanaUserCalls;
|
|
89
95
|
deployment: ISolanaDeployment;
|
|
90
96
|
}
|
|
91
97
|
/**
|
|
@@ -178,6 +184,8 @@ export declare class EmberVaults {
|
|
|
178
184
|
private _solanaParser?;
|
|
179
185
|
private _solanaDeployment?;
|
|
180
186
|
private _solanaVaultReader?;
|
|
187
|
+
private _solanaUser?;
|
|
188
|
+
private _solanaSigner?;
|
|
181
189
|
private _walletAddress?;
|
|
182
190
|
private _chainIdentifier?;
|
|
183
191
|
constructor(options: EmberVaultsInitOptions);
|
|
@@ -221,7 +229,7 @@ export declare class EmberVaults {
|
|
|
221
229
|
get admin(): SuiAdminCalls | EVMAdminCalls;
|
|
222
230
|
get operator(): SuiOperatorCalls | EVMOperatorCalls;
|
|
223
231
|
get vaultAdmin(): SuiVaultAdminCalls | EVMVaultAdminCalls;
|
|
224
|
-
get user(): SuiUserCalls | EVMUserCalls;
|
|
232
|
+
get user(): SuiUserCalls | EVMUserCalls | SolanaUserCalls;
|
|
225
233
|
get deployment(): ISuiDeployment | EvmProtocolInfo | ISolanaDeployment;
|
|
226
234
|
/**
|
|
227
235
|
* Check if this is a SUI instance
|
package/dist/src/ember-vaults.js
CHANGED
|
@@ -16,6 +16,7 @@ import { AccountsApi, Configuration, VaultsApi } from "./api/index.js";
|
|
|
16
16
|
import { AccountsApi as AccountsV2Api, Configuration as ConfigurationV2, VaultsApi as VaultsV2Api } from "./api/v2/index.js";
|
|
17
17
|
import { SolanaDeploymentParser } from "./solana-vaults/utils/deployment-parser.js";
|
|
18
18
|
import { SolanaVaultReader } from "./solana-vaults/on-chain-calls/vault-reader.js";
|
|
19
|
+
import { SolanaUserCalls } from "./solana-vaults/on-chain-calls/user.js";
|
|
19
20
|
import { environmentConfig } from "./common/constants.js";
|
|
20
21
|
/**
|
|
21
22
|
* EmberVaults - Unified interface for both SUI and EVM chains
|
|
@@ -73,10 +74,12 @@ export class EmberVaults {
|
|
|
73
74
|
_initSolana(options) {
|
|
74
75
|
this._solanaConnection = options.connection;
|
|
75
76
|
this._walletAddress = options.walletAddress;
|
|
77
|
+
this._solanaSigner = options.signer;
|
|
76
78
|
if (options.deployment) {
|
|
77
79
|
this._solanaDeployment = options.deployment;
|
|
78
80
|
this._solanaParser = new SolanaDeploymentParser(options.deployment);
|
|
79
81
|
this._solanaVaultReader = new SolanaVaultReader(options.connection, this._solanaParser);
|
|
82
|
+
this._solanaUser = new SolanaUserCalls(options.connection, this._solanaParser, options.signer);
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
_initSui(options) {
|
|
@@ -157,6 +160,12 @@ export class EmberVaults {
|
|
|
157
160
|
// so it is rebuilt rather than mutated - a deployment pointing at a
|
|
158
161
|
// different program is a different parser.
|
|
159
162
|
this._solanaParser = new SolanaDeploymentParser(solanaDeployment);
|
|
163
|
+
if (this._solanaUser) {
|
|
164
|
+
this._solanaUser.updateDeployment(this._solanaParser);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
this._solanaUser = new SolanaUserCalls(this._solanaConnection, this._solanaParser, this._solanaSigner);
|
|
168
|
+
}
|
|
160
169
|
if (this._solanaVaultReader) {
|
|
161
170
|
this._solanaVaultReader.updateDeployment(this._solanaParser);
|
|
162
171
|
}
|
|
@@ -234,7 +243,8 @@ export class EmberVaults {
|
|
|
234
243
|
*/
|
|
235
244
|
notOnSolana(surface) {
|
|
236
245
|
throw new Error(`${surface} is not available for Solana yet. ` +
|
|
237
|
-
`The Solana module
|
|
246
|
+
`The Solana module exposes reads and the user writes: ` +
|
|
247
|
+
`use .reader, .user, .parser or .deployment.`);
|
|
238
248
|
}
|
|
239
249
|
// ============================================
|
|
240
250
|
// Solana Getters
|
|
@@ -276,8 +286,11 @@ export class EmberVaults {
|
|
|
276
286
|
return this.chainType === "sui" ? this._suiVaultAdmin : this._evmVaultAdmin;
|
|
277
287
|
}
|
|
278
288
|
get user() {
|
|
279
|
-
if (this.chainType === "solana")
|
|
280
|
-
this.
|
|
289
|
+
if (this.chainType === "solana") {
|
|
290
|
+
if (!this._solanaUser)
|
|
291
|
+
throw new Error("No Solana deployment loaded. Pass one, or call init().");
|
|
292
|
+
return this._solanaUser;
|
|
293
|
+
}
|
|
281
294
|
return this.chainType === "sui" ? this._suiUser : this._evmUser;
|
|
282
295
|
}
|
|
283
296
|
get deployment() {
|
|
@@ -360,12 +373,16 @@ export class EmberVaults {
|
|
|
360
373
|
if (!this._solanaParser || !this._solanaVaultReader || !this._solanaDeployment) {
|
|
361
374
|
throw new Error("No Solana deployment loaded. Pass one, or call init().");
|
|
362
375
|
}
|
|
376
|
+
if (!this._solanaUser) {
|
|
377
|
+
throw new Error("No Solana deployment loaded. Pass one, or call init().");
|
|
378
|
+
}
|
|
363
379
|
return {
|
|
364
380
|
chainType: "solana",
|
|
365
381
|
connection: this._solanaConnection,
|
|
366
382
|
parser: this._solanaParser,
|
|
367
383
|
walletAddress: this._walletAddress,
|
|
368
384
|
reader: this._solanaVaultReader,
|
|
385
|
+
user: this._solanaUser,
|
|
369
386
|
deployment: this._solanaDeployment
|
|
370
387
|
};
|
|
371
388
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PublicKey, TransactionInstruction } from "@solana/web3.js";
|
|
2
|
+
/** Everything an instruction needs that cannot be derived from the share mint. */
|
|
3
|
+
export interface ISolanaVaultContext {
|
|
4
|
+
programId: PublicKey;
|
|
5
|
+
shareMint: PublicKey;
|
|
6
|
+
collateralMint: PublicKey;
|
|
7
|
+
/** The owning program of each mint; they need not be the same one. */
|
|
8
|
+
shareProgram: PublicKey;
|
|
9
|
+
collateralProgram: PublicKey;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Builds the program's user instructions. Pure: it derives addresses and
|
|
13
|
+
* encodes data, and never touches the network or signs.
|
|
14
|
+
*
|
|
15
|
+
* Kept separate from SolanaUserCalls so a caller can batch several of these
|
|
16
|
+
* into one transaction, add their own instructions around them, or hand the
|
|
17
|
+
* result to a different sender — the same split the EVM side has.
|
|
18
|
+
*/
|
|
19
|
+
export declare class SolanaTxBuilder {
|
|
20
|
+
/**
|
|
21
|
+
* Creates an associated token account only if it is missing.
|
|
22
|
+
*
|
|
23
|
+
* `tokenProgram` is a parameter rather than a constant: an ATA's address
|
|
24
|
+
* derives from it, so a Token-2022 mint resolves somewhere else entirely
|
|
25
|
+
* and passing the SPL Token default would create an account at an address
|
|
26
|
+
* the program will not accept.
|
|
27
|
+
*/
|
|
28
|
+
static createAtaIdempotent(payer: PublicKey, owner: PublicKey, mint: PublicKey, tokenProgram?: PublicKey): TransactionInstruction;
|
|
29
|
+
/**
|
|
30
|
+
* `deposit_asset`: moves collateral in and mints shares at the live rate.
|
|
31
|
+
*
|
|
32
|
+
* `minShares` is the slippage floor and is enforced on chain. It matters
|
|
33
|
+
* only if the rate manager moves the rate between quoting and landing.
|
|
34
|
+
*/
|
|
35
|
+
static depositAsset(ctx: ISolanaVaultContext, depositor: PublicKey, amount: bigint, minShares: bigint): TransactionInstruction;
|
|
36
|
+
/**
|
|
37
|
+
* `mint_shares`: the same movement expressed the other way round — an exact
|
|
38
|
+
* share count out, with `maxAmount` capping the collateral it may consume.
|
|
39
|
+
*/
|
|
40
|
+
static mintShares(ctx: ISolanaVaultContext, depositor: PublicKey, shares: bigint, maxAmount: bigint): TransactionInstruction;
|
|
41
|
+
/**
|
|
42
|
+
* `redeem_shares`: burns nothing yet — it escrows the shares into the
|
|
43
|
+
* vault's pending account and appends a request to the withdrawal queue.
|
|
44
|
+
* The operator settles it later, so this returns no collateral.
|
|
45
|
+
*
|
|
46
|
+
* `requestIndex` identifies the request account and must be the owner's
|
|
47
|
+
* next index; SolanaUserCalls reads it so callers do not have to.
|
|
48
|
+
*/
|
|
49
|
+
static redeemShares(ctx: ISolanaVaultContext, owner: PublicKey, shares: bigint, requestNonce: bigint, receiver: PublicKey): TransactionInstruction;
|
|
50
|
+
/**
|
|
51
|
+
* `cancel_pending_withdrawal_request`: withdraws the request from the
|
|
52
|
+
* queue, identified by its `sequenceNumber`.
|
|
53
|
+
*
|
|
54
|
+
* Only four accounts, none of them token accounts. The escrowed shares are
|
|
55
|
+
* not moved here — the request is marked cancelled on the owner's
|
|
56
|
+
* VaultAccount and the operator returns the shares when it next settles —
|
|
57
|
+
* so neither the share mint, the pending-shares escrow nor the request's
|
|
58
|
+
* own account appear. Matches CancelWithdrawalRequest in withdraw.rs: note
|
|
59
|
+
* that `vault` and `owner` are both read-only.
|
|
60
|
+
*/
|
|
61
|
+
static cancelPendingWithdrawalRequest(ctx: ISolanaVaultContext, owner: PublicKey, sequenceNumber: bigint): TransactionInstruction;
|
|
62
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { SystemProgram, TransactionInstruction } from "@solana/web3.js";
|
|
2
|
+
import { ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_PROGRAM_ID, associatedTokenAddress, deriveProtocolConfig, deriveVaultAccount, deriveVaultPdas, deriveWithdrawalRequest, eventCpiAccounts, instructionDiscriminator, u128le, u64le } from "../utils/pdas.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds the program's user instructions. Pure: it derives addresses and
|
|
5
|
+
* encodes data, and never touches the network or signs.
|
|
6
|
+
*
|
|
7
|
+
* Kept separate from SolanaUserCalls so a caller can batch several of these
|
|
8
|
+
* into one transaction, add their own instructions around them, or hand the
|
|
9
|
+
* result to a different sender — the same split the EVM side has.
|
|
10
|
+
*/
|
|
11
|
+
export class SolanaTxBuilder {
|
|
12
|
+
/**
|
|
13
|
+
* Creates an associated token account only if it is missing.
|
|
14
|
+
*
|
|
15
|
+
* `tokenProgram` is a parameter rather than a constant: an ATA's address
|
|
16
|
+
* derives from it, so a Token-2022 mint resolves somewhere else entirely
|
|
17
|
+
* and passing the SPL Token default would create an account at an address
|
|
18
|
+
* the program will not accept.
|
|
19
|
+
*/
|
|
20
|
+
static createAtaIdempotent(payer, owner, mint, tokenProgram = TOKEN_PROGRAM_ID) {
|
|
21
|
+
return new TransactionInstruction({
|
|
22
|
+
programId: ASSOCIATED_TOKEN_PROGRAM_ID,
|
|
23
|
+
keys: [
|
|
24
|
+
{ pubkey: payer, isSigner: true, isWritable: true },
|
|
25
|
+
{
|
|
26
|
+
pubkey: associatedTokenAddress(owner, mint, tokenProgram),
|
|
27
|
+
isSigner: false,
|
|
28
|
+
isWritable: true
|
|
29
|
+
},
|
|
30
|
+
{ pubkey: owner, isSigner: false, isWritable: false },
|
|
31
|
+
{ pubkey: mint, isSigner: false, isWritable: false },
|
|
32
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
33
|
+
{ pubkey: tokenProgram, isSigner: false, isWritable: false }
|
|
34
|
+
],
|
|
35
|
+
// 1 = CreateIdempotent. Unlike 0 (Create), this succeeds when the
|
|
36
|
+
// account already exists, so a repeat deposit needs no pre-check.
|
|
37
|
+
data: Buffer.from([1])
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `deposit_asset`: moves collateral in and mints shares at the live rate.
|
|
42
|
+
*
|
|
43
|
+
* `minShares` is the slippage floor and is enforced on chain. It matters
|
|
44
|
+
* only if the rate manager moves the rate between quoting and landing.
|
|
45
|
+
*/
|
|
46
|
+
static depositAsset(ctx, depositor, amount, minShares) {
|
|
47
|
+
const pdas = deriveVaultPdas(ctx.programId, ctx.shareMint);
|
|
48
|
+
return new TransactionInstruction({
|
|
49
|
+
programId: ctx.programId,
|
|
50
|
+
keys: [
|
|
51
|
+
{
|
|
52
|
+
pubkey: deriveProtocolConfig(ctx.programId),
|
|
53
|
+
isSigner: false,
|
|
54
|
+
isWritable: false
|
|
55
|
+
},
|
|
56
|
+
{ pubkey: pdas.vault, isSigner: false, isWritable: true },
|
|
57
|
+
{ pubkey: ctx.collateralMint, isSigner: false, isWritable: false },
|
|
58
|
+
{ pubkey: ctx.shareMint, isSigner: false, isWritable: true },
|
|
59
|
+
{ pubkey: pdas.vaultCollateral, isSigner: false, isWritable: true },
|
|
60
|
+
{ pubkey: depositor, isSigner: true, isWritable: true },
|
|
61
|
+
{
|
|
62
|
+
pubkey: deriveVaultAccount(ctx.programId, pdas.vault, depositor),
|
|
63
|
+
isSigner: false,
|
|
64
|
+
isWritable: true
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
pubkey: associatedTokenAddress(depositor, ctx.collateralMint, ctx.collateralProgram),
|
|
68
|
+
isSigner: false,
|
|
69
|
+
isWritable: true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
pubkey: associatedTokenAddress(depositor, ctx.shareMint, ctx.shareProgram),
|
|
73
|
+
isSigner: false,
|
|
74
|
+
isWritable: true
|
|
75
|
+
},
|
|
76
|
+
{ pubkey: ctx.collateralProgram, isSigner: false, isWritable: false },
|
|
77
|
+
{ pubkey: ctx.shareProgram, isSigner: false, isWritable: false },
|
|
78
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
79
|
+
...eventCpiAccounts(ctx.programId)
|
|
80
|
+
],
|
|
81
|
+
data: Buffer.concat([
|
|
82
|
+
instructionDiscriminator("deposit_asset"),
|
|
83
|
+
u64le(amount),
|
|
84
|
+
u64le(minShares)
|
|
85
|
+
])
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* `mint_shares`: the same movement expressed the other way round — an exact
|
|
90
|
+
* share count out, with `maxAmount` capping the collateral it may consume.
|
|
91
|
+
*/
|
|
92
|
+
static mintShares(ctx, depositor, shares, maxAmount) {
|
|
93
|
+
const deposit = SolanaTxBuilder.depositAsset(ctx, depositor, shares, maxAmount);
|
|
94
|
+
return new TransactionInstruction({
|
|
95
|
+
programId: deposit.programId,
|
|
96
|
+
keys: deposit.keys,
|
|
97
|
+
data: Buffer.concat([
|
|
98
|
+
instructionDiscriminator("mint_shares"),
|
|
99
|
+
u64le(shares),
|
|
100
|
+
u64le(maxAmount)
|
|
101
|
+
])
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `redeem_shares`: burns nothing yet — it escrows the shares into the
|
|
106
|
+
* vault's pending account and appends a request to the withdrawal queue.
|
|
107
|
+
* The operator settles it later, so this returns no collateral.
|
|
108
|
+
*
|
|
109
|
+
* `requestIndex` identifies the request account and must be the owner's
|
|
110
|
+
* next index; SolanaUserCalls reads it so callers do not have to.
|
|
111
|
+
*/
|
|
112
|
+
static redeemShares(ctx, owner, shares, requestNonce, receiver) {
|
|
113
|
+
const pdas = deriveVaultPdas(ctx.programId, ctx.shareMint);
|
|
114
|
+
const vaultAccount = deriveVaultAccount(ctx.programId, pdas.vault, owner);
|
|
115
|
+
return new TransactionInstruction({
|
|
116
|
+
programId: ctx.programId,
|
|
117
|
+
keys: [
|
|
118
|
+
{
|
|
119
|
+
pubkey: deriveProtocolConfig(ctx.programId),
|
|
120
|
+
isSigner: false,
|
|
121
|
+
isWritable: false
|
|
122
|
+
},
|
|
123
|
+
{ pubkey: pdas.vault, isSigner: false, isWritable: true },
|
|
124
|
+
{ pubkey: ctx.shareMint, isSigner: false, isWritable: false },
|
|
125
|
+
{ pubkey: pdas.vaultPendingShares, isSigner: false, isWritable: true },
|
|
126
|
+
{ pubkey: pdas.withdrawalQueue, isSigner: false, isWritable: true },
|
|
127
|
+
{ pubkey: vaultAccount, isSigner: false, isWritable: true },
|
|
128
|
+
{ pubkey: owner, isSigner: true, isWritable: true },
|
|
129
|
+
{
|
|
130
|
+
pubkey: deriveWithdrawalRequest(ctx.programId, pdas.vault, owner, requestNonce),
|
|
131
|
+
isSigner: false,
|
|
132
|
+
isWritable: true
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
pubkey: associatedTokenAddress(owner, ctx.shareMint, ctx.shareProgram),
|
|
136
|
+
isSigner: false,
|
|
137
|
+
isWritable: true
|
|
138
|
+
},
|
|
139
|
+
{ pubkey: ctx.shareProgram, isSigner: false, isWritable: false },
|
|
140
|
+
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
|
|
141
|
+
...eventCpiAccounts(ctx.programId)
|
|
142
|
+
],
|
|
143
|
+
// `receiver` is an argument, not an account: the collateral is paid
|
|
144
|
+
// out later by the operator, so there is nothing to write to now.
|
|
145
|
+
data: Buffer.concat([
|
|
146
|
+
instructionDiscriminator("redeem_shares"),
|
|
147
|
+
u64le(shares),
|
|
148
|
+
receiver.toBuffer()
|
|
149
|
+
])
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* `cancel_pending_withdrawal_request`: withdraws the request from the
|
|
154
|
+
* queue, identified by its `sequenceNumber`.
|
|
155
|
+
*
|
|
156
|
+
* Only four accounts, none of them token accounts. The escrowed shares are
|
|
157
|
+
* not moved here — the request is marked cancelled on the owner's
|
|
158
|
+
* VaultAccount and the operator returns the shares when it next settles —
|
|
159
|
+
* so neither the share mint, the pending-shares escrow nor the request's
|
|
160
|
+
* own account appear. Matches CancelWithdrawalRequest in withdraw.rs: note
|
|
161
|
+
* that `vault` and `owner` are both read-only.
|
|
162
|
+
*/
|
|
163
|
+
static cancelPendingWithdrawalRequest(ctx, owner, sequenceNumber) {
|
|
164
|
+
const pdas = deriveVaultPdas(ctx.programId, ctx.shareMint);
|
|
165
|
+
return new TransactionInstruction({
|
|
166
|
+
programId: ctx.programId,
|
|
167
|
+
keys: [
|
|
168
|
+
{
|
|
169
|
+
pubkey: deriveProtocolConfig(ctx.programId),
|
|
170
|
+
isSigner: false,
|
|
171
|
+
isWritable: false
|
|
172
|
+
},
|
|
173
|
+
{ pubkey: pdas.vault, isSigner: false, isWritable: false },
|
|
174
|
+
{
|
|
175
|
+
pubkey: deriveVaultAccount(ctx.programId, pdas.vault, owner),
|
|
176
|
+
isSigner: false,
|
|
177
|
+
isWritable: true
|
|
178
|
+
},
|
|
179
|
+
{ pubkey: owner, isSigner: true, isWritable: false },
|
|
180
|
+
...eventCpiAccounts(ctx.programId)
|
|
181
|
+
],
|
|
182
|
+
// u128, not u64: a queue sequence number, not the request nonce
|
|
183
|
+
// that seeds the request PDA.
|
|
184
|
+
data: Buffer.concat([
|
|
185
|
+
instructionDiscriminator("cancel_pending_withdrawal_request"),
|
|
186
|
+
u128le(sequenceNumber)
|
|
187
|
+
])
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { type Connection, PublicKey, type TransactionInstruction } from "@solana/web3.js";
|
|
2
|
+
import { type ISolanaWithdrawalRequest } from "../interfaces/index.js";
|
|
3
|
+
import { type SolanaSigner } from "../signers/solana-signer.js";
|
|
4
|
+
import { SolanaDeploymentParser } from "../utils/deployment-parser.js";
|
|
5
|
+
/** What a write returns. */
|
|
6
|
+
export interface ISolanaTxResponse {
|
|
7
|
+
/** The transaction signature, base58. Also its explorer id. */
|
|
8
|
+
signature: string;
|
|
9
|
+
/** Compute units the simulation reported, when one ran. */
|
|
10
|
+
unitsConsumed?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ISolanaWriteOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Simulate before sending. On by default: a failed simulation costs one
|
|
15
|
+
* round trip and reports the program's error, where a failed send costs a
|
|
16
|
+
* fee and reports a bare "custom program error".
|
|
17
|
+
*/
|
|
18
|
+
simulate?: boolean;
|
|
19
|
+
/** Extra instructions to run before the vault call, e.g. a compute budget. */
|
|
20
|
+
preInstructions?: TransactionInstruction[];
|
|
21
|
+
/** Overrides the signer supplied to the constructor, for this call only. */
|
|
22
|
+
signer?: SolanaSigner;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Redemptions, and only redemptions, take a receiver.
|
|
26
|
+
*
|
|
27
|
+
* `deposit_asset` and `mint_shares` have no receiver argument and mint into the
|
|
28
|
+
* depositor's own associated token account, so a `receiver` on those options
|
|
29
|
+
* would be accepted and silently ignored. It is kept off their type rather than
|
|
30
|
+
* rejected at runtime, so the mistake does not compile.
|
|
31
|
+
*/
|
|
32
|
+
export interface ISolanaRedeemOptions extends ISolanaWriteOptions {
|
|
33
|
+
/** Who receives the collateral at settlement. Defaults to the signer. */
|
|
34
|
+
receiver?: PublicKey | string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Amounts accept the shapes the Sui and EVM surfaces accept, not just bigint.
|
|
38
|
+
*
|
|
39
|
+
* `EmberVaults.user` is a union of the three chains' call classes, so a shared
|
|
40
|
+
* call site resolves each parameter to the INTERSECTION of their types. Sui's
|
|
41
|
+
* `NumStr` is `number | string` and EVM's is `string | number | bigint`;
|
|
42
|
+
* intersected with a bigint-only Solana that is `never`, so before this type
|
|
43
|
+
* existed `ember.user.redeemShares(vault, x)` failed to compile for every x —
|
|
44
|
+
* `100`, `"100"` and `100n` alike.
|
|
45
|
+
*
|
|
46
|
+
* `100n` through `ember.user` is still rejected, because Sui's `NumStr` has no
|
|
47
|
+
* bigint. That is pre-existing and unrelated to Solana; a bigint works on a
|
|
48
|
+
* narrowed instance (`asSolana().user`, or a `SolanaUserCalls` directly).
|
|
49
|
+
*/
|
|
50
|
+
export type SolanaAmount = bigint | number | string;
|
|
51
|
+
/**
|
|
52
|
+
* The user-facing writes: deposit, mint, redeem and cancel.
|
|
53
|
+
*
|
|
54
|
+
* Each method reads only what it needs to build a correct instruction — the
|
|
55
|
+
* mints' token programs and, for a redeem, the owner's request nonce — then
|
|
56
|
+
* signs and sends. The instruction building itself lives in SolanaTxBuilder,
|
|
57
|
+
* so a caller who wants to batch or route differently can use that directly.
|
|
58
|
+
*/
|
|
59
|
+
export declare class SolanaUserCalls {
|
|
60
|
+
private readonly connection;
|
|
61
|
+
/**
|
|
62
|
+
* Optional so one instance can serve a page that has not connected a
|
|
63
|
+
* wallet yet; every write then takes its own signer.
|
|
64
|
+
*/
|
|
65
|
+
private readonly signer?;
|
|
66
|
+
private parser;
|
|
67
|
+
private readonly reader;
|
|
68
|
+
constructor(connection: Connection, parser: SolanaDeploymentParser,
|
|
69
|
+
/**
|
|
70
|
+
* Optional so one instance can serve a page that has not connected a
|
|
71
|
+
* wallet yet; every write then takes its own signer.
|
|
72
|
+
*/
|
|
73
|
+
signer?: SolanaSigner);
|
|
74
|
+
/** Swap in a newer deployment without rebuilding the caller. */
|
|
75
|
+
updateDeployment(parser: SolanaDeploymentParser): void;
|
|
76
|
+
private key;
|
|
77
|
+
private resolveSigner;
|
|
78
|
+
/**
|
|
79
|
+
* The mints and their owning token programs.
|
|
80
|
+
*
|
|
81
|
+
* The programs come from the deployment manifest when it records them and
|
|
82
|
+
* from the mint accounts otherwise. They are not assumed equal: a vault's
|
|
83
|
+
* collateral and share mints may sit under SPL Token and Token-2022
|
|
84
|
+
* respectively, and the ATA addresses differ between the two.
|
|
85
|
+
*/
|
|
86
|
+
private vaultContext;
|
|
87
|
+
private tokenProgram;
|
|
88
|
+
/**
|
|
89
|
+
* Signs, sends and confirms. Simulation runs first unless turned off, and
|
|
90
|
+
* its error is surfaced in place of the send's: the program's own message
|
|
91
|
+
* is in the simulation logs, while a failed send gives only a code.
|
|
92
|
+
*/
|
|
93
|
+
private send;
|
|
94
|
+
/**
|
|
95
|
+
* Deposits collateral and mints shares at the vault's live rate.
|
|
96
|
+
*
|
|
97
|
+
* `minShares` is the on-chain slippage floor; it bites only if the rate
|
|
98
|
+
* manager moves the rate between quoting and landing. Quote it with
|
|
99
|
+
* `SolanaVaultReader.previewDeposit`.
|
|
100
|
+
*
|
|
101
|
+
* The depositor's share account is created here when missing, so a first
|
|
102
|
+
* deposit is still a single transaction.
|
|
103
|
+
*/
|
|
104
|
+
depositAsset(shareMint: PublicKey | string, amount: SolanaAmount, minShares: SolanaAmount, options?: ISolanaWriteOptions): Promise<ISolanaTxResponse>;
|
|
105
|
+
/**
|
|
106
|
+
* Mints an exact share count, spending at most `maxAmount` of collateral.
|
|
107
|
+
* The mirror of depositAsset, for a caller who wants a round share figure.
|
|
108
|
+
*/
|
|
109
|
+
mintShares(shareMint: PublicKey | string, shares: SolanaAmount, maxAmount: SolanaAmount, options?: ISolanaWriteOptions): Promise<ISolanaTxResponse>;
|
|
110
|
+
/**
|
|
111
|
+
* The idempotent ATA instruction, but only when the account is actually
|
|
112
|
+
* missing: including it unconditionally costs a few hundred compute units
|
|
113
|
+
* on every repeat deposit for nothing.
|
|
114
|
+
*/
|
|
115
|
+
private ensureShareAccount;
|
|
116
|
+
/**
|
|
117
|
+
* Queues a withdrawal. This does NOT return collateral: the shares are
|
|
118
|
+
* escrowed and a request is appended to the vault's queue, which the
|
|
119
|
+
* operator settles later. Watch it with `getPendingWithdrawals`.
|
|
120
|
+
*
|
|
121
|
+
* The request's account is seeded by the owner's next nonce, which is read
|
|
122
|
+
* here — a caller passing a stale one would collide with their own earlier
|
|
123
|
+
* request.
|
|
124
|
+
*/
|
|
125
|
+
redeemShares(shareMint: PublicKey | string, shares: SolanaAmount, options?: ISolanaRedeemOptions): Promise<ISolanaTxResponse>;
|
|
126
|
+
/**
|
|
127
|
+
* Cancels a queued withdrawal by its queue `sequenceNumber` — not the
|
|
128
|
+
* request nonce that seeds the request account. Take it from
|
|
129
|
+
* `getPendingWithdrawals`.
|
|
130
|
+
*/
|
|
131
|
+
cancelPendingWithdrawalRequest(shareMint: PublicKey | string, sequenceNumber: SolanaAmount, options?: ISolanaWriteOptions): Promise<ISolanaTxResponse>;
|
|
132
|
+
/**
|
|
133
|
+
* An owner's live queued withdrawals, each carrying the `sequenceNumber`
|
|
134
|
+
* that `cancelPendingWithdrawalRequest` takes.
|
|
135
|
+
*
|
|
136
|
+
* CANCELLED REQUESTS ARE EXCLUDED, and that filter is not cosmetic.
|
|
137
|
+
* `cancel_pending_withdrawal_request` does not remove the entry: it pushes
|
|
138
|
+
* the sequence number onto the account's `cancel_withdraw_request` list and
|
|
139
|
+
* leaves the request in place, for the operator to skip when it settles
|
|
140
|
+
* (see withdraw.rs). Returning the raw list would therefore show a
|
|
141
|
+
* withdrawal the user already cancelled as still pending, indefinitely.
|
|
142
|
+
*/
|
|
143
|
+
getPendingWithdrawals(shareMint: PublicKey | string, owner?: PublicKey | string): Promise<ISolanaWithdrawalRequest[]>;
|
|
144
|
+
/**
|
|
145
|
+
* The requests this owner has cancelled that the operator has not yet
|
|
146
|
+
* settled. They still occupy a slot on the account until it does.
|
|
147
|
+
*/
|
|
148
|
+
getCancelledWithdrawals(shareMint: PublicKey | string, owner?: PublicKey | string): Promise<ISolanaWithdrawalRequest[]>;
|
|
149
|
+
private accountState;
|
|
150
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { PublicKey, Transaction } from "@solana/web3.js";
|
|
2
|
+
import { normaliseSigner } from "../signers/solana-signer.js";
|
|
3
|
+
import { associatedTokenAddress } from "../utils/pdas.js";
|
|
4
|
+
import { SolanaTxBuilder } from "./tx-builder.js";
|
|
5
|
+
import { SolanaVaultReader } from "./vault-reader.js";
|
|
6
|
+
/**
|
|
7
|
+
* Normalises an amount to a bigint, rejecting what `BigInt()` would quietly
|
|
8
|
+
* accept.
|
|
9
|
+
*
|
|
10
|
+
* `BigInt()` is far looser than it looks: `BigInt("")` and `BigInt(" ")` are
|
|
11
|
+
* `0n`, `BigInt("0x10")` is `16n` and `BigInt("-5")` is `-5n`. An empty form
|
|
12
|
+
* field would therefore become a zero-amount deposit rather than an error, and
|
|
13
|
+
* a negative would travel as far as `u64le` before failing with a RangeError
|
|
14
|
+
* that names no field. Strings must be plain decimal digits, and every amount
|
|
15
|
+
* here is unsigned on chain.
|
|
16
|
+
*/
|
|
17
|
+
function toBigInt(value, field) {
|
|
18
|
+
let result;
|
|
19
|
+
if (typeof value === "bigint") {
|
|
20
|
+
result = value;
|
|
21
|
+
}
|
|
22
|
+
else if (typeof value === "number") {
|
|
23
|
+
if (!Number.isInteger(value)) {
|
|
24
|
+
throw new TypeError(`${field} must be a whole number, got ${value}`);
|
|
25
|
+
}
|
|
26
|
+
// Past 2^53 a number has already lost the precision an exact token
|
|
27
|
+
// amount needs, so the value here would not be the one the caller meant.
|
|
28
|
+
if (!Number.isSafeInteger(value)) {
|
|
29
|
+
throw new TypeError(`${field} is too large to be exact as a number (${value}); pass a bigint or a string`);
|
|
30
|
+
}
|
|
31
|
+
result = BigInt(value);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const digits = value.trim();
|
|
35
|
+
if (!/^\d+$/.test(digits)) {
|
|
36
|
+
throw new TypeError(`${field} must be a decimal integer string, got ${JSON.stringify(value)}`);
|
|
37
|
+
}
|
|
38
|
+
result = BigInt(digits);
|
|
39
|
+
}
|
|
40
|
+
if (result < 0n) {
|
|
41
|
+
throw new TypeError(`${field} must not be negative, got ${result}`);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Rejects a `receiver` on the instructions that have none.
|
|
47
|
+
*
|
|
48
|
+
* The options types already keep it off deposit and mint, but excess-property
|
|
49
|
+
* checking only applies to object LITERALS: a variable typed
|
|
50
|
+
* ISolanaRedeemOptions satisfies ISolanaWriteOptions and passes straight
|
|
51
|
+
* through, where the field would be silently dropped.
|
|
52
|
+
*/
|
|
53
|
+
function rejectReceiver(options, call) {
|
|
54
|
+
if (options && "receiver" in options) {
|
|
55
|
+
throw new TypeError(`${call} mints into the depositor's own token account and takes no receiver; ` +
|
|
56
|
+
`the program's instruction has no such argument`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The user-facing writes: deposit, mint, redeem and cancel.
|
|
61
|
+
*
|
|
62
|
+
* Each method reads only what it needs to build a correct instruction — the
|
|
63
|
+
* mints' token programs and, for a redeem, the owner's request nonce — then
|
|
64
|
+
* signs and sends. The instruction building itself lives in SolanaTxBuilder,
|
|
65
|
+
* so a caller who wants to batch or route differently can use that directly.
|
|
66
|
+
*/
|
|
67
|
+
export class SolanaUserCalls {
|
|
68
|
+
constructor(connection, parser,
|
|
69
|
+
/**
|
|
70
|
+
* Optional so one instance can serve a page that has not connected a
|
|
71
|
+
* wallet yet; every write then takes its own signer.
|
|
72
|
+
*/
|
|
73
|
+
signer) {
|
|
74
|
+
this.connection = connection;
|
|
75
|
+
this.signer = signer;
|
|
76
|
+
this.parser = parser;
|
|
77
|
+
this.reader = new SolanaVaultReader(connection, parser);
|
|
78
|
+
}
|
|
79
|
+
/** Swap in a newer deployment without rebuilding the caller. */
|
|
80
|
+
updateDeployment(parser) {
|
|
81
|
+
this.parser = parser;
|
|
82
|
+
this.reader.updateDeployment(parser);
|
|
83
|
+
}
|
|
84
|
+
key(value) {
|
|
85
|
+
return typeof value === "string" ? new PublicKey(value) : value;
|
|
86
|
+
}
|
|
87
|
+
resolveSigner(override) {
|
|
88
|
+
const signer = override ?? this.signer;
|
|
89
|
+
if (!signer) {
|
|
90
|
+
throw new Error("no signer: pass one to the constructor or to this call");
|
|
91
|
+
}
|
|
92
|
+
return normaliseSigner(signer);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The mints and their owning token programs.
|
|
96
|
+
*
|
|
97
|
+
* The programs come from the deployment manifest when it records them and
|
|
98
|
+
* from the mint accounts otherwise. They are not assumed equal: a vault's
|
|
99
|
+
* collateral and share mints may sit under SPL Token and Token-2022
|
|
100
|
+
* respectively, and the ATA addresses differ between the two.
|
|
101
|
+
*/
|
|
102
|
+
async vaultContext(shareMint) {
|
|
103
|
+
const mint = this.key(shareMint);
|
|
104
|
+
const collateralMint = this.parser.getCollateralMint(mint);
|
|
105
|
+
const [shareProgram, collateralProgram] = await Promise.all([
|
|
106
|
+
this.tokenProgram(mint),
|
|
107
|
+
this.tokenProgram(collateralMint)
|
|
108
|
+
]);
|
|
109
|
+
return {
|
|
110
|
+
programId: this.parser.programId,
|
|
111
|
+
shareMint: mint,
|
|
112
|
+
collateralMint,
|
|
113
|
+
shareProgram,
|
|
114
|
+
collateralProgram
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async tokenProgram(mint) {
|
|
118
|
+
const recorded = this.parser.getTokenProgram(mint);
|
|
119
|
+
if (recorded)
|
|
120
|
+
return recorded;
|
|
121
|
+
const account = await this.connection.getAccountInfo(mint);
|
|
122
|
+
if (account === null)
|
|
123
|
+
throw new Error(`mint ${mint.toBase58()} not found`);
|
|
124
|
+
return account.owner;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Signs, sends and confirms. Simulation runs first unless turned off, and
|
|
128
|
+
* its error is surfaced in place of the send's: the program's own message
|
|
129
|
+
* is in the simulation logs, while a failed send gives only a code.
|
|
130
|
+
*/
|
|
131
|
+
async send(instructions, signer, options) {
|
|
132
|
+
const tx = new Transaction();
|
|
133
|
+
if (options?.preInstructions?.length)
|
|
134
|
+
tx.add(...options.preInstructions);
|
|
135
|
+
tx.add(...instructions);
|
|
136
|
+
const { blockhash, lastValidBlockHeight } = await this.connection.getLatestBlockhash();
|
|
137
|
+
tx.recentBlockhash = blockhash;
|
|
138
|
+
tx.feePayer = signer.publicKey;
|
|
139
|
+
let unitsConsumed;
|
|
140
|
+
if (options?.simulate !== false) {
|
|
141
|
+
const sim = await this.connection.simulateTransaction(tx);
|
|
142
|
+
if (sim.value.err) {
|
|
143
|
+
const logs = sim.value.logs?.join("\n") ?? "";
|
|
144
|
+
throw new Error(`simulation failed: ${JSON.stringify(sim.value.err)}\n${logs}`);
|
|
145
|
+
}
|
|
146
|
+
unitsConsumed = sim.value.unitsConsumed ?? undefined;
|
|
147
|
+
}
|
|
148
|
+
const signed = await signer.signTransaction(tx);
|
|
149
|
+
const signature = await this.connection.sendRawTransaction(signed.serialize());
|
|
150
|
+
// Confirm at the commitment this Connection READS at, not a hardcoded
|
|
151
|
+
// one. Confirming at "confirmed" under a "finalized" connection returns
|
|
152
|
+
// before the following reads can see the write - two awaited
|
|
153
|
+
// redeemShares calls would then read the same nonce and derive a
|
|
154
|
+
// request account that already exists.
|
|
155
|
+
const confirmation = await this.connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, this.connection.commitment ?? "confirmed");
|
|
156
|
+
if (confirmation.value.err) {
|
|
157
|
+
throw new Error(`transaction ${signature} failed: ${JSON.stringify(confirmation.value.err)}`);
|
|
158
|
+
}
|
|
159
|
+
return { signature, unitsConsumed };
|
|
160
|
+
}
|
|
161
|
+
// --- deposits ---
|
|
162
|
+
/**
|
|
163
|
+
* Deposits collateral and mints shares at the vault's live rate.
|
|
164
|
+
*
|
|
165
|
+
* `minShares` is the on-chain slippage floor; it bites only if the rate
|
|
166
|
+
* manager moves the rate between quoting and landing. Quote it with
|
|
167
|
+
* `SolanaVaultReader.previewDeposit`.
|
|
168
|
+
*
|
|
169
|
+
* The depositor's share account is created here when missing, so a first
|
|
170
|
+
* deposit is still a single transaction.
|
|
171
|
+
*/
|
|
172
|
+
async depositAsset(shareMint, amount, minShares, options) {
|
|
173
|
+
// Validated before any round trip: a bad amount should not cost two
|
|
174
|
+
// account reads first, and the error should name the field rather than
|
|
175
|
+
// surface as a failed mint lookup.
|
|
176
|
+
rejectReceiver(options, "depositAsset");
|
|
177
|
+
const amountValue = toBigInt(amount, "amount");
|
|
178
|
+
const minSharesValue = toBigInt(minShares, "minShares");
|
|
179
|
+
const signer = this.resolveSigner(options?.signer);
|
|
180
|
+
const ctx = await this.vaultContext(shareMint);
|
|
181
|
+
const instructions = [
|
|
182
|
+
...(await this.ensureShareAccount(ctx, signer.publicKey)),
|
|
183
|
+
SolanaTxBuilder.depositAsset(ctx, signer.publicKey, amountValue, minSharesValue)
|
|
184
|
+
];
|
|
185
|
+
return this.send(instructions, signer, options);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Mints an exact share count, spending at most `maxAmount` of collateral.
|
|
189
|
+
* The mirror of depositAsset, for a caller who wants a round share figure.
|
|
190
|
+
*/
|
|
191
|
+
async mintShares(shareMint, shares, maxAmount, options) {
|
|
192
|
+
rejectReceiver(options, "mintShares");
|
|
193
|
+
const sharesValue = toBigInt(shares, "shares");
|
|
194
|
+
const maxAmountValue = toBigInt(maxAmount, "maxAmount");
|
|
195
|
+
const signer = this.resolveSigner(options?.signer);
|
|
196
|
+
const ctx = await this.vaultContext(shareMint);
|
|
197
|
+
const instructions = [
|
|
198
|
+
...(await this.ensureShareAccount(ctx, signer.publicKey)),
|
|
199
|
+
SolanaTxBuilder.mintShares(ctx, signer.publicKey, sharesValue, maxAmountValue)
|
|
200
|
+
];
|
|
201
|
+
return this.send(instructions, signer, options);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The idempotent ATA instruction, but only when the account is actually
|
|
205
|
+
* missing: including it unconditionally costs a few hundred compute units
|
|
206
|
+
* on every repeat deposit for nothing.
|
|
207
|
+
*/
|
|
208
|
+
async ensureShareAccount(ctx, owner) {
|
|
209
|
+
const ata = associatedTokenAddress(owner, ctx.shareMint, ctx.shareProgram);
|
|
210
|
+
const existing = await this.connection.getAccountInfo(ata);
|
|
211
|
+
if (existing !== null)
|
|
212
|
+
return [];
|
|
213
|
+
return [
|
|
214
|
+
SolanaTxBuilder.createAtaIdempotent(owner, owner, ctx.shareMint, ctx.shareProgram)
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
// --- withdrawals ---
|
|
218
|
+
/**
|
|
219
|
+
* Queues a withdrawal. This does NOT return collateral: the shares are
|
|
220
|
+
* escrowed and a request is appended to the vault's queue, which the
|
|
221
|
+
* operator settles later. Watch it with `getPendingWithdrawals`.
|
|
222
|
+
*
|
|
223
|
+
* The request's account is seeded by the owner's next nonce, which is read
|
|
224
|
+
* here — a caller passing a stale one would collide with their own earlier
|
|
225
|
+
* request.
|
|
226
|
+
*/
|
|
227
|
+
async redeemShares(shareMint, shares, options) {
|
|
228
|
+
const sharesValue = toBigInt(shares, "shares");
|
|
229
|
+
const signer = this.resolveSigner(options?.signer);
|
|
230
|
+
const ctx = await this.vaultContext(shareMint);
|
|
231
|
+
const account = await this.reader.getAccountState(ctx.shareMint, signer.publicKey);
|
|
232
|
+
// Null before the owner's first deposit or redeem, where the program
|
|
233
|
+
// creates the account and starts the nonce at 0.
|
|
234
|
+
const nonce = account?.nextRequestNonce ?? 0n;
|
|
235
|
+
const receiver = options?.receiver
|
|
236
|
+
? this.key(options.receiver)
|
|
237
|
+
: signer.publicKey;
|
|
238
|
+
return this.send([
|
|
239
|
+
SolanaTxBuilder.redeemShares(ctx, signer.publicKey, sharesValue, nonce, receiver)
|
|
240
|
+
], signer, options);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Cancels a queued withdrawal by its queue `sequenceNumber` — not the
|
|
244
|
+
* request nonce that seeds the request account. Take it from
|
|
245
|
+
* `getPendingWithdrawals`.
|
|
246
|
+
*/
|
|
247
|
+
async cancelPendingWithdrawalRequest(shareMint, sequenceNumber, options) {
|
|
248
|
+
const sequenceValue = toBigInt(sequenceNumber, "sequenceNumber");
|
|
249
|
+
const signer = this.resolveSigner(options?.signer);
|
|
250
|
+
const ctx = await this.vaultContext(shareMint);
|
|
251
|
+
return this.send([
|
|
252
|
+
SolanaTxBuilder.cancelPendingWithdrawalRequest(ctx, signer.publicKey, sequenceValue)
|
|
253
|
+
], signer, options);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* An owner's live queued withdrawals, each carrying the `sequenceNumber`
|
|
257
|
+
* that `cancelPendingWithdrawalRequest` takes.
|
|
258
|
+
*
|
|
259
|
+
* CANCELLED REQUESTS ARE EXCLUDED, and that filter is not cosmetic.
|
|
260
|
+
* `cancel_pending_withdrawal_request` does not remove the entry: it pushes
|
|
261
|
+
* the sequence number onto the account's `cancel_withdraw_request` list and
|
|
262
|
+
* leaves the request in place, for the operator to skip when it settles
|
|
263
|
+
* (see withdraw.rs). Returning the raw list would therefore show a
|
|
264
|
+
* withdrawal the user already cancelled as still pending, indefinitely.
|
|
265
|
+
*/
|
|
266
|
+
async getPendingWithdrawals(shareMint, owner) {
|
|
267
|
+
const account = await this.accountState(shareMint, owner);
|
|
268
|
+
if (!account)
|
|
269
|
+
return [];
|
|
270
|
+
const cancelled = new Set(account.cancelWithdrawRequest.map(String));
|
|
271
|
+
return account.pendingWithdrawalRequests.filter(request => !cancelled.has(String(request.sequenceNumber)));
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* The requests this owner has cancelled that the operator has not yet
|
|
275
|
+
* settled. They still occupy a slot on the account until it does.
|
|
276
|
+
*/
|
|
277
|
+
async getCancelledWithdrawals(shareMint, owner) {
|
|
278
|
+
const account = await this.accountState(shareMint, owner);
|
|
279
|
+
if (!account)
|
|
280
|
+
return [];
|
|
281
|
+
const cancelled = new Set(account.cancelWithdrawRequest.map(String));
|
|
282
|
+
return account.pendingWithdrawalRequests.filter(request => cancelled.has(String(request.sequenceNumber)));
|
|
283
|
+
}
|
|
284
|
+
async accountState(shareMint, owner) {
|
|
285
|
+
const address = owner ? this.key(owner) : this.resolveSigner().publicKey;
|
|
286
|
+
return this.reader.getAccountState(shareMint, address);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./solana-signer.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./solana-signer.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Keypair, PublicKey, type Transaction } from "@solana/web3.js";
|
|
2
|
+
/**
|
|
3
|
+
* The browser-wallet shape: Phantom, Solflare, Backpack and the
|
|
4
|
+
* `@solana/wallet-adapter` wallets all satisfy this.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately structural rather than an import of the wallet-adapter types:
|
|
7
|
+
* taking that dependency would pull a React-oriented package into a library
|
|
8
|
+
* that otherwise needs only web3.js, and every adapter already matches this.
|
|
9
|
+
*/
|
|
10
|
+
export interface ISolanaWalletAdapter {
|
|
11
|
+
/** Null while the wallet is disconnected. */
|
|
12
|
+
publicKey: PublicKey | null;
|
|
13
|
+
signTransaction(tx: Transaction): Promise<Transaction>;
|
|
14
|
+
/** Present on most adapters; unused here, declared so they still match. */
|
|
15
|
+
signAllTransactions?(txs: Transaction[]): Promise<Transaction[]>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Either shape works wherever the SDK signs: a raw `Keypair` for scripts, ops
|
|
19
|
+
* tooling and tests, or a connected wallet for a browser app.
|
|
20
|
+
*/
|
|
21
|
+
export type SolanaSigner = Keypair | ISolanaWalletAdapter;
|
|
22
|
+
/** What the call layer actually uses, once the two shapes are collapsed. */
|
|
23
|
+
export interface INormalisedSolanaSigner {
|
|
24
|
+
publicKey: PublicKey;
|
|
25
|
+
signTransaction(tx: Transaction): Promise<Transaction>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Duck-typed, NOT `instanceof Keypair`.
|
|
29
|
+
*
|
|
30
|
+
* A dApp frequently ends up with two copies of @solana/web3.js in its tree —
|
|
31
|
+
* its own and the SDK's, or two versions hoisted side by side — and a `Keypair`
|
|
32
|
+
* built against one copy fails `instanceof` against the other. That failure
|
|
33
|
+
* mode is silent and maddening to debug, so the check is on shape instead:
|
|
34
|
+
* only an adapter has `signTransaction`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isWalletAdapter(signer: SolanaSigner): signer is ISolanaWalletAdapter;
|
|
37
|
+
/**
|
|
38
|
+
* Collapses either shape into one signer.
|
|
39
|
+
*
|
|
40
|
+
* A disconnected wallet is rejected here rather than later: its `publicKey` is
|
|
41
|
+
* null, which would otherwise surface as a confusing failure deep inside
|
|
42
|
+
* instruction building, after the caller has already paid for account reads.
|
|
43
|
+
*/
|
|
44
|
+
export declare function normaliseSigner(signer: SolanaSigner): INormalisedSolanaSigner;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duck-typed, NOT `instanceof Keypair`.
|
|
3
|
+
*
|
|
4
|
+
* A dApp frequently ends up with two copies of @solana/web3.js in its tree —
|
|
5
|
+
* its own and the SDK's, or two versions hoisted side by side — and a `Keypair`
|
|
6
|
+
* built against one copy fails `instanceof` against the other. That failure
|
|
7
|
+
* mode is silent and maddening to debug, so the check is on shape instead:
|
|
8
|
+
* only an adapter has `signTransaction`.
|
|
9
|
+
*/
|
|
10
|
+
export function isWalletAdapter(signer) {
|
|
11
|
+
return typeof signer.signTransaction === "function";
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The inverse check, structural throughout.
|
|
15
|
+
*
|
|
16
|
+
* `publicKey instanceof PublicKey` would defeat the whole point of the check
|
|
17
|
+
* above: a Keypair from another copy of web3.js carries THAT copy's PublicKey,
|
|
18
|
+
* so the duplicate-package hazard would simply move here and reject a perfectly
|
|
19
|
+
* good signer. It is checked by the methods this SDK actually calls on it.
|
|
20
|
+
*/
|
|
21
|
+
function isKeypairLike(signer) {
|
|
22
|
+
const candidate = signer;
|
|
23
|
+
const key = candidate.publicKey;
|
|
24
|
+
return (candidate.secretKey instanceof Uint8Array &&
|
|
25
|
+
key !== null &&
|
|
26
|
+
key !== undefined &&
|
|
27
|
+
typeof key.toBase58 === "function" &&
|
|
28
|
+
typeof key.toBuffer === "function");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Collapses either shape into one signer.
|
|
32
|
+
*
|
|
33
|
+
* A disconnected wallet is rejected here rather than later: its `publicKey` is
|
|
34
|
+
* null, which would otherwise surface as a confusing failure deep inside
|
|
35
|
+
* instruction building, after the caller has already paid for account reads.
|
|
36
|
+
*/
|
|
37
|
+
export function normaliseSigner(signer) {
|
|
38
|
+
if (isWalletAdapter(signer)) {
|
|
39
|
+
const { publicKey } = signer;
|
|
40
|
+
if (publicKey === null || publicKey === undefined) {
|
|
41
|
+
throw new Error("wallet is not connected: its publicKey is null. Connect it before signing.");
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
publicKey,
|
|
45
|
+
signTransaction: (tx) => signer.signTransaction(tx)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (isKeypairLike(signer)) {
|
|
49
|
+
return {
|
|
50
|
+
publicKey: signer.publicKey,
|
|
51
|
+
// partialSign rather than sign: a transaction may legitimately need
|
|
52
|
+
// other signatures too (a fresh mint keypair, a fee payer), and
|
|
53
|
+
// `sign` would discard any already collected.
|
|
54
|
+
signTransaction: async (tx) => {
|
|
55
|
+
tx.partialSign(signer);
|
|
56
|
+
return tx;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
throw new Error("unsupported signer: pass a Keypair, or a wallet exposing publicKey and signTransaction");
|
|
61
|
+
}
|
|
@@ -2,6 +2,15 @@ import { PublicKey } from "@solana/web3.js";
|
|
|
2
2
|
import { type ISolanaVaultPdas } from "../interfaces/index.js";
|
|
3
3
|
/** Little-endian u64, for the nonce and sequence-number seeds. */
|
|
4
4
|
export declare function u64le(v: bigint | number): Buffer;
|
|
5
|
+
/**
|
|
6
|
+
* A u128 as 16 little-endian bytes: the program's sequence numbers.
|
|
7
|
+
*
|
|
8
|
+
* Range-checked, because the masking loop truncates silently otherwise:
|
|
9
|
+
* `(1n << 128n) + 6n` and `6n` encode to identical bytes, so an out-of-range
|
|
10
|
+
* sequence number would cancel request 6 rather than fail. `u64le` gets this
|
|
11
|
+
* for free from `writeBigUInt64LE`, which throws.
|
|
12
|
+
*/
|
|
13
|
+
export declare function u128le(v: bigint | number): Buffer;
|
|
5
14
|
/** The protocol-wide config. One per program id, and it can never be closed. */
|
|
6
15
|
export declare const deriveProtocolConfig: (programId: PublicKey) => PublicKey;
|
|
7
16
|
/**
|
|
@@ -20,6 +20,27 @@ export function u64le(v) {
|
|
|
20
20
|
b.writeBigUInt64LE(BigInt(v));
|
|
21
21
|
return b;
|
|
22
22
|
}
|
|
23
|
+
const U128_MAX = (1n << 128n) - 1n;
|
|
24
|
+
/**
|
|
25
|
+
* A u128 as 16 little-endian bytes: the program's sequence numbers.
|
|
26
|
+
*
|
|
27
|
+
* Range-checked, because the masking loop truncates silently otherwise:
|
|
28
|
+
* `(1n << 128n) + 6n` and `6n` encode to identical bytes, so an out-of-range
|
|
29
|
+
* sequence number would cancel request 6 rather than fail. `u64le` gets this
|
|
30
|
+
* for free from `writeBigUInt64LE`, which throws.
|
|
31
|
+
*/
|
|
32
|
+
export function u128le(v) {
|
|
33
|
+
let value = BigInt(v);
|
|
34
|
+
if (value < 0n || value > U128_MAX) {
|
|
35
|
+
throw new RangeError(`value ${value} is out of range for a u128`);
|
|
36
|
+
}
|
|
37
|
+
const buf = Buffer.alloc(16);
|
|
38
|
+
for (let i = 0; i < 16; i++) {
|
|
39
|
+
buf[i] = Number(value & 0xffn);
|
|
40
|
+
value >>= 8n;
|
|
41
|
+
}
|
|
42
|
+
return buf;
|
|
43
|
+
}
|
|
23
44
|
/** The protocol-wide config. One per program id, and it can never be closed. */
|
|
24
45
|
export const deriveProtocolConfig = (programId) => PublicKey.findProgramAddressSync([seed(SOLANA_SEEDS.protocolConfig)], programId)[0];
|
|
25
46
|
/**
|