Skip to content

Commit 158e1b0

Browse files
committed
feat: add Flyover swap command (liquidity, peg-in, peg-out, interactive)
1 parent 6175a2e commit 158e1b0

File tree

3 files changed

+204
-6
lines changed

3 files changed

+204
-6
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,8 @@ rsk-cli swap --testnet --liquidity
11671167
rsk-cli swap --testnet --pegin --amount 0.1
11681168
# Force a specific LP by name or URL
11691169
rsk-cli swap --testnet --pegin --amount 0.1 --provider "teks-staging"
1170+
# Bypass captcha using a trusted (whitelisted) account (recommended for automation)
1171+
rsk-cli swap --testnet --pegin --amount 0.1 --trusted-wallet <trustedWalletName>
11701172
```
11711173

11721174
#### Peg-out (RBTC -> BTC)
@@ -1175,6 +1177,8 @@ rsk-cli swap --testnet --pegin --amount 0.1 --provider "teks-staging"
11751177
rsk-cli swap --testnet --pegout --amount 0.01 --btc-address <address>
11761178
# Force a specific LP by apiBaseUrl
11771179
rsk-cli swap --testnet --pegout --amount 0.01 --btc-address <address> --provider "https://staging.lps.tekscapital.com"
1180+
# Bypass captcha using a trusted (whitelisted) account (recommended for automation)
1181+
rsk-cli swap --testnet --pegout --amount 0.01 --btc-address <address> --trusted-wallet <trustedWalletName>
11781182
```
11791183

11801184
#### Interactive Mode
@@ -1192,6 +1196,8 @@ rsk-cli swap --interactive
11921196
- `--amount <amount>`: Amount in BTC (for `--pegin`) or RBTC (for `--pegout`)
11931197
- `--btc-address <address>`: Destination BTC address (required for `--pegout`)
11941198
- `--provider <name-or-url>`: Force LP selection by provider name or apiBaseUrl
1199+
- `--trusted-wallet <name>`: Use a trusted (whitelisted) Rootstock wallet to accept quotes without captcha
1200+
- `--trusted-private-key <hex>`: Use a trusted (whitelisted) private key to accept quotes without captcha (avoid in shared shells)
11951201
- `--interactive`: Run guided interactive swap flow
11961202
- `--wallet <name>`: Wallet name to use for signing (uses current wallet by default)
11971203

bin/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ interface CommandOptions {
8787
pegin?: boolean;
8888
pegout?: boolean;
8989
provider?: string;
90+
trustedWallet?: string;
91+
trustedPrivateKey?: string;
9092
}
9193

