-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathinteractive.ts
More file actions
289 lines (250 loc) · 10.5 KB
/
Copy pathinteractive.ts
File metadata and controls
289 lines (250 loc) · 10.5 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
/**
* Interactive payment setup flow with TTY support
*
* This module provides a guided, interactive setup experience for configuring
* payment approvals. It uses @clack/prompts for a terminal interface
* with password-style input for private keys and spinners for long operations.
*/
import { cancel, confirm, isCancel, password, text } from '@clack/prompts'
import pc from 'picocolors'
import { parseUnits } from 'viem'
import { CliFatal, isCliFatal, setIncompleteExitCode } from '../common/cli-errors.js'
import {
calculateDepositCapacity,
checkAllowances,
checkAndSetAllowances,
checkFILBalance,
checkUSDFCBalance,
DEFAULT_LOCKUP_DAYS,
depositUSDFC,
getPaymentStatus,
validateGasRequirement,
validatePaymentRequirements,
} from '../core/payments/index.js'
import { getClientAddress, initializeSynapse } from '../core/synapse/index.js'
import { formatUSDFC } from '../core/utils/format.js'
import { parseCLIAuth } from '../utils/cli-auth.js'
import { createSpinner, intro, outro } from '../utils/cli-helpers.js'
import { isTTY, log } from '../utils/cli-logger.js'
import { displayAccountInfo, displayDepositWarning, displayPricing } from './setup.js'
import type { PaymentSetupOptions } from './types.js'
/**
* Run interactive payment setup
*
* @param options - Initial options from command line
*/
export async function runInteractiveSetup(options: PaymentSetupOptions): Promise<void> {
// Check for TTY support
if (!isTTY()) {
log.line(pc.red('Error: Interactive mode requires a TTY terminal.'))
log.line('Use --auto flag for non-interactive setup.')
log.flush()
throw new CliFatal('Interactive mode requires a TTY terminal')
}
intro(pc.bold('Filecoin Onchain Cloud Payment Setup'))
const s = createSpinner()
try {
// Get private key
let privateKey = options.privateKey
if (!privateKey) {
const input = await password({
message: 'Enter your private key',
validate: (value) => {
if (!value) return 'Private key is required'
// Add 0x prefix if missing
const key = value.startsWith('0x') ? value : `0x${value}`
// Validate format: 0x followed by 64 hex characters
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) {
return 'Private key must be 64 hex characters (with or without 0x prefix)'
}
return undefined
},
})
if (isCancel(input)) {
cancel('Setup cancelled')
// User cancelled: not a failure. Signal "incomplete" (2) distinctly
// from success (0) and a caught error (1).
setIncompleteExitCode()
return
}
// Add 0x prefix if it was missing
privateKey = input.startsWith('0x') ? input : `0x${input}`
}
// Initialize Synapse
s.start('Initializing connection...')
const config = await parseCLIAuth({ ...options, privateKey })
const synapse = await initializeSynapse(config)
const network = synapse.chain.name
const address = getClientAddress(synapse)
s.stop(`${pc.green('✓')} Connected to ${pc.bold(network)}`)
// Check balances
s.start('Checking balances...')
const filStatus = await checkFILBalance(synapse)
const walletUsdfcBalance = await checkUSDFCBalance(synapse)
const [status, allowanceCheck] = await Promise.all([getPaymentStatus(synapse), checkAllowances(synapse)])
s.stop(`${pc.green('✓')} Balance check complete`)
// Gate on wallet funding only when setup work remains. An account with a
// deposit and current allowances can complete this flow without sending a
// transaction. A first deposit spends wallet USDFC and gas; an allowance
// update spends gas alone, so wallet USDFC is not required for it.
const needsFirstDeposit = status.filecoinPayBalance === 0n
if (needsFirstDeposit || allowanceCheck.needsUpdate) {
const validation = needsFirstDeposit
? validatePaymentRequirements(filStatus.balance, walletUsdfcBalance, filStatus.isCalibnet)
: validateGasRequirement(filStatus.balance, filStatus.isCalibnet)
if (!validation.isValid) {
const errorMsg = validation.errorMessage ?? 'Payment validation failed'
log.line(`${pc.red('✗')} ${errorMsg}`)
if (validation.helpMessage) {
log.line('')
log.line(` ${pc.cyan(validation.helpMessage)}`)
}
log.flush()
cancel('Please fund your wallet and try again')
throw new CliFatal(errorMsg)
}
}
displayAccountInfo(
address,
network,
filStatus.balance,
filStatus.isCalibnet,
filStatus.hasSufficientGas,
walletUsdfcBalance,
status.filecoinPayBalance
)
// Get storage pricing info once for all subsequent operations
s.start('Getting current pricing...')
const storageInfo = await synapse.storage.getStorageInfo()
const pricePerTiBPerEpoch = storageInfo.pricing.noCDN.perTiBPerEpoch
const pricePerTiBPerMonth = storageInfo.pricing.noCDN.perTiBPerMonth
const pricePerGiBPerMonth = pricePerTiBPerMonth / 1024n
s.stop(`${pc.green('✓')} Pricing loaded`)
// Initialize tracking variables
let depositAmount = 0n
let actionsTaken = false // Track if any changes were made
// Show current deposit capacity
const currentCapacity = calculateDepositCapacity(status.filecoinPayBalance, pricePerTiBPerEpoch)
log.line(pc.bold('Current Storage Capacity:'))
if (status.filecoinPayBalance > 0n) {
const capacityStr =
currentCapacity.gibPerMonth >= 1024
? `${(currentCapacity.gibPerMonth / 1024).toFixed(1)} TiB`
: `${currentCapacity.gibPerMonth.toFixed(1)} GiB`
log.indent(`Deposit: ${formatUSDFC(status.filecoinPayBalance)} USDFC`)
log.indent(`Capacity: ~${capacityStr} for 1 month`)
} else {
log.indent(pc.gray('No deposit yet'))
}
log.flush()
// Show pricing to help user understand costs
displayPricing(pricePerGiBPerMonth, pricePerTiBPerMonth)
// Offer deposit options with contextual message
const depositMessage =
status.filecoinPayBalance === 0n
? 'Would you like to deposit USDFC to enable storage?'
: 'Would you like to deposit additional USDFC?'
const shouldDeposit = await confirm({
message: depositMessage,
initialValue: status.filecoinPayBalance === 0n,
})
if (isCancel(shouldDeposit)) {
cancel('Setup cancelled')
setIncompleteExitCode()
return
}
if (shouldDeposit) {
// Show examples to help user decide
log.line(pc.bold('Storage Examples (per month):'))
log.indent(`100 GiB capacity: ~${formatUSDFC((pricePerGiBPerMonth * 100n * 11n) / 10n)} USDFC`)
log.indent(`1 TiB capacity: ~${formatUSDFC((pricePerTiBPerMonth * 11n) / 10n)} USDFC`)
log.indent(`10 TiB capacity: ~${formatUSDFC((pricePerTiBPerMonth * 10n * 11n) / 10n)} USDFC`)
log.indent(pc.gray(`(deposit covers 1 month + ${DEFAULT_LOCKUP_DAYS}-day safety reserve)`))
log.flush()
const amountStr = await text({
message: 'How much USDFC would you like to deposit?',
placeholder: '10.0',
initialValue: status.filecoinPayBalance === 0n ? '10.0' : '5.0',
validate: (value) => {
if (!value) return 'Amount is required'
try {
const amount = parseUnits(value, 18)
if (amount <= 0n) return 'Amount must be greater than 0'
if (amount > walletUsdfcBalance)
return `Insufficient balance (have ${formatUSDFC(walletUsdfcBalance)} USDFC)`
return undefined
} catch {
return 'Invalid amount'
}
},
})
if (isCancel(amountStr)) {
cancel('Setup cancelled')
setIncompleteExitCode()
return
}
depositAmount = parseUnits(amountStr, 18)
s.start('Depositing USDFC...')
const { depositTx } = await depositUSDFC(synapse, depositAmount)
s.stop(`${pc.green('✓')} Deposit complete`)
log.indent(pc.gray(`Deposit tx: ${depositTx}`))
actionsTaken = true
// Show new capacity after deposit
const newCapacity = calculateDepositCapacity(status.filecoinPayBalance + depositAmount, pricePerTiBPerEpoch)
const newCapacityStr =
newCapacity.gibPerMonth >= 1024
? `${(newCapacity.gibPerMonth / 1024).toFixed(1)} TiB`
: `${newCapacity.gibPerMonth.toFixed(1)} GiB`
log.line('')
log.line(pc.bold('New Storage Capacity:'))
log.indent(`Total deposit: ${formatUSDFC(status.filecoinPayBalance + depositAmount)} USDFC`)
log.indent(`Capacity: ~${newCapacityStr} for 1 month`)
log.flush()
} else {
const { updated, transactionHash } = await checkAndSetAllowances(synapse)
if (updated) {
log.indent(`${pc.green('✓')} Updated payment allowances, tx: ${transactionHash}`)
} else {
log.indent(`${pc.green('✓')} Deposit already sufficient (${formatUSDFC(status.filecoinPayBalance)} USDFC)`)
}
}
// Final summary
s.start('Fetching final status...')
const finalStatus = await getPaymentStatus(synapse)
s.stop('━━━ Setup Complete ━━━')
const finalCapacity = calculateDepositCapacity(finalStatus.filecoinPayBalance, pricePerTiBPerEpoch)
log.line(`Network: ${pc.bold(network)}`)
log.line('')
log.line(pc.bold('Wallet'))
log.indent(`${formatUSDFC(walletUsdfcBalance)} USDFC available`)
log.line('')
log.line(pc.bold('Storage Deposit'))
log.indent(`${formatUSDFC(finalStatus.filecoinPayBalance)} USDFC deposited`)
if (finalCapacity.gibPerMonth > 0) {
const capacityStr =
finalCapacity.gibPerMonth >= 1024
? `${(finalCapacity.gibPerMonth / 1024).toFixed(1)} TiB`
: `${finalCapacity.gibPerMonth.toFixed(1)} GiB`
log.indent(`Capacity: ~${capacityStr} for 1 month`)
log.indent(pc.gray(`(includes ${DEFAULT_LOCKUP_DAYS}-day safety reserve)`))
}
log.flush()
// Show deposit warning if needed
displayDepositWarning(finalStatus.filecoinPayBalance, finalStatus.currentAllowances.lockupUsage)
// Show appropriate outro message based on whether actions were taken
if (actionsTaken) {
outro('Payment setup completed successfully')
} else {
outro('No changes made to payment setup')
}
} catch (error) {
if (isCliFatal(error)) {
s.stop()
throw error
}
const msg = error instanceof Error ? error.message : String(error)
s.stop(`${pc.red('✗')} Setup failed: ${msg}`)
cancel('Setup failed')
throw new CliFatal(msg, { cause: error instanceof Error ? error : undefined })
}
}