@t2000/sdk 0.17.22 → 0.17.24
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/README.md +1 -1
- package/dist/adapters/cetus.d.ts +29 -0
- package/dist/adapters/cetus.d.ts.map +1 -0
- package/dist/adapters/cetus.js +74 -0
- package/dist/adapters/cetus.js.map +1 -0
- package/dist/adapters/cetus.test.d.ts +2 -0
- package/dist/adapters/cetus.test.d.ts.map +1 -0
- package/dist/adapters/cetus.test.js +57 -0
- package/dist/adapters/cetus.test.js.map +1 -0
- package/dist/adapters/compliance.test.d.ts +8 -0
- package/dist/adapters/compliance.test.d.ts.map +1 -0
- package/dist/adapters/compliance.test.js +202 -0
- package/dist/adapters/compliance.test.js.map +1 -0
- package/dist/adapters/index.d.ts +10 -4
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +15 -2107
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/navi.d.ts +41 -0
- package/dist/adapters/navi.d.ts.map +1 -0
- package/dist/adapters/navi.js +102 -0
- package/dist/adapters/navi.js.map +1 -0
- package/dist/adapters/navi.test.d.ts +2 -0
- package/dist/adapters/navi.test.d.ts.map +1 -0
- package/dist/adapters/navi.test.js +164 -0
- package/dist/adapters/navi.test.js.map +1 -0
- package/dist/adapters/registry.d.ts +47 -0
- package/dist/adapters/registry.d.ts.map +1 -0
- package/dist/adapters/registry.js +162 -0
- package/dist/adapters/registry.js.map +1 -0
- package/dist/adapters/registry.test.d.ts +2 -0
- package/dist/adapters/registry.test.d.ts.map +1 -0
- package/dist/adapters/registry.test.js +197 -0
- package/dist/adapters/registry.test.js.map +1 -0
- package/dist/adapters/suilend.d.ts +71 -0
- package/dist/adapters/suilend.d.ts.map +1 -0
- package/dist/adapters/suilend.js +826 -0
- package/dist/adapters/suilend.js.map +1 -0
- package/dist/adapters/suilend.test.d.ts +2 -0
- package/dist/adapters/suilend.test.d.ts.map +1 -0
- package/dist/adapters/suilend.test.js +294 -0
- package/dist/adapters/suilend.test.js.map +1 -0
- package/dist/adapters/types.d.ts +160 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +2 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/auto-invest.d.ts +23 -0
- package/dist/auto-invest.d.ts.map +1 -0
- package/dist/auto-invest.js +131 -0
- package/dist/auto-invest.js.map +1 -0
- package/dist/auto-invest.test.d.ts +2 -0
- package/dist/auto-invest.test.d.ts.map +1 -0
- package/dist/auto-invest.test.js +220 -0
- package/dist/auto-invest.test.js.map +1 -0
- package/dist/constants.d.ts +177 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +135 -0
- package/dist/constants.js.map +1 -0
- package/dist/contacts.d.ts +25 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +83 -0
- package/dist/contacts.js.map +1 -0
- package/dist/contacts.test.d.ts +2 -0
- package/dist/contacts.test.d.ts.map +1 -0
- package/dist/contacts.test.js +164 -0
- package/dist/contacts.test.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +81 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.test.d.ts +2 -0
- package/dist/errors.test.d.ts.map +1 -0
- package/dist/errors.test.js +48 -0
- package/dist/errors.test.js.map +1 -0
- package/dist/gas/autoTopUp.d.ts +18 -0
- package/dist/gas/autoTopUp.d.ts.map +1 -0
- package/dist/gas/autoTopUp.js +55 -0
- package/dist/gas/autoTopUp.js.map +1 -0
- package/dist/gas/autoTopUp.test.d.ts +2 -0
- package/dist/gas/autoTopUp.test.d.ts.map +1 -0
- package/dist/gas/autoTopUp.test.js +59 -0
- package/dist/gas/autoTopUp.test.js.map +1 -0
- package/dist/gas/gasStation.d.ts +23 -0
- package/dist/gas/gasStation.d.ts.map +1 -0
- package/dist/gas/gasStation.js +58 -0
- package/dist/gas/gasStation.js.map +1 -0
- package/dist/gas/index.d.ts +4 -0
- package/dist/gas/index.d.ts.map +1 -0
- package/dist/gas/index.js +4 -0
- package/dist/gas/index.js.map +1 -0
- package/dist/gas/manager.d.ts +24 -0
- package/dist/gas/manager.d.ts.map +1 -0
- package/dist/gas/manager.js +142 -0
- package/dist/gas/manager.js.map +1 -0
- package/dist/gas/manager.test.d.ts +2 -0
- package/dist/gas/manager.test.d.ts.map +1 -0
- package/dist/gas/manager.test.js +220 -0
- package/dist/gas/manager.test.js.map +1 -0
- package/dist/gas/serialization.test.d.ts +2 -0
- package/dist/gas/serialization.test.d.ts.map +1 -0
- package/dist/gas/serialization.test.js +47 -0
- package/dist/gas/serialization.test.js.map +1 -0
- package/dist/index.d.ts +30 -649
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -6053
- package/dist/index.js.map +1 -1
- package/dist/invest.test.d.ts +2 -0
- package/dist/invest.test.d.ts.map +1 -0
- package/dist/invest.test.js +256 -0
- package/dist/invest.test.js.map +1 -0
- package/dist/portfolio.d.ts +39 -0
- package/dist/portfolio.d.ts.map +1 -0
- package/dist/portfolio.js +201 -0
- package/dist/portfolio.js.map +1 -0
- package/dist/portfolio.test.d.ts +2 -0
- package/dist/portfolio.test.d.ts.map +1 -0
- package/dist/portfolio.test.js +301 -0
- package/dist/portfolio.test.js.map +1 -0
- package/dist/protocols/cetus.d.ts +73 -0
- package/dist/protocols/cetus.d.ts.map +1 -0
- package/dist/protocols/cetus.js +267 -0
- package/dist/protocols/cetus.js.map +1 -0
- package/dist/protocols/cetus.test.d.ts +2 -0
- package/dist/protocols/cetus.test.d.ts.map +1 -0
- package/dist/protocols/cetus.test.js +325 -0
- package/dist/protocols/cetus.test.js.map +1 -0
- package/dist/protocols/navi.d.ts +59 -0
- package/dist/protocols/navi.d.ts.map +1 -0
- package/dist/protocols/navi.js +945 -0
- package/dist/protocols/navi.js.map +1 -0
- package/dist/protocols/navi.test.d.ts +2 -0
- package/dist/protocols/navi.test.d.ts.map +1 -0
- package/dist/protocols/navi.test.js +339 -0
- package/dist/protocols/navi.test.js.map +1 -0
- package/dist/protocols/protocolFee.d.ts +17 -0
- package/dist/protocols/protocolFee.d.ts.map +1 -0
- package/dist/protocols/protocolFee.js +62 -0
- package/dist/protocols/protocolFee.js.map +1 -0
- package/dist/protocols/protocolFee.test.d.ts +2 -0
- package/dist/protocols/protocolFee.test.d.ts.map +1 -0
- package/dist/protocols/protocolFee.test.js +137 -0
- package/dist/protocols/protocolFee.test.js.map +1 -0
- package/dist/protocols/sentinel.d.ts +18 -0
- package/dist/protocols/sentinel.d.ts.map +1 -0
- package/dist/protocols/sentinel.js +188 -0
- package/dist/protocols/sentinel.js.map +1 -0
- package/dist/protocols/sentinel.test.d.ts +2 -0
- package/dist/protocols/sentinel.test.d.ts.map +1 -0
- package/dist/protocols/sentinel.test.js +199 -0
- package/dist/protocols/sentinel.test.js.map +1 -0
- package/dist/protocols/yieldTracker.d.ts +6 -0
- package/dist/protocols/yieldTracker.d.ts.map +1 -0
- package/dist/protocols/yieldTracker.js +29 -0
- package/dist/protocols/yieldTracker.js.map +1 -0
- package/dist/safeguards/enforcer.d.ts +18 -0
- package/dist/safeguards/enforcer.d.ts.map +1 -0
- package/dist/safeguards/enforcer.js +130 -0
- package/dist/safeguards/enforcer.js.map +1 -0
- package/dist/safeguards/enforcer.test.d.ts +2 -0
- package/dist/safeguards/enforcer.test.d.ts.map +1 -0
- package/dist/safeguards/enforcer.test.js +212 -0
- package/dist/safeguards/enforcer.test.js.map +1 -0
- package/dist/safeguards/errors.d.ts +24 -0
- package/dist/safeguards/errors.d.ts.map +1 -0
- package/dist/safeguards/errors.js +31 -0
- package/dist/safeguards/errors.js.map +1 -0
- package/dist/safeguards/index.d.ts +6 -0
- package/dist/safeguards/index.d.ts.map +1 -0
- package/dist/safeguards/index.js +4 -0
- package/dist/safeguards/index.js.map +1 -0
- package/dist/safeguards/types.d.ts +16 -0
- package/dist/safeguards/types.d.ts.map +1 -0
- package/dist/safeguards/types.js +13 -0
- package/dist/safeguards/types.js.map +1 -0
- package/dist/strategy.d.ts +22 -0
- package/dist/strategy.d.ts.map +1 -0
- package/dist/strategy.js +113 -0
- package/dist/strategy.js.map +1 -0
- package/dist/strategy.test.d.ts +2 -0
- package/dist/strategy.test.d.ts.map +1 -0
- package/dist/strategy.test.js +212 -0
- package/dist/strategy.test.js.map +1 -0
- package/dist/t2000.d.ts +218 -0
- package/dist/t2000.d.ts.map +1 -0
- package/dist/t2000.integration.test.d.ts +2 -0
- package/dist/t2000.integration.test.d.ts.map +1 -0
- package/dist/t2000.integration.test.js +954 -0
- package/dist/t2000.integration.test.js.map +1 -0
- package/dist/t2000.js +2395 -0
- package/dist/t2000.js.map +1 -0
- package/dist/types.d.ts +419 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/format.d.ts +22 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +71 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/format.test.d.ts +2 -0
- package/dist/utils/format.test.d.ts.map +1 -0
- package/dist/utils/format.test.js +187 -0
- package/dist/utils/format.test.js.map +1 -0
- package/dist/utils/hashcash.d.ts +2 -0
- package/dist/utils/hashcash.d.ts.map +1 -0
- package/dist/utils/hashcash.js +27 -0
- package/dist/utils/hashcash.js.map +1 -0
- package/dist/utils/hashcash.test.d.ts +2 -0
- package/dist/utils/hashcash.test.d.ts.map +1 -0
- package/dist/utils/hashcash.test.js +40 -0
- package/dist/utils/hashcash.test.js.map +1 -0
- package/dist/utils/retry.d.ts +9 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +47 -0
- package/dist/utils/retry.js.map +1 -0
- package/dist/utils/simulate.d.ts +15 -0
- package/dist/utils/simulate.d.ts.map +1 -0
- package/dist/utils/simulate.js +75 -0
- package/dist/utils/simulate.js.map +1 -0
- package/dist/utils/simulate.test.d.ts +2 -0
- package/dist/utils/simulate.test.d.ts.map +1 -0
- package/dist/utils/simulate.test.js +80 -0
- package/dist/utils/simulate.test.js.map +1 -0
- package/dist/utils/sui.d.ts +6 -0
- package/dist/utils/sui.d.ts.map +1 -0
- package/dist/utils/sui.js +28 -0
- package/dist/utils/sui.js.map +1 -0
- package/dist/utils/sui.test.d.ts +2 -0
- package/dist/utils/sui.test.d.ts.map +1 -0
- package/dist/utils/sui.test.js +58 -0
- package/dist/utils/sui.test.js.map +1 -0
- package/dist/wallet/balance.d.ts +4 -0
- package/dist/wallet/balance.d.ts.map +1 -0
- package/dist/wallet/balance.js +98 -0
- package/dist/wallet/balance.js.map +1 -0
- package/dist/wallet/history.d.ts +4 -0
- package/dist/wallet/history.d.ts.map +1 -0
- package/dist/wallet/history.js +38 -0
- package/dist/wallet/history.js.map +1 -0
- package/dist/wallet/keyManager.d.ts +9 -0
- package/dist/wallet/keyManager.d.ts.map +1 -0
- package/dist/wallet/keyManager.js +113 -0
- package/dist/wallet/keyManager.js.map +1 -0
- package/dist/wallet/keyManager.test.d.ts +2 -0
- package/dist/wallet/keyManager.test.d.ts.map +1 -0
- package/dist/wallet/keyManager.test.js +55 -0
- package/dist/wallet/keyManager.test.js.map +1 -0
- package/dist/wallet/send.d.ts +24 -0
- package/dist/wallet/send.d.ts.map +1 -0
- package/dist/wallet/send.js +95 -0
- package/dist/wallet/send.js.map +1 -0
- package/dist/wallet/send.test.d.ts +2 -0
- package/dist/wallet/send.test.d.ts.map +1 -0
- package/dist/wallet/send.test.js +69 -0
- package/dist/wallet/send.test.js.map +1 -0
- package/package.json +1 -1
package/dist/t2000.js
ADDED
|
@@ -0,0 +1,2395 @@
|
|
|
1
|
+
import { EventEmitter } from 'eventemitter3';
|
|
2
|
+
import { Transaction } from '@mysten/sui/transactions';
|
|
3
|
+
import { getSuiClient } from './utils/sui.js';
|
|
4
|
+
import { generateKeypair, keypairFromPrivateKey, saveKey, loadKey, walletExists, exportPrivateKey, getAddress, } from './wallet/keyManager.js';
|
|
5
|
+
import { buildSendTx } from './wallet/send.js';
|
|
6
|
+
import { queryBalance } from './wallet/balance.js';
|
|
7
|
+
import { queryHistory } from './wallet/history.js';
|
|
8
|
+
import { calculateFee, reportFee } from './protocols/protocolFee.js';
|
|
9
|
+
import * as yieldTracker from './protocols/yieldTracker.js';
|
|
10
|
+
import * as sentinel from './protocols/sentinel.js';
|
|
11
|
+
import { ProtocolRegistry } from './adapters/registry.js';
|
|
12
|
+
import { NaviAdapter } from './adapters/navi.js';
|
|
13
|
+
import { CetusAdapter } from './adapters/cetus.js';
|
|
14
|
+
import { buildRawSwapTx } from './protocols/cetus.js';
|
|
15
|
+
import { SuilendAdapter } from './adapters/suilend.js';
|
|
16
|
+
import { solveHashcash } from './utils/hashcash.js';
|
|
17
|
+
import { executeWithGas } from './gas/manager.js';
|
|
18
|
+
import { T2000Error } from './errors.js';
|
|
19
|
+
import { SUPPORTED_ASSETS, DEFAULT_NETWORK, API_BASE_URL, INVESTMENT_ASSETS, GAS_RESERVE_MIN } from './constants.js';
|
|
20
|
+
const LOW_LIQUIDITY_ASSETS = new Set(['GOLD']);
|
|
21
|
+
const REWARD_TOKEN_DECIMALS = {
|
|
22
|
+
'0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT': 9,
|
|
23
|
+
'0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP': 6,
|
|
24
|
+
'0x83556891f4a0f233ce7b05cfe7f957d4020492a34f5405b2cb9377d060bef4bf::spring_sui::SPRING_SUI': 9,
|
|
25
|
+
};
|
|
26
|
+
function defaultSlippage(asset) {
|
|
27
|
+
return LOW_LIQUIDITY_ASSETS.has(asset) ? 0.05 : 0.03;
|
|
28
|
+
}
|
|
29
|
+
import { truncateAddress } from './utils/sui.js';
|
|
30
|
+
import { SafeguardEnforcer } from './safeguards/enforcer.js';
|
|
31
|
+
import { ContactManager } from './contacts.js';
|
|
32
|
+
import { PortfolioManager } from './portfolio.js';
|
|
33
|
+
import { StrategyManager } from './strategy.js';
|
|
34
|
+
import { AutoInvestManager } from './auto-invest.js';
|
|
35
|
+
import { homedir } from 'node:os';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
const DEFAULT_CONFIG_DIR = join(homedir(), '.t2000');
|
|
38
|
+
export class T2000 extends EventEmitter {
|
|
39
|
+
keypair;
|
|
40
|
+
client;
|
|
41
|
+
_address;
|
|
42
|
+
registry;
|
|
43
|
+
enforcer;
|
|
44
|
+
contacts;
|
|
45
|
+
portfolio;
|
|
46
|
+
strategies;
|
|
47
|
+
autoInvest;
|
|
48
|
+
constructor(keypair, client, registry, configDir) {
|
|
49
|
+
super();
|
|
50
|
+
this.keypair = keypair;
|
|
51
|
+
this.client = client;
|
|
52
|
+
this._address = getAddress(keypair);
|
|
53
|
+
this.registry = registry ?? T2000.createDefaultRegistry(client);
|
|
54
|
+
this.enforcer = new SafeguardEnforcer(configDir);
|
|
55
|
+
this.enforcer.load();
|
|
56
|
+
this.contacts = new ContactManager(configDir);
|
|
57
|
+
this.portfolio = new PortfolioManager(configDir);
|
|
58
|
+
this.strategies = new StrategyManager(configDir);
|
|
59
|
+
this.autoInvest = new AutoInvestManager(configDir);
|
|
60
|
+
}
|
|
61
|
+
static createDefaultRegistry(client) {
|
|
62
|
+
const registry = new ProtocolRegistry();
|
|
63
|
+
const naviAdapter = new NaviAdapter();
|
|
64
|
+
naviAdapter.initSync(client);
|
|
65
|
+
registry.registerLending(naviAdapter);
|
|
66
|
+
const cetusAdapter = new CetusAdapter();
|
|
67
|
+
cetusAdapter.initSync(client);
|
|
68
|
+
registry.registerSwap(cetusAdapter);
|
|
69
|
+
const suilendAdapter = new SuilendAdapter();
|
|
70
|
+
suilendAdapter.initSync(client);
|
|
71
|
+
registry.registerLending(suilendAdapter);
|
|
72
|
+
return registry;
|
|
73
|
+
}
|
|
74
|
+
static async create(options = {}) {
|
|
75
|
+
const { keyPath, pin, passphrase, network = DEFAULT_NETWORK, rpcUrl, sponsored, name } = options;
|
|
76
|
+
const secret = pin ?? passphrase;
|
|
77
|
+
const client = getSuiClient(rpcUrl);
|
|
78
|
+
if (sponsored) {
|
|
79
|
+
const keypair = generateKeypair();
|
|
80
|
+
if (secret) {
|
|
81
|
+
await saveKey(keypair, secret, keyPath);
|
|
82
|
+
}
|
|
83
|
+
return new T2000(keypair, client, undefined, DEFAULT_CONFIG_DIR);
|
|
84
|
+
}
|
|
85
|
+
const exists = await walletExists(keyPath);
|
|
86
|
+
if (!exists) {
|
|
87
|
+
throw new T2000Error('WALLET_NOT_FOUND', 'No wallet found. Run `t2000 init` to create one.');
|
|
88
|
+
}
|
|
89
|
+
if (!secret) {
|
|
90
|
+
throw new T2000Error('WALLET_LOCKED', 'PIN required to unlock wallet');
|
|
91
|
+
}
|
|
92
|
+
const keypair = await loadKey(secret, keyPath);
|
|
93
|
+
return new T2000(keypair, client, undefined, DEFAULT_CONFIG_DIR);
|
|
94
|
+
}
|
|
95
|
+
static fromPrivateKey(privateKey, options = {}) {
|
|
96
|
+
const keypair = keypairFromPrivateKey(privateKey);
|
|
97
|
+
const client = getSuiClient(options.rpcUrl);
|
|
98
|
+
return new T2000(keypair, client);
|
|
99
|
+
}
|
|
100
|
+
static async init(options) {
|
|
101
|
+
const secret = options.pin ?? options.passphrase ?? '';
|
|
102
|
+
const keypair = generateKeypair();
|
|
103
|
+
await saveKey(keypair, secret, options.keyPath);
|
|
104
|
+
const client = getSuiClient();
|
|
105
|
+
const agent = new T2000(keypair, client, undefined, DEFAULT_CONFIG_DIR);
|
|
106
|
+
const address = agent.address();
|
|
107
|
+
let sponsored = false;
|
|
108
|
+
if (options.sponsored !== false) {
|
|
109
|
+
try {
|
|
110
|
+
await callSponsorApi(address, options.name);
|
|
111
|
+
sponsored = true;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Sponsor unavailable — agent can still be funded manually
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { agent, address, sponsored };
|
|
118
|
+
}
|
|
119
|
+
// -- Gas --
|
|
120
|
+
/** SuiJsonRpcClient used by this agent — exposed for x402 and other integrations. */
|
|
121
|
+
get suiClient() {
|
|
122
|
+
return this.client;
|
|
123
|
+
}
|
|
124
|
+
/** Ed25519Keypair used by this agent — exposed for x402 and other integrations. */
|
|
125
|
+
get signer() {
|
|
126
|
+
return this.keypair;
|
|
127
|
+
}
|
|
128
|
+
// -- Wallet --
|
|
129
|
+
address() {
|
|
130
|
+
return this._address;
|
|
131
|
+
}
|
|
132
|
+
async send(params) {
|
|
133
|
+
this.enforcer.assertNotLocked();
|
|
134
|
+
const asset = (params.asset ?? 'USDC');
|
|
135
|
+
if (!(asset in SUPPORTED_ASSETS)) {
|
|
136
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `Asset ${asset} is not supported`);
|
|
137
|
+
}
|
|
138
|
+
if (asset in INVESTMENT_ASSETS) {
|
|
139
|
+
const free = await this.getFreeBalance(asset);
|
|
140
|
+
if (params.amount > free) {
|
|
141
|
+
const pos = this.portfolio.getPosition(asset);
|
|
142
|
+
const invested = pos?.totalAmount ?? 0;
|
|
143
|
+
throw new T2000Error('INVESTMENT_LOCKED', `Cannot send ${params.amount} ${asset} — ${invested.toFixed(4)} ${asset} is invested. Free ${asset}: ${free.toFixed(4)}\nTo access invested funds: t2000 invest sell ${params.amount} ${asset}`, { free, invested, requested: params.amount });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const resolved = this.contacts.resolve(params.to);
|
|
147
|
+
const sendAmount = params.amount;
|
|
148
|
+
const sendTo = resolved.address;
|
|
149
|
+
const gasResult = await executeWithGas(this.client, this.keypair, () => buildSendTx({ client: this.client, address: this._address, to: sendTo, amount: sendAmount, asset }), { metadata: { operation: 'send', amount: sendAmount }, enforcer: this.enforcer });
|
|
150
|
+
this.enforcer.recordUsage(sendAmount);
|
|
151
|
+
const balance = await this.balance();
|
|
152
|
+
this.emitBalanceChange(asset, sendAmount, 'send', gasResult.digest);
|
|
153
|
+
return {
|
|
154
|
+
success: true,
|
|
155
|
+
tx: gasResult.digest,
|
|
156
|
+
amount: sendAmount,
|
|
157
|
+
to: resolved.address,
|
|
158
|
+
contactName: resolved.contactName,
|
|
159
|
+
gasCost: gasResult.gasCostSui,
|
|
160
|
+
gasCostUnit: 'SUI',
|
|
161
|
+
gasMethod: gasResult.gasMethod,
|
|
162
|
+
balance,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async balance() {
|
|
166
|
+
const bal = await queryBalance(this.client, this._address);
|
|
167
|
+
const earningAssets = new Set(this.portfolio.getPositions().filter(p => p.earning).map(p => p.asset));
|
|
168
|
+
try {
|
|
169
|
+
const positions = await this.positions();
|
|
170
|
+
const savings = positions.positions
|
|
171
|
+
.filter((p) => p.type === 'save')
|
|
172
|
+
.filter((p) => !earningAssets.has(p.asset))
|
|
173
|
+
.reduce((sum, p) => sum + p.amount, 0);
|
|
174
|
+
const debt = positions.positions
|
|
175
|
+
.filter((p) => p.type === 'borrow')
|
|
176
|
+
.reduce((sum, p) => sum + p.amount, 0);
|
|
177
|
+
bal.savings = savings;
|
|
178
|
+
bal.debt = debt;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// NAVI unavailable — show basic balance
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const portfolioPositions = this.portfolio.getPositions();
|
|
185
|
+
const suiPrice = bal.gasReserve.sui > 0
|
|
186
|
+
? bal.gasReserve.usdEquiv / bal.gasReserve.sui
|
|
187
|
+
: 0;
|
|
188
|
+
const assetPrices = { SUI: suiPrice };
|
|
189
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
190
|
+
// Collect all invested assets (direct + strategy) to fetch prices
|
|
191
|
+
const investedAssets = new Set();
|
|
192
|
+
for (const pos of portfolioPositions) {
|
|
193
|
+
if (pos.asset in INVESTMENT_ASSETS)
|
|
194
|
+
investedAssets.add(pos.asset);
|
|
195
|
+
}
|
|
196
|
+
for (const key of this.portfolio.getAllStrategyKeys()) {
|
|
197
|
+
for (const sp of this.portfolio.getStrategyPositions(key)) {
|
|
198
|
+
if (sp.asset in INVESTMENT_ASSETS)
|
|
199
|
+
investedAssets.add(sp.asset);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const asset of investedAssets) {
|
|
203
|
+
if (asset === 'SUI' || asset in assetPrices)
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
if (swapAdapter) {
|
|
207
|
+
const quote = await swapAdapter.getQuote('USDC', asset, 1);
|
|
208
|
+
assetPrices[asset] = quote.expectedOutput > 0 ? 1 / quote.expectedOutput : 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
assetPrices[asset] = 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
let investmentValue = 0;
|
|
216
|
+
let investmentCostBasis = 0;
|
|
217
|
+
let trackedValue = 0;
|
|
218
|
+
// Aggregate tracked amounts and cost basis per asset across direct + strategy positions
|
|
219
|
+
const trackedAmounts = {};
|
|
220
|
+
const trackedCostBasis = {};
|
|
221
|
+
const earningAssetSet = new Set();
|
|
222
|
+
for (const pos of portfolioPositions) {
|
|
223
|
+
if (!(pos.asset in INVESTMENT_ASSETS))
|
|
224
|
+
continue;
|
|
225
|
+
trackedAmounts[pos.asset] = (trackedAmounts[pos.asset] ?? 0) + pos.totalAmount;
|
|
226
|
+
trackedCostBasis[pos.asset] = (trackedCostBasis[pos.asset] ?? 0) + pos.costBasis;
|
|
227
|
+
if (pos.earning)
|
|
228
|
+
earningAssetSet.add(pos.asset);
|
|
229
|
+
}
|
|
230
|
+
for (const key of this.portfolio.getAllStrategyKeys()) {
|
|
231
|
+
for (const sp of this.portfolio.getStrategyPositions(key)) {
|
|
232
|
+
if (!(sp.asset in INVESTMENT_ASSETS))
|
|
233
|
+
continue;
|
|
234
|
+
trackedAmounts[sp.asset] = (trackedAmounts[sp.asset] ?? 0) + sp.totalAmount;
|
|
235
|
+
trackedCostBasis[sp.asset] = (trackedCostBasis[sp.asset] ?? 0) + sp.costBasis;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
for (const asset of Object.keys(INVESTMENT_ASSETS)) {
|
|
239
|
+
const price = assetPrices[asset] ?? 0;
|
|
240
|
+
const tracked = trackedAmounts[asset] ?? 0;
|
|
241
|
+
const costBasis = trackedCostBasis[asset] ?? 0;
|
|
242
|
+
if (asset === 'SUI') {
|
|
243
|
+
const actualSui = earningAssetSet.has('SUI') ? tracked : Math.min(tracked, bal.gasReserve.sui);
|
|
244
|
+
investmentValue += actualSui * price;
|
|
245
|
+
trackedValue += actualSui * price;
|
|
246
|
+
if (actualSui < tracked && tracked > 0) {
|
|
247
|
+
investmentCostBasis += costBasis * (actualSui / tracked);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
investmentCostBasis += costBasis;
|
|
251
|
+
}
|
|
252
|
+
if (!earningAssetSet.has('SUI')) {
|
|
253
|
+
const gasSui = Math.max(0, bal.gasReserve.sui - tracked);
|
|
254
|
+
bal.gasReserve = { sui: gasSui, usdEquiv: gasSui * price };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
// Use on-chain balance for total value (balance accuracy)
|
|
259
|
+
// but tracked amount for P&L (so untracked tokens don't inflate P&L)
|
|
260
|
+
const onChainAmount = bal.assets[asset] ?? 0;
|
|
261
|
+
const effectiveAmount = Math.max(tracked, onChainAmount);
|
|
262
|
+
investmentValue += effectiveAmount * price;
|
|
263
|
+
trackedValue += tracked * price;
|
|
264
|
+
investmentCostBasis += costBasis;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
bal.investment = investmentValue;
|
|
268
|
+
bal.investmentPnL = trackedValue - investmentCostBasis;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
bal.investment = 0;
|
|
272
|
+
bal.investmentPnL = 0;
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const pendingRewards = await this.getPendingRewards();
|
|
276
|
+
bal.pendingRewards = pendingRewards.reduce((s, r) => s + r.estimatedValueUsd, 0);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
bal.pendingRewards = 0;
|
|
280
|
+
}
|
|
281
|
+
bal.total = bal.available + bal.savings - bal.debt + bal.investment + bal.gasReserve.usdEquiv;
|
|
282
|
+
return bal;
|
|
283
|
+
}
|
|
284
|
+
async history(params) {
|
|
285
|
+
return queryHistory(this.client, this._address, params?.limit);
|
|
286
|
+
}
|
|
287
|
+
async deposit() {
|
|
288
|
+
return {
|
|
289
|
+
address: this._address,
|
|
290
|
+
network: 'Sui (mainnet)',
|
|
291
|
+
supportedAssets: ['USDC'],
|
|
292
|
+
instructions: [
|
|
293
|
+
`Send USDC on Sui to: ${this._address}`,
|
|
294
|
+
'',
|
|
295
|
+
'From a CEX (Coinbase, Binance):',
|
|
296
|
+
` 1. Withdraw USDC`,
|
|
297
|
+
` 2. Select "Sui" network`,
|
|
298
|
+
` 3. Paste address: ${truncateAddress(this._address)}`,
|
|
299
|
+
'',
|
|
300
|
+
'From another Sui wallet:',
|
|
301
|
+
` Transfer USDC to ${truncateAddress(this._address)}`,
|
|
302
|
+
].join('\n'),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
exportKey() {
|
|
306
|
+
return exportPrivateKey(this.keypair);
|
|
307
|
+
}
|
|
308
|
+
async registerAdapter(adapter) {
|
|
309
|
+
await adapter.init(this.client);
|
|
310
|
+
if ('buildSaveTx' in adapter)
|
|
311
|
+
this.registry.registerLending(adapter);
|
|
312
|
+
if ('buildSwapTx' in adapter)
|
|
313
|
+
this.registry.registerSwap(adapter);
|
|
314
|
+
}
|
|
315
|
+
// -- Savings --
|
|
316
|
+
async save(params) {
|
|
317
|
+
this.enforcer.assertNotLocked();
|
|
318
|
+
const asset = 'USDC';
|
|
319
|
+
const bal = await queryBalance(this.client, this._address);
|
|
320
|
+
const usdcBalance = bal.stables.USDC ?? 0;
|
|
321
|
+
const needsAutoConvert = params.amount === 'all'
|
|
322
|
+
? Object.entries(bal.stables).some(([k, v]) => k !== 'USDC' && v > 0.01)
|
|
323
|
+
: typeof params.amount === 'number' && params.amount > usdcBalance;
|
|
324
|
+
let amount;
|
|
325
|
+
if (params.amount === 'all') {
|
|
326
|
+
amount = (bal.available ?? 0) - 1.0;
|
|
327
|
+
if (amount <= 0) {
|
|
328
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'Balance too low to save after $1 gas reserve', {
|
|
329
|
+
reason: 'gas_reserve_required', available: bal.available ?? 0,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
amount = params.amount;
|
|
335
|
+
if (amount > (bal.available ?? 0)) {
|
|
336
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', `Insufficient balance. Available: $${(bal.available ?? 0).toFixed(2)}, requested: $${amount.toFixed(2)}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const fee = calculateFee('save', amount);
|
|
340
|
+
const saveAmount = amount;
|
|
341
|
+
const adapter = await this.resolveLending(params.protocol, asset, 'save');
|
|
342
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
343
|
+
const canPTB = adapter.addSaveToTx && (!needsAutoConvert || swapAdapter?.addSwapToTx);
|
|
344
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
345
|
+
if (canPTB && needsAutoConvert) {
|
|
346
|
+
const tx = new Transaction();
|
|
347
|
+
tx.setSender(this._address);
|
|
348
|
+
const usdcCoins = [];
|
|
349
|
+
// Swap non-USDC stables → USDC within the same PTB
|
|
350
|
+
for (const [stableAsset, stableAmount] of Object.entries(bal.stables)) {
|
|
351
|
+
if (stableAsset === 'USDC' || stableAmount <= 0.01)
|
|
352
|
+
continue;
|
|
353
|
+
const assetInfo = SUPPORTED_ASSETS[stableAsset];
|
|
354
|
+
if (!assetInfo)
|
|
355
|
+
continue;
|
|
356
|
+
const coins = await this._fetchCoins(assetInfo.type);
|
|
357
|
+
if (coins.length === 0)
|
|
358
|
+
continue;
|
|
359
|
+
const merged = this._mergeCoinsInTx(tx, coins);
|
|
360
|
+
const { outputCoin } = await swapAdapter.addSwapToTx(tx, this._address, merged, stableAsset, 'USDC', stableAmount);
|
|
361
|
+
usdcCoins.push(outputCoin);
|
|
362
|
+
}
|
|
363
|
+
// Add existing wallet USDC
|
|
364
|
+
const existingUsdc = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
365
|
+
if (existingUsdc.length > 0) {
|
|
366
|
+
usdcCoins.push(this._mergeCoinsInTx(tx, existingUsdc));
|
|
367
|
+
}
|
|
368
|
+
// Merge all USDC into one coin
|
|
369
|
+
if (usdcCoins.length > 1) {
|
|
370
|
+
tx.mergeCoins(usdcCoins[0], usdcCoins.slice(1));
|
|
371
|
+
}
|
|
372
|
+
await adapter.addSaveToTx(tx, this._address, usdcCoins[0], asset, { collectFee: true });
|
|
373
|
+
return tx;
|
|
374
|
+
}
|
|
375
|
+
if (canPTB && !needsAutoConvert) {
|
|
376
|
+
const tx = new Transaction();
|
|
377
|
+
tx.setSender(this._address);
|
|
378
|
+
const existingUsdc = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
379
|
+
if (existingUsdc.length === 0)
|
|
380
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC coins found');
|
|
381
|
+
const merged = this._mergeCoinsInTx(tx, existingUsdc);
|
|
382
|
+
const rawAmount = BigInt(Math.floor(saveAmount * 10 ** SUPPORTED_ASSETS.USDC.decimals));
|
|
383
|
+
const [depositCoin] = tx.splitCoins(merged, [rawAmount]);
|
|
384
|
+
await adapter.addSaveToTx(tx, this._address, depositCoin, asset, { collectFee: true });
|
|
385
|
+
return tx;
|
|
386
|
+
}
|
|
387
|
+
// Fallback: non-composable path
|
|
388
|
+
if (needsAutoConvert) {
|
|
389
|
+
await this._convertWalletStablesToUsdc(bal, params.amount === 'all' ? undefined : amount - usdcBalance);
|
|
390
|
+
}
|
|
391
|
+
const { tx } = await adapter.buildSaveTx(this._address, saveAmount, asset, { collectFee: true });
|
|
392
|
+
return tx;
|
|
393
|
+
});
|
|
394
|
+
const rates = await adapter.getRates(asset);
|
|
395
|
+
reportFee(this._address, 'save', fee.amount, fee.rate, gasResult.digest);
|
|
396
|
+
this.emitBalanceChange(asset, saveAmount, 'save', gasResult.digest);
|
|
397
|
+
let savingsBalance = saveAmount;
|
|
398
|
+
try {
|
|
399
|
+
const positions = await this.positions();
|
|
400
|
+
savingsBalance = positions.positions
|
|
401
|
+
.filter((p) => p.type === 'save' && p.asset === asset)
|
|
402
|
+
.reduce((sum, p) => sum + p.amount, 0);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// query failed — fall back to deposit amount
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
success: true,
|
|
409
|
+
tx: gasResult.digest,
|
|
410
|
+
amount: saveAmount,
|
|
411
|
+
apy: rates.saveApy,
|
|
412
|
+
fee: fee.amount,
|
|
413
|
+
gasCost: gasResult.gasCostSui,
|
|
414
|
+
gasMethod: gasResult.gasMethod,
|
|
415
|
+
savingsBalance,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
async withdraw(params) {
|
|
419
|
+
this.enforcer.assertNotLocked();
|
|
420
|
+
if (params.amount === 'all' && !params.protocol) {
|
|
421
|
+
return this.withdrawAllProtocols();
|
|
422
|
+
}
|
|
423
|
+
// Find the actual position to withdraw from (may be non-USDC after rebalance)
|
|
424
|
+
// Only consider stablecoin savings — investment assets (SUI, ETH, BTC) are
|
|
425
|
+
// managed via invest sell/unearn and should not be touched by withdraw.
|
|
426
|
+
const allPositions = await this.registry.allPositions(this._address);
|
|
427
|
+
const supplies = [];
|
|
428
|
+
for (const pos of allPositions) {
|
|
429
|
+
if (params.protocol && pos.protocolId !== params.protocol)
|
|
430
|
+
continue;
|
|
431
|
+
for (const s of pos.positions.supplies) {
|
|
432
|
+
if (s.amount > 0.001 && !(s.asset in INVESTMENT_ASSETS)) {
|
|
433
|
+
supplies.push({ protocolId: pos.protocolId, asset: s.asset, amount: s.amount, apy: s.apy });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (supplies.length === 0) {
|
|
438
|
+
throw new T2000Error('NO_COLLATERAL', 'No savings to withdraw');
|
|
439
|
+
}
|
|
440
|
+
// Withdraw from lowest-APY position first
|
|
441
|
+
supplies.sort((a, b) => a.apy - b.apy);
|
|
442
|
+
const target = supplies[0];
|
|
443
|
+
const adapter = this.registry.getLending(target.protocolId);
|
|
444
|
+
if (!adapter)
|
|
445
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', `Protocol ${target.protocolId} not found`);
|
|
446
|
+
let amount;
|
|
447
|
+
if (params.amount === 'all') {
|
|
448
|
+
const maxResult = await adapter.maxWithdraw(this._address, target.asset);
|
|
449
|
+
amount = maxResult.maxAmount;
|
|
450
|
+
if (amount <= 0) {
|
|
451
|
+
throw new T2000Error('NO_COLLATERAL', 'No savings to withdraw');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
amount = params.amount;
|
|
456
|
+
const hf = await adapter.getHealth(this._address);
|
|
457
|
+
if (hf.borrowed > 0) {
|
|
458
|
+
const maxResult = await adapter.maxWithdraw(this._address, target.asset);
|
|
459
|
+
if (amount > maxResult.maxAmount) {
|
|
460
|
+
throw new T2000Error('WITHDRAW_WOULD_LIQUIDATE', `Withdrawing $${amount.toFixed(2)} would drop health factor below 1.5`, {
|
|
461
|
+
safeWithdrawAmount: maxResult.maxAmount,
|
|
462
|
+
currentHF: maxResult.currentHF,
|
|
463
|
+
projectedHF: maxResult.healthFactorAfter,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const withdrawAmount = amount;
|
|
469
|
+
let finalAmount = withdrawAmount;
|
|
470
|
+
const swapAdapter = target.asset !== 'USDC' ? this.registry.listSwap()[0] : undefined;
|
|
471
|
+
const canPTB = adapter.addWithdrawToTx && (!swapAdapter || swapAdapter.addSwapToTx);
|
|
472
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
473
|
+
if (canPTB) {
|
|
474
|
+
const tx = new Transaction();
|
|
475
|
+
tx.setSender(this._address);
|
|
476
|
+
const { coin, effectiveAmount } = await adapter.addWithdrawToTx(tx, this._address, withdrawAmount, target.asset);
|
|
477
|
+
finalAmount = effectiveAmount;
|
|
478
|
+
if (target.asset !== 'USDC' && swapAdapter?.addSwapToTx) {
|
|
479
|
+
const { outputCoin, estimatedOut, toDecimals } = await swapAdapter.addSwapToTx(tx, this._address, coin, target.asset, 'USDC', effectiveAmount);
|
|
480
|
+
finalAmount = estimatedOut / 10 ** toDecimals;
|
|
481
|
+
tx.transferObjects([outputCoin], this._address);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
tx.transferObjects([coin], this._address);
|
|
485
|
+
}
|
|
486
|
+
return tx;
|
|
487
|
+
}
|
|
488
|
+
const built = await adapter.buildWithdrawTx(this._address, withdrawAmount, target.asset);
|
|
489
|
+
finalAmount = built.effectiveAmount;
|
|
490
|
+
return built.tx;
|
|
491
|
+
});
|
|
492
|
+
this.emitBalanceChange('USDC', finalAmount, 'withdraw', gasResult.digest);
|
|
493
|
+
return {
|
|
494
|
+
success: true,
|
|
495
|
+
tx: gasResult.digest,
|
|
496
|
+
amount: finalAmount,
|
|
497
|
+
gasCost: gasResult.gasCostSui,
|
|
498
|
+
gasMethod: gasResult.gasMethod,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
async withdrawAllProtocols() {
|
|
502
|
+
const allPositions = await this.registry.allPositions(this._address);
|
|
503
|
+
// Skip positions that are investment-earning (managed via invest sell/unearn)
|
|
504
|
+
const earningAssets = new Set(this.portfolio.getPositions().filter(p => p.earning).map(p => p.asset));
|
|
505
|
+
const withdrawable = [];
|
|
506
|
+
for (const pos of allPositions) {
|
|
507
|
+
for (const supply of pos.positions.supplies) {
|
|
508
|
+
if (supply.amount > 0.01 && !earningAssets.has(supply.asset) && !(supply.asset in INVESTMENT_ASSETS)) {
|
|
509
|
+
withdrawable.push({ protocolId: pos.protocolId, asset: supply.asset, amount: supply.amount });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (withdrawable.length === 0) {
|
|
514
|
+
throw new T2000Error('NO_COLLATERAL', 'No savings to withdraw across any protocol');
|
|
515
|
+
}
|
|
516
|
+
// Pre-check maxWithdraw per protocol, then distribute across entries
|
|
517
|
+
const protocolMaxes = new Map();
|
|
518
|
+
const entries = [];
|
|
519
|
+
for (const entry of withdrawable) {
|
|
520
|
+
const adapter = this.registry.getLending(entry.protocolId);
|
|
521
|
+
if (!adapter)
|
|
522
|
+
continue;
|
|
523
|
+
if (!protocolMaxes.has(entry.protocolId)) {
|
|
524
|
+
const maxResult = await adapter.maxWithdraw(this._address, entry.asset);
|
|
525
|
+
protocolMaxes.set(entry.protocolId, maxResult.maxAmount);
|
|
526
|
+
}
|
|
527
|
+
const remaining = protocolMaxes.get(entry.protocolId);
|
|
528
|
+
const perAssetMax = Math.min(entry.amount, remaining);
|
|
529
|
+
if (perAssetMax > 0.01) {
|
|
530
|
+
entries.push({ ...entry, maxAmount: perAssetMax, adapter });
|
|
531
|
+
protocolMaxes.set(entry.protocolId, remaining - perAssetMax);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (entries.length === 0) {
|
|
535
|
+
throw new T2000Error('NO_COLLATERAL', 'No savings to withdraw across any protocol');
|
|
536
|
+
}
|
|
537
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
538
|
+
const canPTB = entries.every(e => e.adapter.addWithdrawToTx) && (!swapAdapter || swapAdapter.addSwapToTx);
|
|
539
|
+
let totalUsdcReceived = 0;
|
|
540
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
541
|
+
if (canPTB) {
|
|
542
|
+
const tx = new Transaction();
|
|
543
|
+
tx.setSender(this._address);
|
|
544
|
+
const usdcCoins = [];
|
|
545
|
+
const nonUsdcCoins = [];
|
|
546
|
+
for (const entry of entries) {
|
|
547
|
+
const { coin, effectiveAmount } = await entry.adapter.addWithdrawToTx(tx, this._address, entry.maxAmount, entry.asset);
|
|
548
|
+
if (entry.asset === 'USDC') {
|
|
549
|
+
totalUsdcReceived += effectiveAmount;
|
|
550
|
+
usdcCoins.push(coin);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
totalUsdcReceived += effectiveAmount;
|
|
554
|
+
nonUsdcCoins.push(coin);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (usdcCoins.length > 1) {
|
|
558
|
+
tx.mergeCoins(usdcCoins[0], usdcCoins.slice(1));
|
|
559
|
+
}
|
|
560
|
+
if (usdcCoins.length > 0) {
|
|
561
|
+
tx.transferObjects([usdcCoins[0]], this._address);
|
|
562
|
+
}
|
|
563
|
+
for (const coin of nonUsdcCoins) {
|
|
564
|
+
tx.transferObjects([coin], this._address);
|
|
565
|
+
}
|
|
566
|
+
return tx;
|
|
567
|
+
}
|
|
568
|
+
// Fallback: multi-tx (shouldn't happen with current adapters)
|
|
569
|
+
let lastTx;
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
const built = await entry.adapter.buildWithdrawTx(this._address, entry.maxAmount, entry.asset);
|
|
572
|
+
totalUsdcReceived += built.effectiveAmount;
|
|
573
|
+
lastTx = built.tx;
|
|
574
|
+
}
|
|
575
|
+
return lastTx;
|
|
576
|
+
});
|
|
577
|
+
if (totalUsdcReceived <= 0) {
|
|
578
|
+
throw new T2000Error('NO_COLLATERAL', 'No savings to withdraw across any protocol');
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
success: true,
|
|
582
|
+
tx: gasResult.digest,
|
|
583
|
+
amount: totalUsdcReceived,
|
|
584
|
+
gasCost: gasResult.gasCostSui,
|
|
585
|
+
gasMethod: gasResult.gasMethod,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
async _fetchCoins(coinType) {
|
|
589
|
+
const all = [];
|
|
590
|
+
let cursor;
|
|
591
|
+
let hasNext = true;
|
|
592
|
+
while (hasNext) {
|
|
593
|
+
const page = await this.client.getCoins({ owner: this._address, coinType, cursor: cursor ?? undefined });
|
|
594
|
+
all.push(...page.data.map((c) => ({ coinObjectId: c.coinObjectId, balance: c.balance })));
|
|
595
|
+
cursor = page.nextCursor;
|
|
596
|
+
hasNext = page.hasNextPage;
|
|
597
|
+
}
|
|
598
|
+
return all;
|
|
599
|
+
}
|
|
600
|
+
_mergeCoinsInTx(tx, coins) {
|
|
601
|
+
if (coins.length === 0)
|
|
602
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No coins to merge');
|
|
603
|
+
const primary = tx.object(coins[0].coinObjectId);
|
|
604
|
+
if (coins.length > 1) {
|
|
605
|
+
tx.mergeCoins(primary, coins.slice(1).map((c) => tx.object(c.coinObjectId)));
|
|
606
|
+
}
|
|
607
|
+
return primary;
|
|
608
|
+
}
|
|
609
|
+
async _swapToUsdc(asset, amount) {
|
|
610
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
611
|
+
if (!swapAdapter)
|
|
612
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'No swap adapter available');
|
|
613
|
+
let estimatedOut = 0;
|
|
614
|
+
let toDecimals = 6;
|
|
615
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
616
|
+
const built = await swapAdapter.buildSwapTx(this._address, asset, 'USDC', amount);
|
|
617
|
+
estimatedOut = built.estimatedOut;
|
|
618
|
+
toDecimals = built.toDecimals;
|
|
619
|
+
return built.tx;
|
|
620
|
+
});
|
|
621
|
+
const usdcReceived = estimatedOut / 10 ** toDecimals;
|
|
622
|
+
return { usdcReceived, digest: gasResult.digest, gasCost: gasResult.gasCostSui };
|
|
623
|
+
}
|
|
624
|
+
async _swapFromUsdc(toAsset, amount) {
|
|
625
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
626
|
+
if (!swapAdapter)
|
|
627
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'No swap adapter available');
|
|
628
|
+
let estimatedOut = 0;
|
|
629
|
+
let toDecimals = 6;
|
|
630
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
631
|
+
const built = await swapAdapter.buildSwapTx(this._address, 'USDC', toAsset, amount);
|
|
632
|
+
estimatedOut = built.estimatedOut;
|
|
633
|
+
toDecimals = built.toDecimals;
|
|
634
|
+
return built.tx;
|
|
635
|
+
});
|
|
636
|
+
const received = estimatedOut / 10 ** toDecimals;
|
|
637
|
+
return { received, digest: gasResult.digest, gasCost: gasResult.gasCostSui };
|
|
638
|
+
}
|
|
639
|
+
async _convertWalletStablesToUsdc(bal, amountNeeded) {
|
|
640
|
+
const nonUsdcStables = [];
|
|
641
|
+
for (const [asset, amount] of Object.entries(bal.stables)) {
|
|
642
|
+
if (asset !== 'USDC' && amount > 0.01) {
|
|
643
|
+
nonUsdcStables.push({ asset, amount });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (nonUsdcStables.length === 0)
|
|
647
|
+
return;
|
|
648
|
+
// Sort largest balance first for efficiency
|
|
649
|
+
nonUsdcStables.sort((a, b) => b.amount - a.amount);
|
|
650
|
+
let converted = 0;
|
|
651
|
+
for (const entry of nonUsdcStables) {
|
|
652
|
+
if (amountNeeded !== undefined && converted >= amountNeeded)
|
|
653
|
+
break;
|
|
654
|
+
try {
|
|
655
|
+
await this._swapToUsdc(entry.asset, entry.amount);
|
|
656
|
+
converted += entry.amount;
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Skip this asset if swap fails, continue with others
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async maxWithdraw() {
|
|
664
|
+
const adapter = await this.resolveLending(undefined, 'USDC', 'withdraw');
|
|
665
|
+
return adapter.maxWithdraw(this._address, 'USDC');
|
|
666
|
+
}
|
|
667
|
+
// -- Borrowing --
|
|
668
|
+
async adjustMaxBorrowForInvestments(adapter, maxResult) {
|
|
669
|
+
const earningPositions = this.portfolio.getPositions().filter(p => p.earning);
|
|
670
|
+
if (earningPositions.length === 0)
|
|
671
|
+
return maxResult;
|
|
672
|
+
let investmentCollateralUsd = 0;
|
|
673
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
674
|
+
for (const pos of earningPositions) {
|
|
675
|
+
if (pos.earningProtocol !== adapter.id)
|
|
676
|
+
continue;
|
|
677
|
+
try {
|
|
678
|
+
let price = 0;
|
|
679
|
+
if (pos.asset === 'SUI' && swapAdapter) {
|
|
680
|
+
price = await swapAdapter.getPoolPrice();
|
|
681
|
+
}
|
|
682
|
+
else if (swapAdapter) {
|
|
683
|
+
const quote = await swapAdapter.getQuote('USDC', pos.asset, 1);
|
|
684
|
+
price = quote.expectedOutput > 0 ? 1 / quote.expectedOutput : 0;
|
|
685
|
+
}
|
|
686
|
+
investmentCollateralUsd += pos.totalAmount * price;
|
|
687
|
+
}
|
|
688
|
+
catch { /* keep zero */ }
|
|
689
|
+
}
|
|
690
|
+
if (investmentCollateralUsd <= 0)
|
|
691
|
+
return maxResult;
|
|
692
|
+
const CONSERVATIVE_LTV = 0.60;
|
|
693
|
+
const investmentBorrowCapacity = investmentCollateralUsd * CONSERVATIVE_LTV;
|
|
694
|
+
const adjustedMax = Math.max(0, maxResult.maxAmount - investmentBorrowCapacity);
|
|
695
|
+
return { ...maxResult, maxAmount: adjustedMax };
|
|
696
|
+
}
|
|
697
|
+
async borrow(params) {
|
|
698
|
+
this.enforcer.assertNotLocked();
|
|
699
|
+
const asset = 'USDC';
|
|
700
|
+
const adapter = await this.resolveLending(params.protocol, asset, 'borrow');
|
|
701
|
+
const rawMax = await adapter.maxBorrow(this._address, asset);
|
|
702
|
+
const maxResult = await this.adjustMaxBorrowForInvestments(adapter, rawMax);
|
|
703
|
+
if (maxResult.maxAmount <= 0) {
|
|
704
|
+
const hasInvestmentEarning = this.portfolio.getPositions().some(p => p.earning && p.earningProtocol === adapter.id);
|
|
705
|
+
if (hasInvestmentEarning) {
|
|
706
|
+
throw new T2000Error('BORROW_GUARD_INVESTMENT', 'Max safe borrow: $0.00. Only savings deposits (stablecoins) count as borrowable collateral. Investment collateral (SUI, ETH, BTC) is excluded.');
|
|
707
|
+
}
|
|
708
|
+
throw new T2000Error('NO_COLLATERAL', 'No collateral deposited. Save first with `t2000 save <amount>`.');
|
|
709
|
+
}
|
|
710
|
+
if (params.amount > maxResult.maxAmount) {
|
|
711
|
+
throw new T2000Error('HEALTH_FACTOR_TOO_LOW', `Max safe borrow: $${maxResult.maxAmount.toFixed(2)}. Only savings deposits count as borrowable collateral.`, {
|
|
712
|
+
maxBorrow: maxResult.maxAmount,
|
|
713
|
+
currentHF: maxResult.currentHF,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
const fee = calculateFee('borrow', params.amount);
|
|
717
|
+
const borrowAmount = params.amount;
|
|
718
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
719
|
+
const { tx } = await adapter.buildBorrowTx(this._address, borrowAmount, asset, { collectFee: true });
|
|
720
|
+
return tx;
|
|
721
|
+
});
|
|
722
|
+
const hf = await adapter.getHealth(this._address);
|
|
723
|
+
reportFee(this._address, 'borrow', fee.amount, fee.rate, gasResult.digest);
|
|
724
|
+
this.emitBalanceChange(asset, borrowAmount, 'borrow', gasResult.digest);
|
|
725
|
+
return {
|
|
726
|
+
success: true,
|
|
727
|
+
tx: gasResult.digest,
|
|
728
|
+
amount: borrowAmount,
|
|
729
|
+
fee: fee.amount,
|
|
730
|
+
healthFactor: hf.healthFactor,
|
|
731
|
+
gasCost: gasResult.gasCostSui,
|
|
732
|
+
gasMethod: gasResult.gasMethod,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
async repay(params) {
|
|
736
|
+
this.enforcer.assertNotLocked();
|
|
737
|
+
// Find actual borrows (may be non-USDC from rebalance or legacy)
|
|
738
|
+
const allPositions = await this.registry.allPositions(this._address);
|
|
739
|
+
const borrows = [];
|
|
740
|
+
for (const pos of allPositions) {
|
|
741
|
+
if (params.protocol && pos.protocolId !== params.protocol)
|
|
742
|
+
continue;
|
|
743
|
+
for (const b of pos.positions.borrows) {
|
|
744
|
+
if (b.amount > 0.001)
|
|
745
|
+
borrows.push({ protocolId: pos.protocolId, asset: b.asset, amount: b.amount, apy: b.apy });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (borrows.length === 0) {
|
|
749
|
+
throw new T2000Error('NO_COLLATERAL', 'No outstanding borrow to repay');
|
|
750
|
+
}
|
|
751
|
+
if (params.amount === 'all') {
|
|
752
|
+
return this._repayAllBorrows(borrows);
|
|
753
|
+
}
|
|
754
|
+
// Repay highest-interest borrow first
|
|
755
|
+
borrows.sort((a, b) => b.apy - a.apy);
|
|
756
|
+
const target = borrows[0];
|
|
757
|
+
const adapter = this.registry.getLending(target.protocolId);
|
|
758
|
+
if (!adapter)
|
|
759
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', `Protocol ${target.protocolId} not found`);
|
|
760
|
+
const repayAmount = Math.min(params.amount, target.amount);
|
|
761
|
+
const swapAdapter = target.asset !== 'USDC' ? this.registry.listSwap()[0] : undefined;
|
|
762
|
+
const canPTB = adapter.addRepayToTx && (!swapAdapter || swapAdapter.addSwapToTx);
|
|
763
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
764
|
+
if (canPTB && target.asset !== 'USDC' && swapAdapter?.addSwapToTx) {
|
|
765
|
+
const tx = new Transaction();
|
|
766
|
+
tx.setSender(this._address);
|
|
767
|
+
const buffer = repayAmount * 1.005;
|
|
768
|
+
const usdcCoins = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
769
|
+
if (usdcCoins.length === 0)
|
|
770
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC coins for swap');
|
|
771
|
+
const merged = this._mergeCoinsInTx(tx, usdcCoins);
|
|
772
|
+
const rawSwap = BigInt(Math.floor(buffer * 10 ** SUPPORTED_ASSETS.USDC.decimals));
|
|
773
|
+
const [splitCoin] = tx.splitCoins(merged, [rawSwap]);
|
|
774
|
+
const { outputCoin } = await swapAdapter.addSwapToTx(tx, this._address, splitCoin, 'USDC', target.asset, buffer);
|
|
775
|
+
await adapter.addRepayToTx(tx, this._address, outputCoin, target.asset);
|
|
776
|
+
return tx;
|
|
777
|
+
}
|
|
778
|
+
if (canPTB && target.asset === 'USDC') {
|
|
779
|
+
const tx = new Transaction();
|
|
780
|
+
tx.setSender(this._address);
|
|
781
|
+
const usdcCoins = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
782
|
+
if (usdcCoins.length === 0)
|
|
783
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC coins');
|
|
784
|
+
const merged = this._mergeCoinsInTx(tx, usdcCoins);
|
|
785
|
+
const raw = BigInt(Math.floor(repayAmount * 10 ** SUPPORTED_ASSETS.USDC.decimals));
|
|
786
|
+
const [repayCoin] = tx.splitCoins(merged, [raw]);
|
|
787
|
+
await adapter.addRepayToTx(tx, this._address, repayCoin, target.asset);
|
|
788
|
+
return tx;
|
|
789
|
+
}
|
|
790
|
+
// Fallback: multi-tx
|
|
791
|
+
if (target.asset !== 'USDC') {
|
|
792
|
+
await this._swapFromUsdc(target.asset, repayAmount * 1.005);
|
|
793
|
+
}
|
|
794
|
+
const { tx } = await adapter.buildRepayTx(this._address, repayAmount, target.asset);
|
|
795
|
+
return tx;
|
|
796
|
+
});
|
|
797
|
+
const hf = await adapter.getHealth(this._address);
|
|
798
|
+
this.emitBalanceChange('USDC', repayAmount, 'repay', gasResult.digest);
|
|
799
|
+
return {
|
|
800
|
+
success: true,
|
|
801
|
+
tx: gasResult.digest,
|
|
802
|
+
amount: repayAmount,
|
|
803
|
+
remainingDebt: hf.borrowed,
|
|
804
|
+
gasCost: gasResult.gasCostSui,
|
|
805
|
+
gasMethod: gasResult.gasMethod,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
async _repayAllBorrows(borrows) {
|
|
809
|
+
borrows.sort((a, b) => b.apy - a.apy);
|
|
810
|
+
const entries = [];
|
|
811
|
+
for (const borrow of borrows) {
|
|
812
|
+
const adapter = this.registry.getLending(borrow.protocolId);
|
|
813
|
+
if (adapter)
|
|
814
|
+
entries.push({ borrow, adapter });
|
|
815
|
+
}
|
|
816
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
817
|
+
const canPTB = entries.every(e => e.adapter.addRepayToTx) &&
|
|
818
|
+
(entries.every(e => e.borrow.asset === 'USDC') || swapAdapter?.addSwapToTx);
|
|
819
|
+
let totalRepaid = 0;
|
|
820
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
821
|
+
if (canPTB) {
|
|
822
|
+
const tx = new Transaction();
|
|
823
|
+
tx.setSender(this._address);
|
|
824
|
+
// Pre-fetch USDC coins for any swaps or direct repays
|
|
825
|
+
const usdcCoins = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
826
|
+
let usdcMerged;
|
|
827
|
+
if (usdcCoins.length > 0) {
|
|
828
|
+
usdcMerged = this._mergeCoinsInTx(tx, usdcCoins);
|
|
829
|
+
}
|
|
830
|
+
for (const { borrow, adapter } of entries) {
|
|
831
|
+
if (borrow.asset !== 'USDC' && swapAdapter?.addSwapToTx) {
|
|
832
|
+
const buffer = borrow.amount * 1.005;
|
|
833
|
+
const rawSwap = BigInt(Math.floor(buffer * 10 ** SUPPORTED_ASSETS.USDC.decimals));
|
|
834
|
+
if (!usdcMerged)
|
|
835
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC for swap');
|
|
836
|
+
const [splitCoin] = tx.splitCoins(usdcMerged, [rawSwap]);
|
|
837
|
+
const { outputCoin } = await swapAdapter.addSwapToTx(tx, this._address, splitCoin, 'USDC', borrow.asset, buffer);
|
|
838
|
+
await adapter.addRepayToTx(tx, this._address, outputCoin, borrow.asset);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
const raw = BigInt(Math.floor(borrow.amount * 10 ** SUPPORTED_ASSETS.USDC.decimals));
|
|
842
|
+
if (!usdcMerged)
|
|
843
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC for repayment');
|
|
844
|
+
const [repayCoin] = tx.splitCoins(usdcMerged, [raw]);
|
|
845
|
+
await adapter.addRepayToTx(tx, this._address, repayCoin, borrow.asset);
|
|
846
|
+
}
|
|
847
|
+
totalRepaid += borrow.amount;
|
|
848
|
+
}
|
|
849
|
+
return tx;
|
|
850
|
+
}
|
|
851
|
+
// Fallback: multi-tx
|
|
852
|
+
let lastTx;
|
|
853
|
+
for (const { borrow, adapter } of entries) {
|
|
854
|
+
if (borrow.asset !== 'USDC') {
|
|
855
|
+
await this._swapFromUsdc(borrow.asset, borrow.amount * 1.005);
|
|
856
|
+
}
|
|
857
|
+
const { tx } = await adapter.buildRepayTx(this._address, borrow.amount, borrow.asset);
|
|
858
|
+
lastTx = tx;
|
|
859
|
+
totalRepaid += borrow.amount;
|
|
860
|
+
}
|
|
861
|
+
return lastTx;
|
|
862
|
+
});
|
|
863
|
+
const firstAdapter = entries[0]?.adapter;
|
|
864
|
+
const hf = firstAdapter ? await firstAdapter.getHealth(this._address) : { borrowed: 0 };
|
|
865
|
+
this.emitBalanceChange('USDC', totalRepaid, 'repay', gasResult.digest);
|
|
866
|
+
return {
|
|
867
|
+
success: true,
|
|
868
|
+
tx: gasResult.digest,
|
|
869
|
+
amount: totalRepaid,
|
|
870
|
+
remainingDebt: hf.borrowed,
|
|
871
|
+
gasCost: gasResult.gasCostSui,
|
|
872
|
+
gasMethod: gasResult.gasMethod,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async maxBorrow() {
|
|
876
|
+
const adapter = await this.resolveLending(undefined, 'USDC', 'borrow');
|
|
877
|
+
const rawMax = await adapter.maxBorrow(this._address, 'USDC');
|
|
878
|
+
return this.adjustMaxBorrowForInvestments(adapter, rawMax);
|
|
879
|
+
}
|
|
880
|
+
async healthFactor() {
|
|
881
|
+
const adapter = await this.resolveLending(undefined, 'USDC', 'save');
|
|
882
|
+
const hf = await adapter.getHealth(this._address);
|
|
883
|
+
if (hf.healthFactor < 1.2) {
|
|
884
|
+
this.emit('healthCritical', { healthFactor: hf.healthFactor, threshold: 1.5, severity: 'critical' });
|
|
885
|
+
}
|
|
886
|
+
else if (hf.healthFactor < 2.0) {
|
|
887
|
+
this.emit('healthWarning', { healthFactor: hf.healthFactor, threshold: 2.0, severity: 'warning' });
|
|
888
|
+
}
|
|
889
|
+
return hf;
|
|
890
|
+
}
|
|
891
|
+
// -- Exchange --
|
|
892
|
+
async exchange(params) {
|
|
893
|
+
this.enforcer.assertNotLocked();
|
|
894
|
+
const fromAsset = params.from;
|
|
895
|
+
const toAsset = params.to;
|
|
896
|
+
if (!(fromAsset in SUPPORTED_ASSETS) || !(toAsset in SUPPORTED_ASSETS)) {
|
|
897
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `Swap pair ${fromAsset}/${toAsset} is not supported`);
|
|
898
|
+
}
|
|
899
|
+
if (fromAsset === toAsset) {
|
|
900
|
+
throw new T2000Error('INVALID_AMOUNT', 'Cannot swap same asset');
|
|
901
|
+
}
|
|
902
|
+
if (!params._bypassInvestmentGuard && fromAsset in INVESTMENT_ASSETS) {
|
|
903
|
+
const free = await this.getFreeBalance(fromAsset);
|
|
904
|
+
if (params.amount > free) {
|
|
905
|
+
const pos = this.portfolio.getPosition(fromAsset);
|
|
906
|
+
const invested = pos?.totalAmount ?? 0;
|
|
907
|
+
throw new T2000Error('INVESTMENT_LOCKED', `Cannot exchange ${params.amount} ${fromAsset} — ${invested.toFixed(4)} ${fromAsset} is invested. Free ${fromAsset}: ${free.toFixed(4)}\nTo sell investment: t2000 invest sell ${params.amount} ${fromAsset}`, { free, invested, requested: params.amount });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const best = await this.registry.bestSwapQuote(fromAsset, toAsset, params.amount);
|
|
911
|
+
const adapter = best.adapter;
|
|
912
|
+
const fee = calculateFee('swap', params.amount);
|
|
913
|
+
const swapAmount = params.amount;
|
|
914
|
+
const slippageBps = params.maxSlippage ? params.maxSlippage * 100 : undefined;
|
|
915
|
+
let swapMeta = { estimatedOut: 0, toDecimals: 0 };
|
|
916
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
917
|
+
const built = await adapter.buildSwapTx(this._address, fromAsset, toAsset, swapAmount, slippageBps);
|
|
918
|
+
swapMeta = { estimatedOut: built.estimatedOut, toDecimals: built.toDecimals };
|
|
919
|
+
return built.tx;
|
|
920
|
+
});
|
|
921
|
+
const toInfo = SUPPORTED_ASSETS[toAsset];
|
|
922
|
+
await this.client.waitForTransaction({ digest: gasResult.digest });
|
|
923
|
+
const txDetail = await this.client.getTransactionBlock({
|
|
924
|
+
digest: gasResult.digest,
|
|
925
|
+
options: { showBalanceChanges: true },
|
|
926
|
+
});
|
|
927
|
+
let actualReceived = 0;
|
|
928
|
+
if (txDetail.balanceChanges) {
|
|
929
|
+
for (const change of txDetail.balanceChanges) {
|
|
930
|
+
if (change.coinType === toInfo.type &&
|
|
931
|
+
change.owner &&
|
|
932
|
+
typeof change.owner === 'object' &&
|
|
933
|
+
'AddressOwner' in change.owner &&
|
|
934
|
+
change.owner.AddressOwner === this._address) {
|
|
935
|
+
const amt = Number(change.amount) / 10 ** toInfo.decimals;
|
|
936
|
+
if (amt > 0)
|
|
937
|
+
actualReceived += amt;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const expectedOutput = swapMeta.estimatedOut / 10 ** swapMeta.toDecimals;
|
|
942
|
+
if (actualReceived === 0)
|
|
943
|
+
actualReceived = expectedOutput;
|
|
944
|
+
const priceImpact = expectedOutput > 0
|
|
945
|
+
? Math.abs(actualReceived - expectedOutput) / expectedOutput
|
|
946
|
+
: 0;
|
|
947
|
+
reportFee(this._address, 'swap', fee.amount, fee.rate, gasResult.digest);
|
|
948
|
+
this.emitBalanceChange(fromAsset, swapAmount, 'swap', gasResult.digest);
|
|
949
|
+
return {
|
|
950
|
+
success: true,
|
|
951
|
+
tx: gasResult.digest,
|
|
952
|
+
fromAmount: swapAmount,
|
|
953
|
+
fromAsset,
|
|
954
|
+
toAmount: actualReceived,
|
|
955
|
+
toAsset,
|
|
956
|
+
priceImpact,
|
|
957
|
+
fee: fee.amount,
|
|
958
|
+
gasCost: gasResult.gasCostSui,
|
|
959
|
+
gasMethod: gasResult.gasMethod,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
async exchangeQuote(params) {
|
|
963
|
+
const fromAsset = params.from;
|
|
964
|
+
const toAsset = params.to;
|
|
965
|
+
const best = await this.registry.bestSwapQuote(fromAsset, toAsset, params.amount);
|
|
966
|
+
const fee = calculateFee('swap', params.amount);
|
|
967
|
+
return { ...best.quote, fee: { amount: fee.amount, rate: fee.rate } };
|
|
968
|
+
}
|
|
969
|
+
// -- Investment --
|
|
970
|
+
async investBuy(params) {
|
|
971
|
+
this.enforcer.assertNotLocked();
|
|
972
|
+
if (!params.usdAmount || params.usdAmount <= 0 || !isFinite(params.usdAmount)) {
|
|
973
|
+
throw new T2000Error('INVALID_AMOUNT', 'Investment amount must be greater than $0');
|
|
974
|
+
}
|
|
975
|
+
this.enforcer.check({ operation: 'invest', amount: params.usdAmount });
|
|
976
|
+
if (!(params.asset in INVESTMENT_ASSETS)) {
|
|
977
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `${params.asset} is not available for investment`);
|
|
978
|
+
}
|
|
979
|
+
const bal = await queryBalance(this.client, this._address);
|
|
980
|
+
if (bal.available < params.usdAmount) {
|
|
981
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', `Insufficient checking balance. Available: $${bal.available.toFixed(2)}, requested: $${params.usdAmount.toFixed(2)}`);
|
|
982
|
+
}
|
|
983
|
+
let swapResult;
|
|
984
|
+
const maxRetries = 3;
|
|
985
|
+
for (let attempt = 0;; attempt++) {
|
|
986
|
+
try {
|
|
987
|
+
swapResult = await this.exchange({
|
|
988
|
+
from: 'USDC',
|
|
989
|
+
to: params.asset,
|
|
990
|
+
amount: params.usdAmount,
|
|
991
|
+
maxSlippage: params.maxSlippage ?? defaultSlippage(params.asset),
|
|
992
|
+
_bypassInvestmentGuard: true,
|
|
993
|
+
});
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
catch (err) {
|
|
997
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
998
|
+
const isSlippage = msg.includes('slippage') || msg.includes('amount_out_slippage');
|
|
999
|
+
if (isSlippage && attempt < maxRetries) {
|
|
1000
|
+
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
throw err;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (swapResult.toAmount === 0) {
|
|
1007
|
+
throw new T2000Error('SWAP_FAILED', 'Swap returned zero tokens — try a different amount or check liquidity');
|
|
1008
|
+
}
|
|
1009
|
+
const price = params.usdAmount / swapResult.toAmount;
|
|
1010
|
+
this.portfolio.recordBuy({
|
|
1011
|
+
id: `inv_${Date.now()}`,
|
|
1012
|
+
type: 'buy',
|
|
1013
|
+
asset: params.asset,
|
|
1014
|
+
amount: swapResult.toAmount,
|
|
1015
|
+
price,
|
|
1016
|
+
usdValue: params.usdAmount,
|
|
1017
|
+
fee: swapResult.fee,
|
|
1018
|
+
tx: swapResult.tx,
|
|
1019
|
+
timestamp: new Date().toISOString(),
|
|
1020
|
+
});
|
|
1021
|
+
const pos = this.portfolio.getPosition(params.asset);
|
|
1022
|
+
const currentPrice = price;
|
|
1023
|
+
const position = {
|
|
1024
|
+
asset: params.asset,
|
|
1025
|
+
totalAmount: pos?.totalAmount ?? swapResult.toAmount,
|
|
1026
|
+
costBasis: pos?.costBasis ?? params.usdAmount,
|
|
1027
|
+
avgPrice: pos?.avgPrice ?? price,
|
|
1028
|
+
currentPrice,
|
|
1029
|
+
currentValue: (pos?.totalAmount ?? swapResult.toAmount) * currentPrice,
|
|
1030
|
+
unrealizedPnL: 0,
|
|
1031
|
+
unrealizedPnLPct: 0,
|
|
1032
|
+
trades: pos?.trades ?? [],
|
|
1033
|
+
};
|
|
1034
|
+
return {
|
|
1035
|
+
success: true,
|
|
1036
|
+
tx: swapResult.tx,
|
|
1037
|
+
type: 'buy',
|
|
1038
|
+
asset: params.asset,
|
|
1039
|
+
amount: swapResult.toAmount,
|
|
1040
|
+
price,
|
|
1041
|
+
usdValue: params.usdAmount,
|
|
1042
|
+
fee: swapResult.fee,
|
|
1043
|
+
gasCost: swapResult.gasCost,
|
|
1044
|
+
gasMethod: swapResult.gasMethod,
|
|
1045
|
+
position,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
async investSell(params) {
|
|
1049
|
+
this.enforcer.assertNotLocked();
|
|
1050
|
+
if (params.usdAmount !== 'all') {
|
|
1051
|
+
if (!params.usdAmount || params.usdAmount <= 0 || !isFinite(params.usdAmount)) {
|
|
1052
|
+
throw new T2000Error('INVALID_AMOUNT', 'Sell amount must be greater than $0');
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (!(params.asset in INVESTMENT_ASSETS)) {
|
|
1056
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `${params.asset} is not available for investment`);
|
|
1057
|
+
}
|
|
1058
|
+
const pos = this.portfolio.getPosition(params.asset);
|
|
1059
|
+
if (!pos || pos.totalAmount <= 0) {
|
|
1060
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', `No ${params.asset} position to sell`);
|
|
1061
|
+
}
|
|
1062
|
+
const didAutoWithdraw = !!(pos.earning && pos.earningProtocol);
|
|
1063
|
+
if (didAutoWithdraw) {
|
|
1064
|
+
const unearnResult = await this.investUnearn({ asset: params.asset });
|
|
1065
|
+
if (unearnResult.tx) {
|
|
1066
|
+
await this.client.waitForTransaction({ digest: unearnResult.tx, options: { showEffects: true } });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const assetInfo = SUPPORTED_ASSETS[params.asset];
|
|
1070
|
+
const gasReserve = params.asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
1071
|
+
let walletAmount = 0;
|
|
1072
|
+
for (let attempt = 0;; attempt++) {
|
|
1073
|
+
const assetBalance = await this.client.getBalance({
|
|
1074
|
+
owner: this._address,
|
|
1075
|
+
coinType: assetInfo.type,
|
|
1076
|
+
});
|
|
1077
|
+
walletAmount = Number(assetBalance.totalBalance) / (10 ** assetInfo.decimals);
|
|
1078
|
+
if (!didAutoWithdraw || walletAmount > gasReserve || attempt >= 5)
|
|
1079
|
+
break;
|
|
1080
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
1081
|
+
}
|
|
1082
|
+
const maxSellable = Math.max(0, walletAmount - gasReserve);
|
|
1083
|
+
let sellAmountAsset;
|
|
1084
|
+
if (params.usdAmount === 'all') {
|
|
1085
|
+
sellAmountAsset = Math.min(pos.totalAmount, maxSellable);
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1089
|
+
if (!swapAdapter)
|
|
1090
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'No swap adapter available');
|
|
1091
|
+
const quote = await swapAdapter.getQuote('USDC', params.asset, 1);
|
|
1092
|
+
const assetPrice = 1 / quote.expectedOutput;
|
|
1093
|
+
sellAmountAsset = params.usdAmount / assetPrice;
|
|
1094
|
+
// For strategy sells, cap to wallet balance; for direct sells, cap to tracked position
|
|
1095
|
+
const maxPosition = params._strategyOnly ? maxSellable : pos.totalAmount;
|
|
1096
|
+
sellAmountAsset = Math.min(sellAmountAsset, maxPosition);
|
|
1097
|
+
if (sellAmountAsset > maxSellable) {
|
|
1098
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', `Cannot sell $${params.usdAmount.toFixed(2)} — max sellable: $${(maxSellable * assetPrice).toFixed(2)} (gas reserve: ${gasReserve} ${params.asset})`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (sellAmountAsset <= 0) {
|
|
1102
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', 'Nothing to sell after gas reserve');
|
|
1103
|
+
}
|
|
1104
|
+
let swapResult;
|
|
1105
|
+
const maxRetries = 3;
|
|
1106
|
+
for (let attempt = 0;; attempt++) {
|
|
1107
|
+
try {
|
|
1108
|
+
swapResult = await this.exchange({
|
|
1109
|
+
from: params.asset,
|
|
1110
|
+
to: 'USDC',
|
|
1111
|
+
amount: sellAmountAsset,
|
|
1112
|
+
maxSlippage: params.maxSlippage ?? defaultSlippage(params.asset),
|
|
1113
|
+
_bypassInvestmentGuard: true,
|
|
1114
|
+
});
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
catch (err) {
|
|
1118
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1119
|
+
const isSlippage = msg.includes('slippage') || msg.includes('amount_out_slippage');
|
|
1120
|
+
if (isSlippage && attempt < maxRetries) {
|
|
1121
|
+
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
throw err;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const price = swapResult.toAmount / sellAmountAsset;
|
|
1128
|
+
const realizedPnL = this.portfolio.recordSell({
|
|
1129
|
+
id: `inv_${Date.now()}`,
|
|
1130
|
+
type: 'sell',
|
|
1131
|
+
asset: params.asset,
|
|
1132
|
+
amount: sellAmountAsset,
|
|
1133
|
+
price,
|
|
1134
|
+
usdValue: swapResult.toAmount,
|
|
1135
|
+
fee: swapResult.fee,
|
|
1136
|
+
tx: swapResult.tx,
|
|
1137
|
+
timestamp: new Date().toISOString(),
|
|
1138
|
+
});
|
|
1139
|
+
if (params.usdAmount === 'all' && !params._strategyOnly) {
|
|
1140
|
+
this.portfolio.closePosition(params.asset);
|
|
1141
|
+
}
|
|
1142
|
+
const updatedPos = this.portfolio.getPosition(params.asset);
|
|
1143
|
+
const position = {
|
|
1144
|
+
asset: params.asset,
|
|
1145
|
+
totalAmount: updatedPos?.totalAmount ?? 0,
|
|
1146
|
+
costBasis: updatedPos?.costBasis ?? 0,
|
|
1147
|
+
avgPrice: updatedPos?.avgPrice ?? 0,
|
|
1148
|
+
currentPrice: price,
|
|
1149
|
+
currentValue: (updatedPos?.totalAmount ?? 0) * price,
|
|
1150
|
+
unrealizedPnL: 0,
|
|
1151
|
+
unrealizedPnLPct: 0,
|
|
1152
|
+
trades: updatedPos?.trades ?? [],
|
|
1153
|
+
};
|
|
1154
|
+
return {
|
|
1155
|
+
success: true,
|
|
1156
|
+
tx: swapResult.tx,
|
|
1157
|
+
type: 'sell',
|
|
1158
|
+
asset: params.asset,
|
|
1159
|
+
amount: sellAmountAsset,
|
|
1160
|
+
price,
|
|
1161
|
+
usdValue: swapResult.toAmount,
|
|
1162
|
+
fee: swapResult.fee,
|
|
1163
|
+
gasCost: swapResult.gasCost,
|
|
1164
|
+
gasMethod: swapResult.gasMethod,
|
|
1165
|
+
realizedPnL,
|
|
1166
|
+
position,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
async investEarn(params) {
|
|
1170
|
+
this.enforcer.assertNotLocked();
|
|
1171
|
+
if (!(params.asset in INVESTMENT_ASSETS)) {
|
|
1172
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `${params.asset} is not available for investment`);
|
|
1173
|
+
}
|
|
1174
|
+
const pos = this.portfolio.getPosition(params.asset);
|
|
1175
|
+
if (!pos || pos.totalAmount <= 0) {
|
|
1176
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', `No ${params.asset} position to earn on`);
|
|
1177
|
+
}
|
|
1178
|
+
const assetInfo = SUPPORTED_ASSETS[params.asset];
|
|
1179
|
+
const assetBalance = await this.client.getBalance({
|
|
1180
|
+
owner: this._address,
|
|
1181
|
+
coinType: assetInfo.type,
|
|
1182
|
+
});
|
|
1183
|
+
const walletAmount = Number(assetBalance.totalBalance) / (10 ** assetInfo.decimals);
|
|
1184
|
+
const gasReserve = params.asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
1185
|
+
const depositAmount = Math.max(0, walletAmount - gasReserve);
|
|
1186
|
+
if (pos.earning && depositAmount <= 0) {
|
|
1187
|
+
return {
|
|
1188
|
+
success: true,
|
|
1189
|
+
tx: '',
|
|
1190
|
+
asset: params.asset,
|
|
1191
|
+
amount: 0,
|
|
1192
|
+
protocol: pos.earningProtocol ?? 'unknown',
|
|
1193
|
+
apy: pos.earningApy ?? 0,
|
|
1194
|
+
gasCost: 0,
|
|
1195
|
+
gasMethod: 'none',
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
if (depositAmount <= 0) {
|
|
1199
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', `No ${params.asset} available to deposit (wallet: ${walletAmount}, gas reserve: ${gasReserve})`);
|
|
1200
|
+
}
|
|
1201
|
+
const { adapter, rate } = await this.registry.bestSaveRate(params.asset);
|
|
1202
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
1203
|
+
const { tx } = await adapter.buildSaveTx(this._address, depositAmount, params.asset);
|
|
1204
|
+
return tx;
|
|
1205
|
+
});
|
|
1206
|
+
this.portfolio.recordEarn(params.asset, adapter.id, rate.saveApy);
|
|
1207
|
+
return {
|
|
1208
|
+
success: true,
|
|
1209
|
+
tx: gasResult.digest,
|
|
1210
|
+
asset: params.asset,
|
|
1211
|
+
amount: depositAmount,
|
|
1212
|
+
protocol: adapter.name,
|
|
1213
|
+
apy: rate.saveApy,
|
|
1214
|
+
gasCost: gasResult.gasCostSui,
|
|
1215
|
+
gasMethod: gasResult.gasMethod,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
async investUnearn(params) {
|
|
1219
|
+
this.enforcer.assertNotLocked();
|
|
1220
|
+
if (!(params.asset in INVESTMENT_ASSETS)) {
|
|
1221
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `${params.asset} is not available for investment`);
|
|
1222
|
+
}
|
|
1223
|
+
const pos = this.portfolio.getPosition(params.asset);
|
|
1224
|
+
if (!pos || !pos.earning || !pos.earningProtocol) {
|
|
1225
|
+
throw new T2000Error('INVEST_NOT_EARNING', `${params.asset} is not currently earning`);
|
|
1226
|
+
}
|
|
1227
|
+
const adapter = this.registry.getLending(pos.earningProtocol);
|
|
1228
|
+
if (!adapter) {
|
|
1229
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', `Lending protocol ${pos.earningProtocol} not found`);
|
|
1230
|
+
}
|
|
1231
|
+
// Withdraw only the tracked investment amount, not the entire protocol position
|
|
1232
|
+
// (the protocol may hold more from regular savings or previous runs)
|
|
1233
|
+
const withdrawAmount = pos.totalAmount;
|
|
1234
|
+
const protocolName = adapter.name;
|
|
1235
|
+
let effectiveAmount = withdrawAmount;
|
|
1236
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
1237
|
+
const result = await adapter.buildWithdrawTx(this._address, withdrawAmount, params.asset);
|
|
1238
|
+
effectiveAmount = result.effectiveAmount;
|
|
1239
|
+
return result.tx;
|
|
1240
|
+
});
|
|
1241
|
+
this.portfolio.recordUnearn(params.asset);
|
|
1242
|
+
return {
|
|
1243
|
+
success: true,
|
|
1244
|
+
tx: gasResult.digest,
|
|
1245
|
+
asset: params.asset,
|
|
1246
|
+
amount: effectiveAmount,
|
|
1247
|
+
protocol: protocolName,
|
|
1248
|
+
apy: 0,
|
|
1249
|
+
gasCost: gasResult.gasCostSui,
|
|
1250
|
+
gasMethod: gasResult.gasMethod,
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
// -- Invest Rebalance --
|
|
1254
|
+
async investRebalance(opts = {}) {
|
|
1255
|
+
this.enforcer.assertNotLocked();
|
|
1256
|
+
const minDiff = opts.minYieldDiff ?? 0.5;
|
|
1257
|
+
const positions = this.portfolio.getPositions().filter((p) => p.earning && p.earningProtocol);
|
|
1258
|
+
if (positions.length === 0) {
|
|
1259
|
+
return { executed: false, moves: [], totalGasCost: 0, skipped: [] };
|
|
1260
|
+
}
|
|
1261
|
+
const moves = [];
|
|
1262
|
+
const skipped = [];
|
|
1263
|
+
let totalGasCost = 0;
|
|
1264
|
+
for (const pos of positions) {
|
|
1265
|
+
const currentProtocol = pos.earningProtocol;
|
|
1266
|
+
const currentApy = pos.earningApy ?? 0;
|
|
1267
|
+
let best;
|
|
1268
|
+
try {
|
|
1269
|
+
best = await this.registry.bestSaveRate(pos.asset);
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
skipped.push({ asset: pos.asset, protocol: currentProtocol, apy: currentApy, bestApy: currentApy, reason: 'no_rates' });
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
const apyGain = best.rate.saveApy - currentApy;
|
|
1276
|
+
if (best.adapter.id === currentProtocol) {
|
|
1277
|
+
skipped.push({ asset: pos.asset, protocol: currentProtocol, apy: currentApy, bestApy: best.rate.saveApy, reason: 'already_best' });
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
if (apyGain < minDiff) {
|
|
1281
|
+
skipped.push({ asset: pos.asset, protocol: currentProtocol, apy: currentApy, bestApy: best.rate.saveApy, reason: 'below_threshold' });
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
if (opts.dryRun) {
|
|
1285
|
+
moves.push({
|
|
1286
|
+
asset: pos.asset,
|
|
1287
|
+
fromProtocol: this.registry.getLending(currentProtocol)?.name ?? currentProtocol,
|
|
1288
|
+
toProtocol: best.adapter.name,
|
|
1289
|
+
amount: pos.totalAmount,
|
|
1290
|
+
oldApy: currentApy,
|
|
1291
|
+
newApy: best.rate.saveApy,
|
|
1292
|
+
txDigests: [],
|
|
1293
|
+
gasCost: 0,
|
|
1294
|
+
});
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const txDigests = [];
|
|
1298
|
+
let moveGasCost = 0;
|
|
1299
|
+
const fromAdapter = this.registry.getLending(currentProtocol);
|
|
1300
|
+
if (!fromAdapter) {
|
|
1301
|
+
skipped.push({ asset: pos.asset, protocol: currentProtocol, apy: currentApy, bestApy: best.rate.saveApy, reason: 'protocol_unavailable' });
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
const withdrawResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
1305
|
+
const result = await fromAdapter.buildWithdrawTx(this._address, pos.totalAmount, pos.asset);
|
|
1306
|
+
return result.tx;
|
|
1307
|
+
});
|
|
1308
|
+
txDigests.push(withdrawResult.digest);
|
|
1309
|
+
moveGasCost += withdrawResult.gasCostSui;
|
|
1310
|
+
const depositResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
1311
|
+
const assetInfo = SUPPORTED_ASSETS[pos.asset];
|
|
1312
|
+
const balance = await this.client.getBalance({ owner: this._address, coinType: assetInfo.type });
|
|
1313
|
+
const available = Number(balance.totalBalance) / (10 ** assetInfo.decimals);
|
|
1314
|
+
const gasReserve = pos.asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
1315
|
+
const depositAmount = Math.max(0, available - gasReserve);
|
|
1316
|
+
const { tx } = await best.adapter.buildSaveTx(this._address, depositAmount, pos.asset);
|
|
1317
|
+
return tx;
|
|
1318
|
+
});
|
|
1319
|
+
txDigests.push(depositResult.digest);
|
|
1320
|
+
moveGasCost += depositResult.gasCostSui;
|
|
1321
|
+
this.portfolio.recordUnearn(pos.asset);
|
|
1322
|
+
this.portfolio.recordEarn(pos.asset, best.adapter.id, best.rate.saveApy);
|
|
1323
|
+
moves.push({
|
|
1324
|
+
asset: pos.asset,
|
|
1325
|
+
fromProtocol: fromAdapter.name,
|
|
1326
|
+
toProtocol: best.adapter.name,
|
|
1327
|
+
amount: pos.totalAmount,
|
|
1328
|
+
oldApy: currentApy,
|
|
1329
|
+
newApy: best.rate.saveApy,
|
|
1330
|
+
txDigests,
|
|
1331
|
+
gasCost: moveGasCost,
|
|
1332
|
+
});
|
|
1333
|
+
totalGasCost += moveGasCost;
|
|
1334
|
+
}
|
|
1335
|
+
return { executed: !opts.dryRun && moves.length > 0, moves, totalGasCost, skipped };
|
|
1336
|
+
}
|
|
1337
|
+
// -- Claim Rewards --
|
|
1338
|
+
async getPendingRewards() {
|
|
1339
|
+
const adapters = this.registry.listLending();
|
|
1340
|
+
const results = await Promise.allSettled(adapters
|
|
1341
|
+
.filter((a) => a.getPendingRewards)
|
|
1342
|
+
.map((a) => a.getPendingRewards(this._address)));
|
|
1343
|
+
const all = [];
|
|
1344
|
+
for (const r of results) {
|
|
1345
|
+
if (r.status === 'fulfilled')
|
|
1346
|
+
all.push(...r.value);
|
|
1347
|
+
}
|
|
1348
|
+
return all;
|
|
1349
|
+
}
|
|
1350
|
+
async claimRewards() {
|
|
1351
|
+
this.enforcer.assertNotLocked();
|
|
1352
|
+
const adapters = this.registry.listLending().filter((a) => a.addClaimRewardsToTx);
|
|
1353
|
+
if (adapters.length === 0) {
|
|
1354
|
+
return { success: true, tx: '', rewards: [], totalValueUsd: 0, usdcReceived: 0, gasCost: 0, gasMethod: 'none' };
|
|
1355
|
+
}
|
|
1356
|
+
const tx = new Transaction();
|
|
1357
|
+
tx.setSender(this._address);
|
|
1358
|
+
const allRewards = [];
|
|
1359
|
+
for (const adapter of adapters) {
|
|
1360
|
+
try {
|
|
1361
|
+
const claimed = await adapter.addClaimRewardsToTx(tx, this._address);
|
|
1362
|
+
allRewards.push(...claimed);
|
|
1363
|
+
}
|
|
1364
|
+
catch { /* skip unavailable adapters */ }
|
|
1365
|
+
}
|
|
1366
|
+
if (allRewards.length === 0) {
|
|
1367
|
+
return { success: true, tx: '', rewards: [], totalValueUsd: 0, usdcReceived: 0, gasCost: 0, gasMethod: 'none' };
|
|
1368
|
+
}
|
|
1369
|
+
const claimResult = await executeWithGas(this.client, this.keypair, async () => tx);
|
|
1370
|
+
await this.client.waitForTransaction({ digest: claimResult.digest });
|
|
1371
|
+
const usdcReceived = await this.swapRewardTokensToUsdc(allRewards);
|
|
1372
|
+
return {
|
|
1373
|
+
success: true,
|
|
1374
|
+
tx: claimResult.digest,
|
|
1375
|
+
rewards: allRewards,
|
|
1376
|
+
totalValueUsd: usdcReceived,
|
|
1377
|
+
usdcReceived,
|
|
1378
|
+
gasCost: claimResult.gasCostSui,
|
|
1379
|
+
gasMethod: claimResult.gasMethod,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
async swapRewardTokensToUsdc(rewards) {
|
|
1383
|
+
const uniqueTokens = [...new Set(rewards.map(r => r.coinType))];
|
|
1384
|
+
const usdcType = SUPPORTED_ASSETS.USDC.type;
|
|
1385
|
+
const usdcDecimals = SUPPORTED_ASSETS.USDC.decimals;
|
|
1386
|
+
let totalUsdc = 0;
|
|
1387
|
+
for (const coinType of uniqueTokens) {
|
|
1388
|
+
try {
|
|
1389
|
+
const balResult = await this.client.getBalance({
|
|
1390
|
+
owner: this._address,
|
|
1391
|
+
coinType,
|
|
1392
|
+
});
|
|
1393
|
+
const rawBalance = BigInt(balResult.totalBalance);
|
|
1394
|
+
if (rawBalance <= 0n)
|
|
1395
|
+
continue;
|
|
1396
|
+
const decimals = REWARD_TOKEN_DECIMALS[coinType] ?? 9;
|
|
1397
|
+
const swapResult = await buildRawSwapTx({
|
|
1398
|
+
client: this.client,
|
|
1399
|
+
address: this._address,
|
|
1400
|
+
fromCoinType: coinType,
|
|
1401
|
+
fromDecimals: decimals,
|
|
1402
|
+
toCoinType: usdcType,
|
|
1403
|
+
toDecimals: usdcDecimals,
|
|
1404
|
+
amount: rawBalance,
|
|
1405
|
+
});
|
|
1406
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => swapResult.tx);
|
|
1407
|
+
await this.client.waitForTransaction({ digest: gasResult.digest });
|
|
1408
|
+
totalUsdc += swapResult.estimatedOut / 10 ** usdcDecimals;
|
|
1409
|
+
}
|
|
1410
|
+
catch {
|
|
1411
|
+
// If swap fails for a token (e.g. no liquidity), skip it
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
return totalUsdc;
|
|
1415
|
+
}
|
|
1416
|
+
// -- Strategies --
|
|
1417
|
+
async investStrategy(params) {
|
|
1418
|
+
this.enforcer.assertNotLocked();
|
|
1419
|
+
const definition = this.strategies.get(params.strategy);
|
|
1420
|
+
this.strategies.validateMinAmount(definition.allocations, params.usdAmount);
|
|
1421
|
+
if (!params.usdAmount || params.usdAmount <= 0) {
|
|
1422
|
+
throw new T2000Error('INVALID_AMOUNT', 'Strategy investment must be > $0');
|
|
1423
|
+
}
|
|
1424
|
+
this.enforcer.check({ operation: 'invest', amount: params.usdAmount });
|
|
1425
|
+
const bal = await queryBalance(this.client, this._address);
|
|
1426
|
+
if (bal.available < params.usdAmount) {
|
|
1427
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', `Insufficient balance. Available: $${bal.available.toFixed(2)}, requested: $${params.usdAmount.toFixed(2)}`);
|
|
1428
|
+
}
|
|
1429
|
+
const buys = [];
|
|
1430
|
+
const allocEntries = Object.entries(definition.allocations);
|
|
1431
|
+
if (params.dryRun) {
|
|
1432
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1433
|
+
for (const [asset, pct] of allocEntries) {
|
|
1434
|
+
const assetUsd = params.usdAmount * (pct / 100);
|
|
1435
|
+
let estAmount = 0;
|
|
1436
|
+
let estPrice = 0;
|
|
1437
|
+
try {
|
|
1438
|
+
if (swapAdapter) {
|
|
1439
|
+
const quote = await swapAdapter.getQuote('USDC', asset, assetUsd);
|
|
1440
|
+
estAmount = quote.expectedOutput;
|
|
1441
|
+
estPrice = assetUsd / estAmount;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
catch { /* price unavailable */ }
|
|
1445
|
+
buys.push({ asset, usdAmount: assetUsd, amount: estAmount, price: estPrice, tx: '' });
|
|
1446
|
+
}
|
|
1447
|
+
return { success: true, strategy: params.strategy, totalInvested: params.usdAmount, buys, gasCost: 0, gasMethod: 'self-funded' };
|
|
1448
|
+
}
|
|
1449
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1450
|
+
if (!swapAdapter?.addSwapToTx) {
|
|
1451
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'Swap adapter does not support composable PTB');
|
|
1452
|
+
}
|
|
1453
|
+
let swapMetas = [];
|
|
1454
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
1455
|
+
swapMetas = [];
|
|
1456
|
+
const tx = new Transaction();
|
|
1457
|
+
tx.setSender(this._address);
|
|
1458
|
+
const usdcCoins = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
1459
|
+
if (usdcCoins.length === 0)
|
|
1460
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC coins found');
|
|
1461
|
+
const mergedUsdc = this._mergeCoinsInTx(tx, usdcCoins);
|
|
1462
|
+
const splitAmounts = allocEntries.map(([, pct]) => BigInt(Math.floor(params.usdAmount * (pct / 100) * 10 ** SUPPORTED_ASSETS.USDC.decimals)));
|
|
1463
|
+
const splitCoins = tx.splitCoins(mergedUsdc, splitAmounts);
|
|
1464
|
+
const outputCoins = [];
|
|
1465
|
+
for (let i = 0; i < allocEntries.length; i++) {
|
|
1466
|
+
const [asset] = allocEntries[i];
|
|
1467
|
+
const assetUsd = params.usdAmount * (allocEntries[i][1] / 100);
|
|
1468
|
+
const { outputCoin, estimatedOut, toDecimals } = await swapAdapter.addSwapToTx(tx, this._address, splitCoins[i], 'USDC', asset, assetUsd);
|
|
1469
|
+
outputCoins.push(outputCoin);
|
|
1470
|
+
swapMetas.push({ asset, usdAmount: assetUsd, estimatedOut, toDecimals });
|
|
1471
|
+
}
|
|
1472
|
+
tx.transferObjects(outputCoins, this._address);
|
|
1473
|
+
return tx;
|
|
1474
|
+
});
|
|
1475
|
+
const digest = gasResult.digest;
|
|
1476
|
+
const now = new Date().toISOString();
|
|
1477
|
+
for (const meta of swapMetas) {
|
|
1478
|
+
const amount = meta.estimatedOut / (10 ** meta.toDecimals);
|
|
1479
|
+
const price = meta.usdAmount / amount;
|
|
1480
|
+
this.portfolio.recordBuy({
|
|
1481
|
+
id: `inv_${Date.now()}_${meta.asset}`,
|
|
1482
|
+
type: 'buy',
|
|
1483
|
+
asset: meta.asset,
|
|
1484
|
+
amount,
|
|
1485
|
+
price,
|
|
1486
|
+
usdValue: meta.usdAmount,
|
|
1487
|
+
fee: 0,
|
|
1488
|
+
tx: digest,
|
|
1489
|
+
timestamp: now,
|
|
1490
|
+
});
|
|
1491
|
+
this.portfolio.recordStrategyBuy(params.strategy, {
|
|
1492
|
+
id: `strat_${Date.now()}_${meta.asset}`,
|
|
1493
|
+
type: 'buy',
|
|
1494
|
+
asset: meta.asset,
|
|
1495
|
+
amount,
|
|
1496
|
+
price,
|
|
1497
|
+
usdValue: meta.usdAmount,
|
|
1498
|
+
fee: 0,
|
|
1499
|
+
tx: digest,
|
|
1500
|
+
timestamp: now,
|
|
1501
|
+
});
|
|
1502
|
+
buys.push({ asset: meta.asset, usdAmount: meta.usdAmount, amount, price, tx: digest });
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
success: true,
|
|
1506
|
+
strategy: params.strategy,
|
|
1507
|
+
totalInvested: params.usdAmount,
|
|
1508
|
+
buys,
|
|
1509
|
+
gasCost: gasResult.gasCostSui,
|
|
1510
|
+
gasMethod: gasResult.gasMethod,
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
async sellStrategy(params) {
|
|
1514
|
+
this.enforcer.assertNotLocked();
|
|
1515
|
+
this.strategies.get(params.strategy);
|
|
1516
|
+
const stratPositions = this.portfolio.getStrategyPositions(params.strategy);
|
|
1517
|
+
if (stratPositions.length === 0) {
|
|
1518
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', `No positions in strategy '${params.strategy}'`);
|
|
1519
|
+
}
|
|
1520
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1521
|
+
if (!swapAdapter?.addSwapToTx) {
|
|
1522
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'Swap adapter does not support composable PTB');
|
|
1523
|
+
}
|
|
1524
|
+
// Phase 0: Unearn any earning assets so coins are in the wallet
|
|
1525
|
+
for (const pos of stratPositions) {
|
|
1526
|
+
const directPos = this.portfolio.getPosition(pos.asset);
|
|
1527
|
+
if (directPos?.earning && directPos.earningProtocol) {
|
|
1528
|
+
await this.investUnearn({ asset: pos.asset });
|
|
1529
|
+
// Wait for coins to settle
|
|
1530
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
let swapMetas = [];
|
|
1534
|
+
const buildSellPtb = async () => {
|
|
1535
|
+
swapMetas = [];
|
|
1536
|
+
const tx = new Transaction();
|
|
1537
|
+
tx.setSender(this._address);
|
|
1538
|
+
const usdcOutputs = [];
|
|
1539
|
+
for (const pos of stratPositions) {
|
|
1540
|
+
const assetInfo = SUPPORTED_ASSETS[pos.asset];
|
|
1541
|
+
const bal = await this.client.getBalance({ owner: this._address, coinType: assetInfo.type });
|
|
1542
|
+
const walletAmount = Number(bal.totalBalance) / (10 ** assetInfo.decimals);
|
|
1543
|
+
const gasReserve = pos.asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
1544
|
+
const sellAmount = Math.max(0, Math.min(pos.totalAmount, walletAmount) - gasReserve);
|
|
1545
|
+
if (sellAmount <= 0)
|
|
1546
|
+
continue;
|
|
1547
|
+
const rawAmount = BigInt(Math.floor(sellAmount * 10 ** assetInfo.decimals));
|
|
1548
|
+
let splitCoin;
|
|
1549
|
+
if (pos.asset === 'SUI') {
|
|
1550
|
+
[splitCoin] = tx.splitCoins(tx.gas, [rawAmount]);
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
const coins = await this._fetchCoins(assetInfo.type);
|
|
1554
|
+
if (coins.length === 0)
|
|
1555
|
+
continue;
|
|
1556
|
+
const merged = this._mergeCoinsInTx(tx, coins);
|
|
1557
|
+
[splitCoin] = tx.splitCoins(merged, [rawAmount]);
|
|
1558
|
+
}
|
|
1559
|
+
const slippageBps = LOW_LIQUIDITY_ASSETS.has(pos.asset) ? 500 : 300;
|
|
1560
|
+
const { outputCoin, estimatedOut, toDecimals } = await swapAdapter.addSwapToTx(tx, this._address, splitCoin, pos.asset, 'USDC', sellAmount, slippageBps);
|
|
1561
|
+
usdcOutputs.push(outputCoin);
|
|
1562
|
+
swapMetas.push({ asset: pos.asset, amount: sellAmount, estimatedOut, toDecimals });
|
|
1563
|
+
}
|
|
1564
|
+
if (usdcOutputs.length === 0) {
|
|
1565
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No assets available to sell');
|
|
1566
|
+
}
|
|
1567
|
+
if (usdcOutputs.length > 1) {
|
|
1568
|
+
tx.mergeCoins(usdcOutputs[0], usdcOutputs.slice(1));
|
|
1569
|
+
}
|
|
1570
|
+
tx.transferObjects([usdcOutputs[0]], this._address);
|
|
1571
|
+
return tx;
|
|
1572
|
+
};
|
|
1573
|
+
let gasResult;
|
|
1574
|
+
const MAX_RETRIES = 3;
|
|
1575
|
+
for (let attempt = 0;; attempt++) {
|
|
1576
|
+
try {
|
|
1577
|
+
gasResult = await executeWithGas(this.client, this.keypair, buildSellPtb);
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
catch (err) {
|
|
1581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1582
|
+
const isSlippage = msg.includes('slippage') || msg.includes('amount_out_slippage');
|
|
1583
|
+
if (isSlippage && attempt < MAX_RETRIES) {
|
|
1584
|
+
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const digest = gasResult.digest;
|
|
1591
|
+
const now = new Date().toISOString();
|
|
1592
|
+
const sells = [];
|
|
1593
|
+
let totalProceeds = 0;
|
|
1594
|
+
let totalPnL = 0;
|
|
1595
|
+
for (const meta of swapMetas) {
|
|
1596
|
+
const usdValue = meta.estimatedOut / (10 ** meta.toDecimals);
|
|
1597
|
+
const price = meta.amount > 0 ? usdValue / meta.amount : 0;
|
|
1598
|
+
const pnl = this.portfolio.recordStrategySell(params.strategy, {
|
|
1599
|
+
id: `strat_sell_${Date.now()}_${meta.asset}`,
|
|
1600
|
+
type: 'sell',
|
|
1601
|
+
asset: meta.asset,
|
|
1602
|
+
amount: meta.amount,
|
|
1603
|
+
price,
|
|
1604
|
+
usdValue,
|
|
1605
|
+
fee: 0,
|
|
1606
|
+
tx: digest,
|
|
1607
|
+
timestamp: now,
|
|
1608
|
+
});
|
|
1609
|
+
this.portfolio.recordSell({
|
|
1610
|
+
id: `inv_sell_${Date.now()}_${meta.asset}`,
|
|
1611
|
+
type: 'sell',
|
|
1612
|
+
asset: meta.asset,
|
|
1613
|
+
amount: meta.amount,
|
|
1614
|
+
price,
|
|
1615
|
+
usdValue,
|
|
1616
|
+
fee: 0,
|
|
1617
|
+
tx: digest,
|
|
1618
|
+
timestamp: now,
|
|
1619
|
+
});
|
|
1620
|
+
sells.push({ asset: meta.asset, amount: meta.amount, usdValue, realizedPnL: pnl, tx: digest });
|
|
1621
|
+
totalProceeds += usdValue;
|
|
1622
|
+
totalPnL += pnl;
|
|
1623
|
+
}
|
|
1624
|
+
// Clear any residual dust left in the strategy (gas/rounding differences)
|
|
1625
|
+
if (this.portfolio.hasStrategyPositions(params.strategy)) {
|
|
1626
|
+
this.portfolio.clearStrategy(params.strategy);
|
|
1627
|
+
}
|
|
1628
|
+
return {
|
|
1629
|
+
success: true,
|
|
1630
|
+
strategy: params.strategy,
|
|
1631
|
+
totalProceeds,
|
|
1632
|
+
realizedPnL: totalPnL,
|
|
1633
|
+
sells,
|
|
1634
|
+
gasCost: gasResult.gasCostSui,
|
|
1635
|
+
gasMethod: gasResult.gasMethod,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
async rebalanceStrategy(params) {
|
|
1639
|
+
this.enforcer.assertNotLocked();
|
|
1640
|
+
const definition = this.strategies.get(params.strategy);
|
|
1641
|
+
const stratPositions = this.portfolio.getStrategyPositions(params.strategy);
|
|
1642
|
+
if (stratPositions.length === 0) {
|
|
1643
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', `No positions in strategy '${params.strategy}'`);
|
|
1644
|
+
}
|
|
1645
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1646
|
+
const prices = {};
|
|
1647
|
+
for (const pos of stratPositions) {
|
|
1648
|
+
try {
|
|
1649
|
+
if (pos.asset === 'SUI' && swapAdapter) {
|
|
1650
|
+
prices[pos.asset] = await swapAdapter.getPoolPrice();
|
|
1651
|
+
}
|
|
1652
|
+
else if (swapAdapter) {
|
|
1653
|
+
const q = await swapAdapter.getQuote('USDC', pos.asset, 1);
|
|
1654
|
+
prices[pos.asset] = q.expectedOutput > 0 ? 1 / q.expectedOutput : 0;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
catch {
|
|
1658
|
+
prices[pos.asset] = 0;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
const totalValue = stratPositions.reduce((s, p) => s + p.totalAmount * (prices[p.asset] ?? 0), 0);
|
|
1662
|
+
if (totalValue <= 0) {
|
|
1663
|
+
throw new T2000Error('INSUFFICIENT_INVESTMENT', 'Strategy has no value to rebalance');
|
|
1664
|
+
}
|
|
1665
|
+
const currentWeights = {};
|
|
1666
|
+
const beforeWeights = {};
|
|
1667
|
+
for (const pos of stratPositions) {
|
|
1668
|
+
const w = ((pos.totalAmount * (prices[pos.asset] ?? 0)) / totalValue) * 100;
|
|
1669
|
+
currentWeights[pos.asset] = w;
|
|
1670
|
+
beforeWeights[pos.asset] = w;
|
|
1671
|
+
}
|
|
1672
|
+
const threshold = 3; // only rebalance if > 3% off
|
|
1673
|
+
// Classify each asset as a buy or sell
|
|
1674
|
+
const sellOps = [];
|
|
1675
|
+
const buyOps = [];
|
|
1676
|
+
for (const [asset, targetPct] of Object.entries(definition.allocations)) {
|
|
1677
|
+
const currentPct = currentWeights[asset] ?? 0;
|
|
1678
|
+
const diff = targetPct - currentPct;
|
|
1679
|
+
if (Math.abs(diff) < threshold)
|
|
1680
|
+
continue;
|
|
1681
|
+
const usdDiff = totalValue * (Math.abs(diff) / 100);
|
|
1682
|
+
if (usdDiff < 1)
|
|
1683
|
+
continue;
|
|
1684
|
+
if (diff > 0) {
|
|
1685
|
+
buyOps.push({ asset, usdAmount: usdDiff });
|
|
1686
|
+
}
|
|
1687
|
+
else {
|
|
1688
|
+
const price = prices[asset] ?? 1;
|
|
1689
|
+
const assetAmount = price > 0 ? usdDiff / price : 0;
|
|
1690
|
+
sellOps.push({ asset, usdAmount: usdDiff, assetAmount });
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (sellOps.length === 0 && buyOps.length === 0) {
|
|
1694
|
+
return { success: true, strategy: params.strategy, trades: [], beforeWeights, afterWeights: { ...beforeWeights }, targetWeights: { ...definition.allocations } };
|
|
1695
|
+
}
|
|
1696
|
+
if (!swapAdapter?.addSwapToTx) {
|
|
1697
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'Swap adapter does not support composable PTB');
|
|
1698
|
+
}
|
|
1699
|
+
// Execute all sells and buys in a single PTB
|
|
1700
|
+
const tradeMetas = [];
|
|
1701
|
+
const gasResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
1702
|
+
tradeMetas.length = 0;
|
|
1703
|
+
const tx = new Transaction();
|
|
1704
|
+
tx.setSender(this._address);
|
|
1705
|
+
const usdcCoins = [];
|
|
1706
|
+
// Phase 1: Sells (asset → USDC), collecting USDC output coins
|
|
1707
|
+
for (const sell of sellOps) {
|
|
1708
|
+
const assetInfo = SUPPORTED_ASSETS[sell.asset];
|
|
1709
|
+
const bal = await this.client.getBalance({ owner: this._address, coinType: assetInfo.type });
|
|
1710
|
+
const walletAmount = Number(bal.totalBalance) / (10 ** assetInfo.decimals);
|
|
1711
|
+
const gasReserve = sell.asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
1712
|
+
const sellAmount = Math.max(0, Math.min(sell.assetAmount, walletAmount) - gasReserve);
|
|
1713
|
+
if (sellAmount <= 0)
|
|
1714
|
+
continue;
|
|
1715
|
+
const rawAmount = BigInt(Math.floor(sellAmount * 10 ** assetInfo.decimals));
|
|
1716
|
+
let splitCoin;
|
|
1717
|
+
if (sell.asset === 'SUI') {
|
|
1718
|
+
[splitCoin] = tx.splitCoins(tx.gas, [rawAmount]);
|
|
1719
|
+
}
|
|
1720
|
+
else {
|
|
1721
|
+
const coins = await this._fetchCoins(assetInfo.type);
|
|
1722
|
+
if (coins.length === 0)
|
|
1723
|
+
continue;
|
|
1724
|
+
const merged = this._mergeCoinsInTx(tx, coins);
|
|
1725
|
+
[splitCoin] = tx.splitCoins(merged, [rawAmount]);
|
|
1726
|
+
}
|
|
1727
|
+
const slippageBps = LOW_LIQUIDITY_ASSETS.has(sell.asset) ? 500 : 300;
|
|
1728
|
+
const { outputCoin, estimatedOut, toDecimals } = await swapAdapter.addSwapToTx(tx, this._address, splitCoin, sell.asset, 'USDC', sellAmount, slippageBps);
|
|
1729
|
+
usdcCoins.push(outputCoin);
|
|
1730
|
+
tradeMetas.push({ action: 'sell', asset: sell.asset, usdAmount: sell.usdAmount, estimatedOut, toDecimals });
|
|
1731
|
+
}
|
|
1732
|
+
// Phase 2: Merge sell proceeds with wallet USDC for buys
|
|
1733
|
+
if (buyOps.length > 0) {
|
|
1734
|
+
const walletUsdc = await this._fetchCoins(SUPPORTED_ASSETS.USDC.type);
|
|
1735
|
+
if (walletUsdc.length > 0) {
|
|
1736
|
+
usdcCoins.push(this._mergeCoinsInTx(tx, walletUsdc));
|
|
1737
|
+
}
|
|
1738
|
+
if (usdcCoins.length === 0) {
|
|
1739
|
+
throw new T2000Error('INSUFFICIENT_BALANCE', 'No USDC available for rebalance buys');
|
|
1740
|
+
}
|
|
1741
|
+
// Merge all USDC into one coin
|
|
1742
|
+
if (usdcCoins.length > 1) {
|
|
1743
|
+
tx.mergeCoins(usdcCoins[0], usdcCoins.slice(1));
|
|
1744
|
+
}
|
|
1745
|
+
const mergedUsdc = usdcCoins[0];
|
|
1746
|
+
// Phase 3: Buys (USDC → asset)
|
|
1747
|
+
const splitAmounts = buyOps.map(b => BigInt(Math.floor(b.usdAmount * 10 ** SUPPORTED_ASSETS.USDC.decimals)));
|
|
1748
|
+
const splitCoins = tx.splitCoins(mergedUsdc, splitAmounts);
|
|
1749
|
+
const outputCoins = [];
|
|
1750
|
+
for (let i = 0; i < buyOps.length; i++) {
|
|
1751
|
+
const buy = buyOps[i];
|
|
1752
|
+
const slippageBps = LOW_LIQUIDITY_ASSETS.has(buy.asset) ? 500 : 300;
|
|
1753
|
+
const { outputCoin, estimatedOut, toDecimals } = await swapAdapter.addSwapToTx(tx, this._address, splitCoins[i], 'USDC', buy.asset, buy.usdAmount, slippageBps);
|
|
1754
|
+
outputCoins.push(outputCoin);
|
|
1755
|
+
tradeMetas.push({ action: 'buy', asset: buy.asset, usdAmount: buy.usdAmount, estimatedOut, toDecimals });
|
|
1756
|
+
}
|
|
1757
|
+
tx.transferObjects(outputCoins, this._address);
|
|
1758
|
+
}
|
|
1759
|
+
return tx;
|
|
1760
|
+
});
|
|
1761
|
+
const digest = gasResult.digest;
|
|
1762
|
+
const now = new Date().toISOString();
|
|
1763
|
+
const trades = [];
|
|
1764
|
+
for (const meta of tradeMetas) {
|
|
1765
|
+
const rawAmount = meta.estimatedOut / (10 ** meta.toDecimals);
|
|
1766
|
+
if (meta.action === 'sell') {
|
|
1767
|
+
const price = meta.usdAmount > 0 && rawAmount > 0 ? meta.usdAmount / rawAmount : prices[meta.asset] ?? 0;
|
|
1768
|
+
const assetAmount = prices[meta.asset] > 0 ? meta.usdAmount / prices[meta.asset] : 0;
|
|
1769
|
+
this.portfolio.recordStrategySell(params.strategy, {
|
|
1770
|
+
id: `strat_rebal_${Date.now()}_${meta.asset}`,
|
|
1771
|
+
type: 'sell',
|
|
1772
|
+
asset: meta.asset,
|
|
1773
|
+
amount: assetAmount,
|
|
1774
|
+
price,
|
|
1775
|
+
usdValue: meta.usdAmount,
|
|
1776
|
+
fee: 0,
|
|
1777
|
+
tx: digest,
|
|
1778
|
+
timestamp: now,
|
|
1779
|
+
});
|
|
1780
|
+
this.portfolio.recordSell({
|
|
1781
|
+
id: `inv_rebal_${Date.now()}_${meta.asset}`,
|
|
1782
|
+
type: 'sell',
|
|
1783
|
+
asset: meta.asset,
|
|
1784
|
+
amount: assetAmount,
|
|
1785
|
+
price,
|
|
1786
|
+
usdValue: meta.usdAmount,
|
|
1787
|
+
fee: 0,
|
|
1788
|
+
tx: digest,
|
|
1789
|
+
timestamp: now,
|
|
1790
|
+
});
|
|
1791
|
+
trades.push({ action: 'sell', asset: meta.asset, usdAmount: meta.usdAmount, amount: assetAmount, tx: digest });
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
const amount = rawAmount;
|
|
1795
|
+
const price = meta.usdAmount / amount;
|
|
1796
|
+
this.portfolio.recordBuy({
|
|
1797
|
+
id: `inv_rebal_${Date.now()}_${meta.asset}`,
|
|
1798
|
+
type: 'buy',
|
|
1799
|
+
asset: meta.asset,
|
|
1800
|
+
amount,
|
|
1801
|
+
price,
|
|
1802
|
+
usdValue: meta.usdAmount,
|
|
1803
|
+
fee: 0,
|
|
1804
|
+
tx: digest,
|
|
1805
|
+
timestamp: now,
|
|
1806
|
+
});
|
|
1807
|
+
this.portfolio.recordStrategyBuy(params.strategy, {
|
|
1808
|
+
id: `strat_rebal_${Date.now()}_${meta.asset}`,
|
|
1809
|
+
type: 'buy',
|
|
1810
|
+
asset: meta.asset,
|
|
1811
|
+
amount,
|
|
1812
|
+
price,
|
|
1813
|
+
usdValue: meta.usdAmount,
|
|
1814
|
+
fee: 0,
|
|
1815
|
+
tx: digest,
|
|
1816
|
+
timestamp: now,
|
|
1817
|
+
});
|
|
1818
|
+
trades.push({ action: 'buy', asset: meta.asset, usdAmount: meta.usdAmount, amount, tx: digest });
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
const afterWeights = {};
|
|
1822
|
+
const updatedPositions = this.portfolio.getStrategyPositions(params.strategy);
|
|
1823
|
+
const newTotal = updatedPositions.reduce((s, p) => s + p.totalAmount * (prices[p.asset] ?? 0), 0);
|
|
1824
|
+
for (const p of updatedPositions) {
|
|
1825
|
+
afterWeights[p.asset] = newTotal > 0 ? ((p.totalAmount * (prices[p.asset] ?? 0)) / newTotal) * 100 : 0;
|
|
1826
|
+
}
|
|
1827
|
+
return { success: true, strategy: params.strategy, trades, beforeWeights, afterWeights, targetWeights: { ...definition.allocations } };
|
|
1828
|
+
}
|
|
1829
|
+
async getStrategyStatus(name) {
|
|
1830
|
+
const definition = this.strategies.get(name);
|
|
1831
|
+
const stratPositions = this.portfolio.getStrategyPositions(name);
|
|
1832
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1833
|
+
const prices = {};
|
|
1834
|
+
for (const asset of Object.keys(definition.allocations)) {
|
|
1835
|
+
try {
|
|
1836
|
+
if (asset === 'SUI' && swapAdapter) {
|
|
1837
|
+
prices[asset] = await swapAdapter.getPoolPrice();
|
|
1838
|
+
}
|
|
1839
|
+
else if (swapAdapter) {
|
|
1840
|
+
const q = await swapAdapter.getQuote('USDC', asset, 1);
|
|
1841
|
+
prices[asset] = q.expectedOutput > 0 ? 1 / q.expectedOutput : 0;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
catch {
|
|
1845
|
+
prices[asset] = 0;
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
const positions = stratPositions.map((sp) => {
|
|
1849
|
+
const price = prices[sp.asset] ?? 0;
|
|
1850
|
+
const currentValue = sp.totalAmount * price;
|
|
1851
|
+
const pnl = currentValue - sp.costBasis;
|
|
1852
|
+
return {
|
|
1853
|
+
asset: sp.asset,
|
|
1854
|
+
totalAmount: sp.totalAmount,
|
|
1855
|
+
costBasis: sp.costBasis,
|
|
1856
|
+
avgPrice: sp.avgPrice,
|
|
1857
|
+
currentPrice: price,
|
|
1858
|
+
currentValue,
|
|
1859
|
+
unrealizedPnL: pnl,
|
|
1860
|
+
unrealizedPnLPct: sp.costBasis > 0 ? (pnl / sp.costBasis) * 100 : 0,
|
|
1861
|
+
trades: sp.trades,
|
|
1862
|
+
};
|
|
1863
|
+
});
|
|
1864
|
+
const totalValue = positions.reduce((s, p) => s + p.currentValue, 0);
|
|
1865
|
+
const currentWeights = {};
|
|
1866
|
+
for (const p of positions) {
|
|
1867
|
+
currentWeights[p.asset] = totalValue > 0 ? (p.currentValue / totalValue) * 100 : 0;
|
|
1868
|
+
}
|
|
1869
|
+
return { definition, positions, currentWeights, totalValue };
|
|
1870
|
+
}
|
|
1871
|
+
// -- Auto-Invest --
|
|
1872
|
+
setupAutoInvest(params) {
|
|
1873
|
+
if (params.strategy)
|
|
1874
|
+
this.strategies.get(params.strategy);
|
|
1875
|
+
if (params.asset && !(params.asset in INVESTMENT_ASSETS)) {
|
|
1876
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `${params.asset} is not an investment asset`);
|
|
1877
|
+
}
|
|
1878
|
+
return this.autoInvest.setup(params);
|
|
1879
|
+
}
|
|
1880
|
+
getAutoInvestStatus() {
|
|
1881
|
+
return this.autoInvest.getStatus();
|
|
1882
|
+
}
|
|
1883
|
+
async runAutoInvest() {
|
|
1884
|
+
this.enforcer.assertNotLocked();
|
|
1885
|
+
const status = this.autoInvest.getStatus();
|
|
1886
|
+
const executed = [];
|
|
1887
|
+
const skipped = [];
|
|
1888
|
+
for (const schedule of status.pendingRuns) {
|
|
1889
|
+
try {
|
|
1890
|
+
const bal = await queryBalance(this.client, this._address);
|
|
1891
|
+
if (bal.available < schedule.amount) {
|
|
1892
|
+
skipped.push({ scheduleId: schedule.id, reason: `Insufficient balance ($${bal.available.toFixed(2)} < $${schedule.amount})` });
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
if (schedule.strategy) {
|
|
1896
|
+
const result = await this.investStrategy({ strategy: schedule.strategy, usdAmount: schedule.amount });
|
|
1897
|
+
this.autoInvest.recordRun(schedule.id, schedule.amount);
|
|
1898
|
+
executed.push({ scheduleId: schedule.id, strategy: schedule.strategy, amount: schedule.amount, result });
|
|
1899
|
+
}
|
|
1900
|
+
else if (schedule.asset) {
|
|
1901
|
+
const result = await this.investBuy({ asset: schedule.asset, usdAmount: schedule.amount });
|
|
1902
|
+
this.autoInvest.recordRun(schedule.id, schedule.amount);
|
|
1903
|
+
executed.push({ scheduleId: schedule.id, asset: schedule.asset, amount: schedule.amount, result });
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
catch (err) {
|
|
1907
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1908
|
+
skipped.push({ scheduleId: schedule.id, reason: msg });
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return { executed, skipped };
|
|
1912
|
+
}
|
|
1913
|
+
stopAutoInvest(id) {
|
|
1914
|
+
this.autoInvest.stop(id);
|
|
1915
|
+
}
|
|
1916
|
+
async getPortfolio() {
|
|
1917
|
+
const positions = this.portfolio.getPositions();
|
|
1918
|
+
const realizedPnL = this.portfolio.getRealizedPnL();
|
|
1919
|
+
const prices = {};
|
|
1920
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
1921
|
+
for (const asset of Object.keys(INVESTMENT_ASSETS)) {
|
|
1922
|
+
try {
|
|
1923
|
+
if (asset === 'SUI' && swapAdapter) {
|
|
1924
|
+
prices[asset] = await swapAdapter.getPoolPrice();
|
|
1925
|
+
}
|
|
1926
|
+
else if (swapAdapter) {
|
|
1927
|
+
const quote = await swapAdapter.getQuote('USDC', asset, 1);
|
|
1928
|
+
prices[asset] = quote.expectedOutput > 0 ? 1 / quote.expectedOutput : 0;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
catch {
|
|
1932
|
+
prices[asset] = 0;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
const enrichPosition = async (pos, adjustWallet) => {
|
|
1936
|
+
const currentPrice = prices[pos.asset] ?? 0;
|
|
1937
|
+
let totalAmount = pos.totalAmount;
|
|
1938
|
+
let costBasis = pos.costBasis;
|
|
1939
|
+
if (adjustWallet && pos.asset in INVESTMENT_ASSETS && !pos.earning) {
|
|
1940
|
+
try {
|
|
1941
|
+
const assetInfo = SUPPORTED_ASSETS[pos.asset];
|
|
1942
|
+
const bal = await this.client.getBalance({ owner: this._address, coinType: assetInfo.type });
|
|
1943
|
+
const walletAmount = Number(bal.totalBalance) / (10 ** assetInfo.decimals);
|
|
1944
|
+
const gasReserve = pos.asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
1945
|
+
const actualHeld = Math.max(0, walletAmount - gasReserve);
|
|
1946
|
+
if (actualHeld < totalAmount) {
|
|
1947
|
+
const ratio = totalAmount > 0 ? actualHeld / totalAmount : 0;
|
|
1948
|
+
costBasis *= ratio;
|
|
1949
|
+
totalAmount = actualHeld;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
catch { /* keep tracked values */ }
|
|
1953
|
+
}
|
|
1954
|
+
const currentValue = totalAmount * currentPrice;
|
|
1955
|
+
const unrealizedPnL = currentPrice > 0 ? currentValue - costBasis : 0;
|
|
1956
|
+
const unrealizedPnLPct = currentPrice > 0 && costBasis > 0 ? (unrealizedPnL / costBasis) * 100 : 0;
|
|
1957
|
+
return {
|
|
1958
|
+
asset: pos.asset, totalAmount, costBasis, avgPrice: pos.avgPrice,
|
|
1959
|
+
currentPrice, currentValue, unrealizedPnL, unrealizedPnLPct,
|
|
1960
|
+
trades: pos.trades, earning: pos.earning, earningProtocol: pos.earningProtocol, earningApy: pos.earningApy,
|
|
1961
|
+
};
|
|
1962
|
+
};
|
|
1963
|
+
const enriched = [];
|
|
1964
|
+
for (const pos of positions) {
|
|
1965
|
+
enriched.push(await enrichPosition(pos, true));
|
|
1966
|
+
}
|
|
1967
|
+
const strategyPositions = {};
|
|
1968
|
+
for (const key of this.portfolio.getAllStrategyKeys()) {
|
|
1969
|
+
const sps = this.portfolio.getStrategyPositions(key);
|
|
1970
|
+
const enrichedStrat = [];
|
|
1971
|
+
for (const sp of sps) {
|
|
1972
|
+
enrichedStrat.push(await enrichPosition(sp, false));
|
|
1973
|
+
}
|
|
1974
|
+
if (enrichedStrat.length > 0) {
|
|
1975
|
+
strategyPositions[key] = enrichedStrat;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
const allPositions = [...enriched, ...Object.values(strategyPositions).flat()];
|
|
1979
|
+
const totalInvested = allPositions.reduce((sum, p) => sum + p.costBasis, 0);
|
|
1980
|
+
const totalValue = allPositions.reduce((sum, p) => sum + p.currentValue, 0);
|
|
1981
|
+
const totalUnrealizedPnL = totalValue - totalInvested;
|
|
1982
|
+
const totalUnrealizedPnLPct = totalInvested > 0 ? (totalUnrealizedPnL / totalInvested) * 100 : 0;
|
|
1983
|
+
const result = {
|
|
1984
|
+
positions: enriched,
|
|
1985
|
+
totalInvested,
|
|
1986
|
+
totalValue,
|
|
1987
|
+
unrealizedPnL: totalUnrealizedPnL,
|
|
1988
|
+
unrealizedPnLPct: totalUnrealizedPnLPct,
|
|
1989
|
+
realizedPnL,
|
|
1990
|
+
};
|
|
1991
|
+
if (Object.keys(strategyPositions).length > 0) {
|
|
1992
|
+
result.strategyPositions = strategyPositions;
|
|
1993
|
+
}
|
|
1994
|
+
return result;
|
|
1995
|
+
}
|
|
1996
|
+
// -- Info --
|
|
1997
|
+
async positions() {
|
|
1998
|
+
const allPositions = await this.registry.allPositions(this._address);
|
|
1999
|
+
const positions = allPositions.flatMap(p => [
|
|
2000
|
+
...p.positions.supplies
|
|
2001
|
+
.filter(s => s.amount > 0.005)
|
|
2002
|
+
.map(s => ({
|
|
2003
|
+
protocol: p.protocolId,
|
|
2004
|
+
asset: s.asset,
|
|
2005
|
+
type: 'save',
|
|
2006
|
+
amount: s.amount,
|
|
2007
|
+
apy: s.apy,
|
|
2008
|
+
})),
|
|
2009
|
+
...p.positions.borrows
|
|
2010
|
+
.filter(b => b.amount > 0.005)
|
|
2011
|
+
.map(b => ({
|
|
2012
|
+
protocol: p.protocolId,
|
|
2013
|
+
asset: b.asset,
|
|
2014
|
+
type: 'borrow',
|
|
2015
|
+
amount: b.amount,
|
|
2016
|
+
apy: b.apy,
|
|
2017
|
+
})),
|
|
2018
|
+
]);
|
|
2019
|
+
return { positions };
|
|
2020
|
+
}
|
|
2021
|
+
async rates() {
|
|
2022
|
+
const allRatesResult = await this.registry.allRatesAcrossAssets();
|
|
2023
|
+
const result = {};
|
|
2024
|
+
for (const entry of allRatesResult) {
|
|
2025
|
+
if (!result[entry.asset] || entry.rates.saveApy > result[entry.asset].saveApy) {
|
|
2026
|
+
result[entry.asset] = { saveApy: entry.rates.saveApy, borrowApy: entry.rates.borrowApy };
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
if (!result.USDC)
|
|
2030
|
+
result.USDC = { saveApy: 0, borrowApy: 0 };
|
|
2031
|
+
return result;
|
|
2032
|
+
}
|
|
2033
|
+
async allRates(asset = 'USDC') {
|
|
2034
|
+
return this.registry.allRates(asset);
|
|
2035
|
+
}
|
|
2036
|
+
async allRatesAcrossAssets() {
|
|
2037
|
+
return this.registry.allRatesAcrossAssets();
|
|
2038
|
+
}
|
|
2039
|
+
async rebalance(opts = {}) {
|
|
2040
|
+
this.enforcer.assertNotLocked();
|
|
2041
|
+
const dryRun = opts.dryRun ?? false;
|
|
2042
|
+
const minYieldDiff = opts.minYieldDiff ?? 0.5;
|
|
2043
|
+
const maxBreakEven = opts.maxBreakEven ?? 30;
|
|
2044
|
+
const [allPositions, allRates] = await Promise.all([
|
|
2045
|
+
this.registry.allPositions(this._address),
|
|
2046
|
+
this.registry.allRatesAcrossAssets(),
|
|
2047
|
+
]);
|
|
2048
|
+
const earningAssets = new Set(this.portfolio.getPositions().filter(p => p.earning).map(p => p.asset));
|
|
2049
|
+
const savePositions = allPositions.flatMap(p => p.positions.supplies
|
|
2050
|
+
.filter(s => s.amount > 0.01)
|
|
2051
|
+
.filter(s => !earningAssets.has(s.asset))
|
|
2052
|
+
.filter(s => !(s.asset in INVESTMENT_ASSETS))
|
|
2053
|
+
.map(s => ({
|
|
2054
|
+
protocolId: p.protocolId,
|
|
2055
|
+
protocol: p.protocol,
|
|
2056
|
+
asset: s.asset,
|
|
2057
|
+
amount: s.amount,
|
|
2058
|
+
apy: s.apy,
|
|
2059
|
+
})));
|
|
2060
|
+
if (savePositions.length === 0) {
|
|
2061
|
+
throw new T2000Error('NO_COLLATERAL', 'No savings positions to rebalance. Use `t2000 save <amount>` first.');
|
|
2062
|
+
}
|
|
2063
|
+
const borrowPositions = allPositions.flatMap(p => p.positions.borrows.filter(b => b.amount > 0.01));
|
|
2064
|
+
if (borrowPositions.length > 0) {
|
|
2065
|
+
const healthResults = await Promise.all(allPositions
|
|
2066
|
+
.filter(p => p.positions.borrows.some(b => b.amount > 0.01))
|
|
2067
|
+
.map(async (p) => {
|
|
2068
|
+
const adapter = this.registry.getLending(p.protocolId);
|
|
2069
|
+
if (!adapter)
|
|
2070
|
+
return null;
|
|
2071
|
+
return adapter.getHealth(this._address);
|
|
2072
|
+
}));
|
|
2073
|
+
for (const hf of healthResults) {
|
|
2074
|
+
if (hf && hf.healthFactor < 1.5) {
|
|
2075
|
+
throw new T2000Error('HEALTH_FACTOR_TOO_LOW', `Cannot rebalance — health factor is ${hf.healthFactor.toFixed(2)} (minimum 1.5). Repay some debt first.`, { healthFactor: hf.healthFactor });
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
const bestRate = allRates.reduce((best, r) => r.rates.saveApy > best.rates.saveApy ? r : best);
|
|
2080
|
+
const current = savePositions.reduce((worst, p) => p.apy < worst.apy ? p : worst);
|
|
2081
|
+
const withdrawAdapter = this.registry.getLending(current.protocolId);
|
|
2082
|
+
if (withdrawAdapter) {
|
|
2083
|
+
try {
|
|
2084
|
+
const maxResult = await withdrawAdapter.maxWithdraw(this._address, current.asset);
|
|
2085
|
+
if (maxResult.maxAmount < current.amount) {
|
|
2086
|
+
current.amount = Math.max(0, maxResult.maxAmount - 0.01);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
catch { /* fall through with full amount */ }
|
|
2090
|
+
}
|
|
2091
|
+
if (current.amount <= 0.01) {
|
|
2092
|
+
throw new T2000Error('HEALTH_FACTOR_TOO_LOW', 'Cannot rebalance — active borrows prevent safe withdrawal. Repay some debt first.');
|
|
2093
|
+
}
|
|
2094
|
+
const apyDiff = bestRate.rates.saveApy - current.apy;
|
|
2095
|
+
const isSameProtocol = current.protocolId === bestRate.protocolId;
|
|
2096
|
+
const isSameAsset = current.asset === bestRate.asset;
|
|
2097
|
+
if (apyDiff < minYieldDiff) {
|
|
2098
|
+
return {
|
|
2099
|
+
executed: false,
|
|
2100
|
+
steps: [],
|
|
2101
|
+
fromProtocol: current.protocol,
|
|
2102
|
+
fromAsset: current.asset,
|
|
2103
|
+
toProtocol: bestRate.protocol,
|
|
2104
|
+
toAsset: bestRate.asset,
|
|
2105
|
+
amount: current.amount,
|
|
2106
|
+
currentApy: current.apy,
|
|
2107
|
+
newApy: bestRate.rates.saveApy,
|
|
2108
|
+
annualGain: (current.amount * apyDiff) / 100,
|
|
2109
|
+
estimatedSwapCost: 0,
|
|
2110
|
+
breakEvenDays: Infinity,
|
|
2111
|
+
txDigests: [],
|
|
2112
|
+
totalGasCost: 0,
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
if (isSameProtocol && isSameAsset) {
|
|
2116
|
+
return {
|
|
2117
|
+
executed: false,
|
|
2118
|
+
steps: [],
|
|
2119
|
+
fromProtocol: current.protocol,
|
|
2120
|
+
fromAsset: current.asset,
|
|
2121
|
+
toProtocol: bestRate.protocol,
|
|
2122
|
+
toAsset: bestRate.asset,
|
|
2123
|
+
amount: current.amount,
|
|
2124
|
+
currentApy: current.apy,
|
|
2125
|
+
newApy: bestRate.rates.saveApy,
|
|
2126
|
+
annualGain: 0,
|
|
2127
|
+
estimatedSwapCost: 0,
|
|
2128
|
+
breakEvenDays: Infinity,
|
|
2129
|
+
txDigests: [],
|
|
2130
|
+
totalGasCost: 0,
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
const steps = [];
|
|
2134
|
+
let estimatedSwapCost = 0;
|
|
2135
|
+
steps.push({
|
|
2136
|
+
action: 'withdraw',
|
|
2137
|
+
protocol: current.protocolId,
|
|
2138
|
+
fromAsset: current.asset,
|
|
2139
|
+
amount: current.amount,
|
|
2140
|
+
});
|
|
2141
|
+
let amountToDeposit = current.amount;
|
|
2142
|
+
if (!isSameAsset) {
|
|
2143
|
+
try {
|
|
2144
|
+
const quote = await this.registry.bestSwapQuote(current.asset, bestRate.asset, current.amount);
|
|
2145
|
+
amountToDeposit = quote.quote.expectedOutput;
|
|
2146
|
+
estimatedSwapCost = Math.abs(current.amount - amountToDeposit);
|
|
2147
|
+
}
|
|
2148
|
+
catch {
|
|
2149
|
+
estimatedSwapCost = current.amount * 0.003;
|
|
2150
|
+
amountToDeposit = current.amount - estimatedSwapCost;
|
|
2151
|
+
}
|
|
2152
|
+
steps.push({
|
|
2153
|
+
action: 'swap',
|
|
2154
|
+
fromAsset: current.asset,
|
|
2155
|
+
toAsset: bestRate.asset,
|
|
2156
|
+
amount: current.amount,
|
|
2157
|
+
estimatedOutput: amountToDeposit,
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
steps.push({
|
|
2161
|
+
action: 'deposit',
|
|
2162
|
+
protocol: bestRate.protocolId,
|
|
2163
|
+
toAsset: bestRate.asset,
|
|
2164
|
+
amount: amountToDeposit,
|
|
2165
|
+
});
|
|
2166
|
+
const annualGain = (amountToDeposit * apyDiff) / 100;
|
|
2167
|
+
const breakEvenDays = estimatedSwapCost > 0 ? Math.ceil((estimatedSwapCost / annualGain) * 365) : 0;
|
|
2168
|
+
if (breakEvenDays > maxBreakEven && estimatedSwapCost > 0) {
|
|
2169
|
+
return {
|
|
2170
|
+
executed: false,
|
|
2171
|
+
steps,
|
|
2172
|
+
fromProtocol: current.protocol,
|
|
2173
|
+
fromAsset: current.asset,
|
|
2174
|
+
toProtocol: bestRate.protocol,
|
|
2175
|
+
toAsset: bestRate.asset,
|
|
2176
|
+
amount: current.amount,
|
|
2177
|
+
currentApy: current.apy,
|
|
2178
|
+
newApy: bestRate.rates.saveApy,
|
|
2179
|
+
annualGain,
|
|
2180
|
+
estimatedSwapCost,
|
|
2181
|
+
breakEvenDays,
|
|
2182
|
+
txDigests: [],
|
|
2183
|
+
totalGasCost: 0,
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
if (dryRun) {
|
|
2187
|
+
return {
|
|
2188
|
+
executed: false,
|
|
2189
|
+
steps,
|
|
2190
|
+
fromProtocol: current.protocol,
|
|
2191
|
+
fromAsset: current.asset,
|
|
2192
|
+
toProtocol: bestRate.protocol,
|
|
2193
|
+
toAsset: bestRate.asset,
|
|
2194
|
+
amount: current.amount,
|
|
2195
|
+
currentApy: current.apy,
|
|
2196
|
+
newApy: bestRate.rates.saveApy,
|
|
2197
|
+
annualGain,
|
|
2198
|
+
estimatedSwapCost,
|
|
2199
|
+
breakEvenDays,
|
|
2200
|
+
txDigests: [],
|
|
2201
|
+
totalGasCost: 0,
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
if (!withdrawAdapter)
|
|
2205
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', `Protocol ${current.protocolId} not found`);
|
|
2206
|
+
const depositAdapter = this.registry.getLending(bestRate.protocolId);
|
|
2207
|
+
if (!depositAdapter)
|
|
2208
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', `Protocol ${bestRate.protocolId} not found`);
|
|
2209
|
+
const canComposePTB = withdrawAdapter.addWithdrawToTx && depositAdapter.addSaveToTx &&
|
|
2210
|
+
(isSameAsset || this.registry.listSwap()[0]?.addSwapToTx);
|
|
2211
|
+
let txDigests;
|
|
2212
|
+
let totalGasCost;
|
|
2213
|
+
if (canComposePTB) {
|
|
2214
|
+
const result = await executeWithGas(this.client, this.keypair, async () => {
|
|
2215
|
+
const tx = new Transaction();
|
|
2216
|
+
tx.setSender(this._address);
|
|
2217
|
+
const { coin: withdrawnCoin, effectiveAmount } = await withdrawAdapter.addWithdrawToTx(tx, this._address, current.amount, current.asset);
|
|
2218
|
+
amountToDeposit = effectiveAmount;
|
|
2219
|
+
let depositCoin = withdrawnCoin;
|
|
2220
|
+
if (!isSameAsset) {
|
|
2221
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
2222
|
+
const { outputCoin, estimatedOut, toDecimals } = await swapAdapter.addSwapToTx(tx, this._address, withdrawnCoin, current.asset, bestRate.asset, amountToDeposit);
|
|
2223
|
+
depositCoin = outputCoin;
|
|
2224
|
+
amountToDeposit = estimatedOut / 10 ** toDecimals;
|
|
2225
|
+
}
|
|
2226
|
+
await depositAdapter.addSaveToTx(tx, this._address, depositCoin, bestRate.asset, { collectFee: bestRate.asset === 'USDC' });
|
|
2227
|
+
return tx;
|
|
2228
|
+
});
|
|
2229
|
+
txDigests = [result.digest];
|
|
2230
|
+
totalGasCost = result.gasCostSui;
|
|
2231
|
+
}
|
|
2232
|
+
else {
|
|
2233
|
+
txDigests = [];
|
|
2234
|
+
totalGasCost = 0;
|
|
2235
|
+
const withdrawResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
2236
|
+
const built = await withdrawAdapter.buildWithdrawTx(this._address, current.amount, current.asset);
|
|
2237
|
+
amountToDeposit = built.effectiveAmount;
|
|
2238
|
+
return built.tx;
|
|
2239
|
+
});
|
|
2240
|
+
txDigests.push(withdrawResult.digest);
|
|
2241
|
+
totalGasCost += withdrawResult.gasCostSui;
|
|
2242
|
+
if (!isSameAsset) {
|
|
2243
|
+
const swapAdapter = this.registry.listSwap()[0];
|
|
2244
|
+
if (!swapAdapter)
|
|
2245
|
+
throw new T2000Error('PROTOCOL_UNAVAILABLE', 'No swap adapter available');
|
|
2246
|
+
const swapResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
2247
|
+
const built = await swapAdapter.buildSwapTx(this._address, current.asset, bestRate.asset, amountToDeposit);
|
|
2248
|
+
amountToDeposit = built.estimatedOut / 10 ** built.toDecimals;
|
|
2249
|
+
return built.tx;
|
|
2250
|
+
});
|
|
2251
|
+
txDigests.push(swapResult.digest);
|
|
2252
|
+
totalGasCost += swapResult.gasCostSui;
|
|
2253
|
+
}
|
|
2254
|
+
const depositResult = await executeWithGas(this.client, this.keypair, async () => {
|
|
2255
|
+
const { tx } = await depositAdapter.buildSaveTx(this._address, amountToDeposit, bestRate.asset, { collectFee: bestRate.asset === 'USDC' });
|
|
2256
|
+
return tx;
|
|
2257
|
+
});
|
|
2258
|
+
txDigests.push(depositResult.digest);
|
|
2259
|
+
totalGasCost += depositResult.gasCostSui;
|
|
2260
|
+
}
|
|
2261
|
+
return {
|
|
2262
|
+
executed: true,
|
|
2263
|
+
steps,
|
|
2264
|
+
fromProtocol: current.protocol,
|
|
2265
|
+
fromAsset: current.asset,
|
|
2266
|
+
toProtocol: bestRate.protocol,
|
|
2267
|
+
toAsset: bestRate.asset,
|
|
2268
|
+
amount: current.amount,
|
|
2269
|
+
currentApy: current.apy,
|
|
2270
|
+
newApy: bestRate.rates.saveApy,
|
|
2271
|
+
annualGain,
|
|
2272
|
+
estimatedSwapCost,
|
|
2273
|
+
breakEvenDays,
|
|
2274
|
+
txDigests,
|
|
2275
|
+
totalGasCost,
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
async earnings() {
|
|
2279
|
+
const result = await yieldTracker.getEarnings(this.client, this.keypair);
|
|
2280
|
+
if (result.totalYieldEarned > 0) {
|
|
2281
|
+
this.emit('yield', {
|
|
2282
|
+
earned: result.dailyEarning,
|
|
2283
|
+
total: result.totalYieldEarned,
|
|
2284
|
+
apy: result.currentApy / 100,
|
|
2285
|
+
timestamp: Date.now(),
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
return result;
|
|
2289
|
+
}
|
|
2290
|
+
async fundStatus() {
|
|
2291
|
+
return yieldTracker.getFundStatus(this.client, this.keypair);
|
|
2292
|
+
}
|
|
2293
|
+
// -- Sentinel --
|
|
2294
|
+
async sentinelList() {
|
|
2295
|
+
return sentinel.listSentinels();
|
|
2296
|
+
}
|
|
2297
|
+
async sentinelInfo(id) {
|
|
2298
|
+
return sentinel.getSentinelInfo(this.client, id);
|
|
2299
|
+
}
|
|
2300
|
+
async sentinelAttack(id, prompt, fee) {
|
|
2301
|
+
this.enforcer.check({ operation: 'sentinel', amount: fee ? Number(fee) / 1e9 : 0.1 });
|
|
2302
|
+
return sentinel.attack(this.client, this.keypair, id, prompt, fee);
|
|
2303
|
+
}
|
|
2304
|
+
// -- Helpers --
|
|
2305
|
+
async getFreeBalance(asset) {
|
|
2306
|
+
if (!(asset in INVESTMENT_ASSETS))
|
|
2307
|
+
return Infinity;
|
|
2308
|
+
// Strategy buys record to BOTH direct and strategy positions, so use
|
|
2309
|
+
// max(direct, strategyTotal) to avoid double-counting the overlap.
|
|
2310
|
+
const pos = this.portfolio.getPosition(asset);
|
|
2311
|
+
const directAmount = (pos && pos.totalAmount > 0 && !pos.earning) ? pos.totalAmount : 0;
|
|
2312
|
+
let strategyTotal = 0;
|
|
2313
|
+
for (const key of this.portfolio.getAllStrategyKeys()) {
|
|
2314
|
+
for (const sp of this.portfolio.getStrategyPositions(key)) {
|
|
2315
|
+
if (sp.asset === asset && sp.totalAmount > 0) {
|
|
2316
|
+
strategyTotal += sp.totalAmount;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
const walletInvested = Math.max(directAmount, strategyTotal);
|
|
2321
|
+
if (walletInvested <= 0)
|
|
2322
|
+
return Infinity;
|
|
2323
|
+
const assetInfo = SUPPORTED_ASSETS[asset];
|
|
2324
|
+
const balance = await this.client.getBalance({ owner: this._address, coinType: assetInfo.type });
|
|
2325
|
+
const walletAmount = Number(balance.totalBalance) / (10 ** assetInfo.decimals);
|
|
2326
|
+
const gasReserve = asset === 'SUI' ? GAS_RESERVE_MIN : 0;
|
|
2327
|
+
return Math.max(0, walletAmount - walletInvested - gasReserve);
|
|
2328
|
+
}
|
|
2329
|
+
async resolveLending(protocol, asset, capability) {
|
|
2330
|
+
if (protocol) {
|
|
2331
|
+
const adapter = this.registry.getLending(protocol);
|
|
2332
|
+
if (!adapter)
|
|
2333
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `Lending adapter '${protocol}' not found`);
|
|
2334
|
+
return adapter;
|
|
2335
|
+
}
|
|
2336
|
+
if (capability === 'save') {
|
|
2337
|
+
const { adapter } = await this.registry.bestSaveRate(asset);
|
|
2338
|
+
return adapter;
|
|
2339
|
+
}
|
|
2340
|
+
if (capability === 'borrow' || capability === 'repay') {
|
|
2341
|
+
const adapters = this.registry.listLending().filter(a => a.supportedAssets.includes(asset) &&
|
|
2342
|
+
a.capabilities.includes(capability) &&
|
|
2343
|
+
(capability !== 'borrow' || a.supportsSameAssetBorrow));
|
|
2344
|
+
if (adapters.length === 0) {
|
|
2345
|
+
const alternatives = this.registry.listLending().filter(a => a.capabilities.includes(capability) &&
|
|
2346
|
+
(capability !== 'borrow' || a.supportsSameAssetBorrow));
|
|
2347
|
+
if (alternatives.length > 0) {
|
|
2348
|
+
const altList = alternatives.map(a => a.name).join(', ');
|
|
2349
|
+
const altAssets = [...new Set(alternatives.flatMap(a => [...a.supportedAssets]))].join(', ');
|
|
2350
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `No protocol supports ${capability} for ${asset}. Available for ${capability}: ${altList} (assets: ${altAssets})`);
|
|
2351
|
+
}
|
|
2352
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `No adapter supports ${capability} ${asset}`);
|
|
2353
|
+
}
|
|
2354
|
+
return adapters[0];
|
|
2355
|
+
}
|
|
2356
|
+
const adapters = this.registry.listLending().filter(a => a.supportedAssets.includes(asset) && a.capabilities.includes(capability));
|
|
2357
|
+
if (adapters.length === 0) {
|
|
2358
|
+
const alternatives = this.registry.listLending().filter(a => a.capabilities.includes(capability));
|
|
2359
|
+
if (alternatives.length > 0) {
|
|
2360
|
+
const altList = alternatives.map(a => `${a.name} (${[...a.supportedAssets].join(', ')})`).join('; ');
|
|
2361
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `No protocol supports ${capability} for ${asset}. Try: ${altList}`);
|
|
2362
|
+
}
|
|
2363
|
+
throw new T2000Error('ASSET_NOT_SUPPORTED', `No adapter supports ${capability} ${asset}`);
|
|
2364
|
+
}
|
|
2365
|
+
return adapters[0];
|
|
2366
|
+
}
|
|
2367
|
+
emitBalanceChange(asset, amount, cause, tx) {
|
|
2368
|
+
this.emit('balanceChange', { asset, previous: 0, current: 0, cause, tx });
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
async function callSponsorApi(address, name) {
|
|
2372
|
+
const res = await fetch(`${API_BASE_URL}/api/sponsor`, {
|
|
2373
|
+
method: 'POST',
|
|
2374
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2375
|
+
body: JSON.stringify({ address, name }),
|
|
2376
|
+
});
|
|
2377
|
+
if (res.status === 429) {
|
|
2378
|
+
const data = await res.json();
|
|
2379
|
+
if (data.challenge) {
|
|
2380
|
+
const proof = solveHashcash(data.challenge);
|
|
2381
|
+
const retry = await fetch(`${API_BASE_URL}/api/sponsor`, {
|
|
2382
|
+
method: 'POST',
|
|
2383
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2384
|
+
body: JSON.stringify({ address, name, proof }),
|
|
2385
|
+
});
|
|
2386
|
+
if (!retry.ok)
|
|
2387
|
+
throw new T2000Error('SPONSOR_RATE_LIMITED', 'Sponsor rate limited');
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
if (!res.ok) {
|
|
2392
|
+
throw new T2000Error('SPONSOR_FAILED', 'Sponsor API unavailable');
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
//# sourceMappingURL=t2000.js.map
|