-
Notifications
You must be signed in to change notification settings - Fork 105
Expand file tree
/
Copy pathvault.ts
More file actions
392 lines (343 loc) · 13.6 KB
/
Copy pathvault.ts
File metadata and controls
392 lines (343 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
// InvestmentVault client — synchronous simulation + async on-chain reads.
//
// The sync `vault` object mirrors the Soroban vault's surface so deposit &
// withdraw screens can use it for immediate previews (demo / no-config mode).
//
// When NEXT_PUBLIC_VAULT_CONTRACT_ID is set, the async functions below read
// directly from the deployed Soroban contract via RPC:
// fetchSharePrice / fetchTotalAssets — view reads (Issue #1)
// submitDeposit / submitWithdraw — signed transactions (Issue #2)
//
// In demo mode (isDemo flag) or when env vars are absent, everything falls
// back gracefully — no errors surface to the user.
import { selectSharePrice } from '../state/selectors'
export interface WithdrawPreview {
assets: number
sharePrice: number
networkFee: number
}
/** total_assets / total_supply. Constant in the mock; a live read on-chain. */
export const SHARE_PRICE = selectSharePrice()
/** Number of decimal places used when formatting share prices. */
export const SHARE_PRICE_DECIMALS = 7
/** Formats a share price with consistent precision across all screens. */
export function formatSharePrice(sharePrice: number): string {
return sharePrice.toFixed(SHARE_PRICE_DECIMALS)
}
/** Simulated pending delay for deposit transactions in demo mode. */
export const SIMULATED_DEPOSIT_DELAY_MS = 2000
/** Simulated pending delay for withdraw transactions in demo mode. */
export const SIMULATED_WITHDRAW_DELAY_MS = 2000
export interface DepositPreview {
shares: number
sharePrice: number
/** USDC; sub-cent on Stellar. */
networkFee: number
}
export const vault = {
sharePrice: () => SHARE_PRICE,
/** convert_to_shares(usdc) — what you receive for a deposit. */
convertToShares: (usdc: number): number => usdc / SHARE_PRICE,
/** convert_to_assets(shares) — what shares are worth on withdraw. */
convertToAssets: (shares: number): number => shares * SHARE_PRICE,
previewDeposit: (usdc: number): DepositPreview => ({
shares: usdc / SHARE_PRICE,
sharePrice: SHARE_PRICE,
networkFee: 0.00001,
}),
previewWithdraw: (usdc: number): WithdrawPreview => ({
assets: usdc,
sharePrice: SHARE_PRICE,
networkFee: 0.00001,
}),
}
// ---------------------------------------------------------------------------
// Async Soroban client
// ---------------------------------------------------------------------------
const CONTRACT_ID = process.env.NEXT_PUBLIC_VAULT_CONTRACT_ID
const STELLAR_NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'public'
const RPC_URL =
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ??
(STELLAR_NETWORK === 'public'
? 'https://soroban.stellar.org'
: 'https://soroban-testnet.stellar.org')
const HORIZON_URL =
process.env.NEXT_PUBLIC_HORIZON_URL ??
(STELLAR_NETWORK === 'public'
? 'https://horizon.stellar.org'
: 'https://horizon-testnet.stellar.org')
/** Max time to wait for a Stellar RPC/Horizon response before treating it as offline. */
const RPC_TIMEOUT_MS = 5000
let cachedSharePrice = SHARE_PRICE
let cachedTotalAssets: number | null = null
let offline = false
const offlineListeners = new Set<(offline: boolean) => void>()
function setOffline(nextOffline: boolean) {
if (offline === nextOffline) return
offline = nextOffline
offlineListeners.forEach((listener) => {
try {
listener(nextOffline)
} catch {
// Listener errors must not break network timeout fallbacks.
}
})
}
/** Returns true when the last Stellar network call timed out. */
export function isOffline(): boolean {
return offline
}
/** Subscribe to offline status changes. Returns an unsubscribe function. */
export function onOfflineChange(listener: (offline: boolean) => void): () => void {
offlineListeners.add(listener)
return () => {
offlineListeners.delete(listener)
}
}
/** Reject if a Stellar network call takes longer than RPC_TIMEOUT_MS. */
async function withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
setOffline(true)
reject(new Error(message))
}, RPC_TIMEOUT_MS)
})
const result = await Promise.race([promise, timeout])
setOffline(false)
return result
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
/** Call a Soroban view function (no state mutation) and return the raw ScVal. */
async function sorobanSimulate(
sourceAddress: string,
method: string,
args: unknown[] = [],
network = STELLAR_NETWORK,
) {
const { rpc, Contract, TransactionBuilder, Networks, Account, nativeToScVal } =
await import('@stellar/stellar-sdk')
const rpcUrl =
process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ??
(network === 'testnet' ? 'https://soroban-testnet.stellar.org' : 'https://soroban.stellar.org')
const server = new rpc.Server(rpcUrl, { allowHttp: false })
const contract = new Contract(CONTRACT_ID!)
// Sequence '0' is fine for simulation — only the address format matters.
const source = new Account(sourceAddress, '0')
const scArgs = args.map((a) => nativeToScVal(a))
const networkPassphrase = network === 'public' ? Networks.PUBLIC : Networks.TESTNET
const tx = new TransactionBuilder(source, { fee: '100', networkPassphrase })
.addOperation(contract.call(method, ...scArgs))
.setTimeout(0)
.build()
const result = await withTimeout(
server.simulateTransaction(tx),
'Stellar RPC timed out during simulation',
)
if ('error' in result) throw new Error(`Soroban simulate error: ${result.error}`)
if (!result.result) throw new Error('Soroban simulate returned no result')
return result.result.retval
}
/**
* Read share_price from the on-chain vault.
* Throws when NEXT_PUBLIC_VAULT_CONTRACT_ID is not set — callers should catch
* and fall back to the mock value.
*/
export async function fetchSharePrice(sourceAddress: string): Promise<string> {
if (!CONTRACT_ID) throw new Error('NEXT_PUBLIC_VAULT_CONTRACT_ID not set')
if (offline) return formatSharePrice(cachedSharePrice)
const { scValToNative } = await import('@stellar/stellar-sdk')
try {
const retval = await sorobanSimulate(sourceAddress, 'share_price')
cachedSharePrice = Number(scValToNative(retval)) / 10 ** SHARE_PRICE_DECIMALS
return formatSharePrice(cachedSharePrice)
} catch {
setOffline(true)
return formatSharePrice(cachedSharePrice)
}
}
/**
* Read total_assets from the on-chain vault.
* Throws when NEXT_PUBLIC_VAULT_CONTRACT_ID is not set.
*/
export async function fetchTotalAssets(
sourceAddress: string,
network = STELLAR_NETWORK,
): Promise<number> {
if (!CONTRACT_ID) throw new Error('NEXT_PUBLIC_VAULT_CONTRACT_ID not set')
if (offline) return cachedTotalAssets ?? 0
const { scValToNative } = await import('@stellar/stellar-sdk')
try {
const retval = await sorobanSimulate(sourceAddress, 'total_assets', [], network)
cachedTotalAssets = Number(scValToNative(retval))
return cachedTotalAssets
} catch {
setOffline(true)
return cachedTotalAssets ?? 0
}
}
// ---------------------------------------------------------------------------
// Transaction helpers
// ---------------------------------------------------------------------------
/** Seconds to poll getTransaction before giving up */
const TX_POLL_TIMEOUT_S = 30
/** Poll until a submitted transaction reaches a terminal status. */
async function waitForTransaction(hash: string): Promise<void> {
const { rpc } = await import('@stellar/stellar-sdk')
const server = new rpc.Server(RPC_URL, { allowHttp: false })
const deadline = Date.now() + TX_POLL_TIMEOUT_S * 1000
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2000))
const result = await withTimeout(
server.getTransaction(hash),
'Stellar RPC timed out while polling transaction status',
)
if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) return
if (result.status === rpc.Api.GetTransactionStatus.FAILED) {
throw new Error('Transaction failed on-chain')
}
// NOT_FOUND means still pending — keep polling
}
throw new Error('Transaction confirmation timed out')
}
/**
* Build, sign, and submit a deposit transaction.
* In demo mode (CONTRACT_ID not set): waits 2 s then returns a placeholder hash.
*
* @param amount USDC amount (integer stroops internally)
* @param address Stellar address of the depositor (source account)
* @param sign Signing function from WalletProvider
* @returns Transaction hash (real or placeholder)
*/
export async function submitDeposit(
amount: number,
address: string,
sign: (xdr: string) => Promise<string>,
signal?: AbortSignal,
): Promise<string> {
if (!CONTRACT_ID) {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(
`demo${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}…${Math.random().toString(36).slice(2, 8)}`,
)
}, SIMULATED_DEPOSIT_DELAY_MS)
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(new Error('Aborted'))
})
if (signal.aborted) {
clearTimeout(timer)
reject(new Error('Aborted'))
}
}
})
}
if (offline) throw new Error('Stellar node is offline')
const { rpc, Contract, TransactionBuilder, Networks, Horizon, nativeToScVal, Transaction } =
await import('@stellar/stellar-sdk')
const server = new rpc.Server(RPC_URL, { allowHttp: false })
const horizon = new Horizon.Server(HORIZON_URL)
const contract = new Contract(CONTRACT_ID)
const account = await withTimeout(
horizon.loadAccount(address),
'Stellar Horizon timed out loading account',
)
// USDC uses 7 decimal places on Stellar (stroops-equivalent for SAC tokens).
// The contract expects the raw integer amount scaled by 10^7.
const amountScVal = nativeToScVal(BigInt(Math.round(amount * 1e7)), { type: 'i128' })
const minSharesScVal = nativeToScVal(BigInt(0), { type: 'i128' })
const networkPassphrase = STELLAR_NETWORK === 'public' ? Networks.PUBLIC : Networks.TESTNET
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase })
.addOperation(contract.call('deposit', amountScVal, minSharesScVal))
.setTimeout(180)
.build()
const simResult = await withTimeout(
server.simulateTransaction(tx),
'Stellar RPC timed out during simulation',
)
if ('error' in simResult) throw new Error(`Simulation failed: ${simResult.error}`)
const assembled = rpc.assembleTransaction(tx, simResult).build()
const signedXdr = await sign(assembled.toXDR())
const signedTx = new Transaction(signedXdr, networkPassphrase)
const sendResult = await withTimeout(
server.sendTransaction(signedTx),
'Stellar RPC timed out submitting transaction',
)
if (sendResult.status === 'ERROR')
throw new Error(`Send failed: ${JSON.stringify(sendResult.errorResult)}`)
await waitForTransaction(sendResult.hash)
return sendResult.hash
}
/**
* Build, sign, and submit a withdraw transaction.
* In demo mode (CONTRACT_ID not set): waits 2 s then returns a placeholder hash.
*
* @param amount USDC amount to withdraw
* @param address Stellar address of the withdrawer
* @param sign Signing function from WalletProvider
* @returns Transaction hash (real or placeholder)
*/
export async function submitWithdraw(
amount: number,
address: string,
sign: (xdr: string) => Promise<string>,
signal?: AbortSignal,
): Promise<string> {
if (!CONTRACT_ID) {
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
resolve(
`demo${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}…${Math.random().toString(36).slice(2, 8)}`,
)
}, SIMULATED_WITHDRAW_DELAY_MS)
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timer)
reject(new Error('Aborted'))
})
if (signal.aborted) {
clearTimeout(timer)
reject(new Error('Aborted'))
}
}
})
}
if (offline) throw new Error('Stellar node is offline')
const { rpc, Contract, TransactionBuilder, Networks, Horizon, nativeToScVal, Transaction } =
await import('@stellar/stellar-sdk')
const server = new rpc.Server(RPC_URL, { allowHttp: false })
const horizon = new Horizon.Server(HORIZON_URL)
const contract = new Contract(CONTRACT_ID)
const account = await withTimeout(
horizon.loadAccount(address),
'Stellar Horizon timed out loading account',
)
const sharesScVal = nativeToScVal(BigInt(Math.round(amount * 1e7)), { type: 'i128' })
const minAssetsScVal = nativeToScVal(BigInt(0), { type: 'i128' })
const networkPassphrase = STELLAR_NETWORK === 'public' ? Networks.PUBLIC : Networks.TESTNET
const tx = new TransactionBuilder(account, { fee: '100', networkPassphrase })
.addOperation(contract.call('withdraw', sharesScVal, minAssetsScVal))
.setTimeout(180)
.build()
const simResult = await withTimeout(
server.simulateTransaction(tx),
'Stellar RPC timed out during simulation',
)
if ('error' in simResult) throw new Error(`Simulation failed: ${simResult.error}`)
const assembled = rpc.assembleTransaction(tx, simResult).build()
const signedXdr = await sign(assembled.toXDR())
const signedTx = new Transaction(signedXdr, networkPassphrase)
const sendResult = await withTimeout(
server.sendTransaction(signedTx),
'Stellar RPC timed out submitting transaction',
)
if (sendResult.status === 'ERROR')
throw new Error(`Send failed: ${JSON.stringify(sendResult.errorResult)}`)
await waitForTransaction(sendResult.hash)
return sendResult.hash
}