9294
const orange = chalk.rgb(255, 165, 0);
@@ -158,6 +160,14 @@ program
158160
"--provider <name-or-url>",
159161
"Force a specific liquidity provider by name or apiBaseUrl (optional)"
160162
)
163+
.option(
164+
"--trusted-wallet <name>",
165+
"Use a trusted (whitelisted) Rootstock wallet name to accept quotes without captcha (optional)"
166+
)
167+
.option(
168+
"--trusted-private-key <hex>",
169+
"Use a trusted (whitelisted) private key to accept quotes without captcha (optional; avoid using in shared shells)"
170+
)
161171
.option("-i, --interactive", "Run guided interactive swap flow")
162172
.option("--wallet <name>", "Wallet name to use for signing (optional; defaults to current wallet)")
163173
.action(async (options: CommandOptions) => {
@@ -172,6 +182,8 @@ program
172182
amount: amount && !Number.isNaN(amount) ? amount : undefined,
173183
btcAddress: options.btcAddress,
174184
provider: options.provider,
185+
trustedWalletName: options.trustedWallet,
186+
trustedPrivateKey: options.trustedPrivateKey,
175187
interactive: !!options.interactive,
176188
walletName: options.wallet,
177189
isExternal: false,

src/commands/swap.ts

Lines changed: 186 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export type SwapCommandOptions = {
2121
password?: string;
2222
walletName?: string;
2323

24+
// Trusted accounts (captcha bypass)
25+
trustedWalletName?: string;
26+
trustedPrivateKey?: string;
27+
2428
// Operation selection
2529
liquidity?: boolean;
2630
pegin?: boolean;
@@ -127,7 +131,7 @@ async function createBlockchainConnectionFromWallet(params: {
127131
walletsData?: WalletData;
128132
password?: string;
129133
spinner?: SpinnerWrapper;
130-
}): Promise<{ rskAddress: string; connection: BlockchainConnection }> {
134+
}): Promise<{ rskAddress: string; connection: BlockchainConnection; resolvedWalletName: string }> {
131135
const isTestnet = params.testnet;
132136
const rpcUrl = getRskRpcUrl(isTestnet);
133137

@@ -160,6 +164,7 @@ async function createBlockchainConnectionFromWallet(params: {
160164
return {
161165
rskAddress: toFlyoverRskAddress(wallet.address, isTestnet),
162166
connection,
167+
resolvedWalletName: params.walletName!,
163168
};
164169
}
165170

@@ -193,6 +198,39 @@ async function createBlockchainConnectionFromWallet(params: {
193198
return {
194199
rskAddress: toFlyoverRskAddress(wallet.address, isTestnet),
195200
connection,
201+
resolvedWalletName,
202+
};
203+
}
204+
205+
async function createBlockchainConnectionFromPrivateKey(params: {
206+
testnet: boolean;
207+
privateKey: string;
208+
}): Promise<{ rskAddress: string; connection: BlockchainConnection }> {
209+
const isTestnet = params.testnet;
210+
const rpcUrl = getRskRpcUrl(isTestnet);
211+
const privateKey = params.privateKey.trim().startsWith("0x")
212+
? params.privateKey.trim()
213+
: `0x${params.privateKey.trim()}`;
214+
215+
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
216+
const walletObj = new ethers.Wallet(privateKey, provider);
217+
218+
// bridges-core-sdk expects encrypted JSON + password.
219+
// We'll generate a throwaway password since we already have the raw private key.
220+
const tempPassword = crypto.randomBytes(16).toString("hex");
221+
const encryptedJson = await walletObj.encrypt(tempPassword);
222+
const encryptedJsonObj =
223+
typeof encryptedJson === "string" ? JSON.parse(encryptedJson) : encryptedJson;
224+
225+
const connection = await BlockchainConnection.createUsingEncryptedJson(
226+
encryptedJsonObj,
227+
tempPassword,
228+
rpcUrl
229+
);
230+
231+
return {
232+
rskAddress: toFlyoverRskAddress(walletObj.address, isTestnet),
233+
connection,
196234
};
197235
}
198236

@@ -206,6 +244,22 @@ function toFlyoverRskAddress(address: string, isTestnet: boolean): string {
206244
return FlyoverUtils.rskChecksum(address, chainId);
207245
}
208246

247+
function normalizeWalletLabel(name?: string): string {
248+
return String(name || "").trim().toLowerCase();
249+
}
250+
251+
function rethrowIfTrustedAccountRejected(error: unknown): never {
252+
const msg = error instanceof Error ? error.message : String(error);
253+
if (/trusted account/i.test(msg) || /fetching trusted/i.test(msg)) {
254+
throw new Error(
255+
"The liquidity provider rejected authenticated (trusted) quote acceptance. " +
256+
"Your Rootstock address must be allowlisted on that LP, or use a valid captcha token (set FLYOVER_CAPTCHA_TOKEN) and run without --trusted-wallet. " +
257+
"Ask the LP operator to add your address to trusted accounts on their LPS."
258+
);
259+
}
260+
throw error instanceof Error ? error : new Error(msg);
261+
}
262+
209263
async function resolveCaptchaToken(params: {
210264
isExternal: boolean;
211265
providerName?: string;
@@ -319,6 +373,65 @@ function selectProvider(params: {
319373
return providers.find((p) => !p?.siteKey) ?? providers[0];
320374
}
321375

376+
async function createTrustedFlyoverSigner(params: {
377+
isTestnet: boolean;
378+
isExternal: boolean;
379+
trustedWalletName?: string;
380+
trustedPrivateKey?: string;
381+
walletsData?: WalletData;
382+
password?: string;
383+
spinner?: SpinnerWrapper;
384+
/** When the trusted wallet is the same as the signing wallet, reuse this connection (one password prompt). */
385+
reuseSigner?: { rskAddress: string; connection: BlockchainConnection };
386+
}): Promise<{ trustedAddress: string; trustedFlyover: Flyover } | null> {
387+
const trustedWalletName = (params.trustedWalletName || "").trim();
388+
const trustedPrivateKey = (params.trustedPrivateKey || "").trim();
389+
390+
if (!trustedWalletName && !trustedPrivateKey) return null;
391+
392+
if (trustedWalletName && trustedPrivateKey) {
393+
throw new Error("Please provide only one of trustedWalletName or trustedPrivateKey.");
394+
}
395+
396+
let rskAddress: string;
397+
let connection: BlockchainConnection;
398+
399+
if (trustedPrivateKey) {
400+
const created = await createBlockchainConnectionFromPrivateKey({
401+
testnet: params.isTestnet,
402+
privateKey: trustedPrivateKey,
403+
});
404+
rskAddress = created.rskAddress;
405+
connection = created.connection;
406+
} else if (params.reuseSigner && trustedWalletName) {
407+
rskAddress = params.reuseSigner.rskAddress;
408+
connection = params.reuseSigner.connection;
409+
} else if (trustedWalletName) {
410+
const created = await createBlockchainConnectionFromWallet({
411+
testnet: params.isTestnet,
412+
isExternal: params.isExternal,
413+
walletName: trustedWalletName,
414+
walletsData: params.walletsData,
415+
password: params.password,
416+
spinner: params.spinner,
417+
});
418+
rskAddress = created.rskAddress;
419+
connection = created.connection;
420+
} else {
421+
return null;
422+
}
423+
424+
const trustedFlyover = new Flyover({
425+
network: getFlyoverNetwork(params.isTestnet),
426+
rskConnection: connection,
427+
// Not used in authenticated acceptance flow, but required by constructor.
428+
captchaTokenResolver: async () => "",
429+
allowInsecureConnections: false,
430+
});
431+
432+
return { trustedAddress: rskAddress, trustedFlyover };
433+
}
434+
322435
async function handlePegIn(_params: {
323436
isTestnet: boolean;
324437
amount: number;
@@ -328,6 +441,8 @@ async function handlePegIn(_params: {
328441
password?: string;
329442
spinner?: SpinnerWrapper;
330443
provider?: string;
444+
trustedWalletName?: string;
445+
trustedPrivateKey?: string;
331446
}): Promise<Pick<
332447
NonNullable<SwapResult["data"]>,
333448
"txHash" | "totalFeeEstimate" | "quoteExpiresAt" | "depositAddress"
@@ -339,7 +454,7 @@ async function handlePegIn(_params: {
339454
_params;
340455
const amountWei = parseUnits(amount.toString(), 18);
341456

342-
const { rskAddress, connection } = await createBlockchainConnectionFromWallet({
457+
const { rskAddress, connection, resolvedWalletName } = await createBlockchainConnectionFromWallet({
343458
testnet: isTestnet,
344459
isExternal,
345460
walletName,
@@ -348,6 +463,11 @@ async function handlePegIn(_params: {
348463
spinner,
349464
});
350465

466+
const reuseTrustedConnection =
467+
!_params.trustedPrivateKey &&
468+
!!_params.trustedWalletName?.trim() &&
469+
normalizeWalletLabel(_params.trustedWalletName) === normalizeWalletLabel(resolvedWalletName);
470+
351471
let selectedProvider: any;
352472
const captchaTokenResolver = async () =>
353473
resolveCaptchaToken({
@@ -416,7 +536,30 @@ async function handlePegIn(_params: {
416536
logInfo(isExternal, `⛽ Required confirmations: ${quote.quote.confirmations}`);
417537
logInfo(isExternal, `⏳ Quote expires at: ${quoteExpiresAt}`);
418538

419-
const accepted = await flyover.acceptQuote(quote);
539+
const trusted = await createTrustedFlyoverSigner({
540+
isTestnet,
541+
isExternal,
542+
trustedWalletName: _params.trustedWalletName,
543+
trustedPrivateKey: _params.trustedPrivateKey,
544+
walletsData,
545+
password,
546+
spinner,
547+
reuseSigner: reuseTrustedConnection ? { rskAddress, connection } : undefined,
548+
});
549+
550+
let accepted: any;
551+
if (trusted) {
552+
trusted.trustedFlyover.useLiquidityProvider(selectedProvider);
553+
const signature = await (trusted.trustedFlyover as any).signQuote(quote);
554+
logInfo(isExternal, `🔐 Using trusted account to accept quote: ${trusted.trustedAddress}`);
555+
try {
556+
accepted = await (flyover as any).acceptAuthenticatedQuote(quote, signature);
557+
} catch (e) {
558+
rethrowIfTrustedAccountRejected(e);
559+
}
560+
} else {
561+
accepted = await flyover.acceptQuote(quote);
562+
}
420563
void accepted; // signature is not used directly by this CLI for peg-in display
421564

422565
const status = await flyover.getPeginStatus(quote.quoteHash);
@@ -455,6 +598,8 @@ async function handlePegOut(_params: {
455598
password?: string;
456599
spinner?: SpinnerWrapper;
457600
provider?: string;
601+
trustedWalletName?: string;
602+
trustedPrivateKey?: string;
458603
}): Promise<Pick<
459604
NonNullable<SwapResult["data"]>,
460605
"txHash" | "totalFeeEstimate" | "quoteExpiresAt"
@@ -487,7 +632,7 @@ async function handlePegOut(_params: {
487632
throw new Error(`Invalid BTC address for ${isTestnet ? "testnet" : "mainnet"}`);
488633
}
489634

490-
const { rskAddress, connection } = await createBlockchainConnectionFromWallet({
635+
const { rskAddress, connection, resolvedWalletName } = await createBlockchainConnectionFromWallet({
491636
testnet: isTestnet,
492637
isExternal,
493638
walletName,
@@ -496,6 +641,11 @@ async function handlePegOut(_params: {
496641
spinner,
497642
});
498643

644+
const reuseTrustedConnection =
645+
!_params.trustedPrivateKey &&
646+
!!_params.trustedWalletName?.trim() &&
647+
normalizeWalletLabel(_params.trustedWalletName) === normalizeWalletLabel(resolvedWalletName);
648+
499649
let selectedProvider: any;
500650
const captchaTokenResolver = async () =>
501651
resolveCaptchaToken({
@@ -551,7 +701,30 @@ async function handlePegOut(_params: {
551701
);
552702
logInfo(isExternal, `⏳ Quote expires at: ${quoteExpiresAt}`);
553703

554-
const acceptedQuote = await flyover.acceptPegoutQuote(quote);
704+
const trusted = await createTrustedFlyoverSigner({
705+
isTestnet,
706+
isExternal,
707+
trustedWalletName: _params.trustedWalletName,
708+
trustedPrivateKey: _params.trustedPrivateKey,
709+
walletsData,
710+
password,
711+
spinner,
712+
reuseSigner: reuseTrustedConnection ? { rskAddress, connection } : undefined,
713+
});
714+
715+
let acceptedQuote: any;
716+
if (trusted) {
717+
trusted.trustedFlyover.useLiquidityProvider(selectedProvider);
718+
const signature = await (trusted.trustedFlyover as any).signQuote(quote);
719+
logInfo(isExternal, `🔐 Using trusted account to accept quote: ${trusted.trustedAddress}`);
720+
try {
721+
acceptedQuote = await (flyover as any).acceptAuthenticatedPegoutQuote(quote, signature);
722+
} catch (e) {
723+
rethrowIfTrustedAccountRejected(e);
724+
}
725+
} else {
726+
acceptedQuote = await flyover.acceptPegoutQuote(quote);
727+
}
555728
const txHash = await flyover.depositPegout(quote, acceptedQuote.signature, totalFeeEstimateWei);
556729

557730
logSuccess(isExternal, `✅ Peg-out transaction executed. TxHash: ${txHash}`);
@@ -714,6 +887,8 @@ export async function swapCommand(
714887
password: params.password,
715888
spinner,
716889
provider: params.provider,
890+
trustedWalletName: params.trustedWalletName,
891+
trustedPrivateKey: params.trustedPrivateKey,
717892
});
718893

719894
spinner.start("⏳ Finalizing peg-in...");
@@ -759,6 +934,8 @@ export async function swapCommand(
759934
password: params.password,
760935
spinner,
761936
provider: params.provider,
937+
trustedWalletName: params.trustedWalletName,
938+
trustedPrivateKey: params.trustedPrivateKey,
762939
});
763940

764941
spinner.start("⏳ Finalizing peg-out...");
@@ -775,7 +952,10 @@ export async function swapCommand(
775952
const errorMessage =
776953
error instanceof Error ? error.message : "Error executing swap (unknown error).";
777954
spinner.fail(chalk.red(`❌ ${errorMessage}`));
778-
logError(params.isExternal || false, errorMessage);
955+
// Avoid duplicating the same line on the console for non-external (MCP) runs.
956+
if (params.isExternal) {
957+
logError(true, errorMessage);
958+
}
779959
return { success: false, error: errorMessage };
780960
} finally {
781961
if (!params.isExternal) {

0 commit comments

Comments
 (0)