Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/rosen_bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Rosen Bridge swaps

The Swap tab offers **Rosen Bridge** for native FIRO ↔ rsFIRO on Ethereum mainnet. Both assets use 8 decimals. Only estimated quotes based on the send amount are supported. Bridge fees are deducted from the deposit; FIRO mining fees or ETH gas are additional.

rsFIRO uses the existing app-specific token defaults and migration from #1448. The bridge enables that catalog token on the selected Ethereum wallet without replacing its existing metadata.

FIRO deposits use the wallet's transparent balance. The transaction contains a zero-value OP_RETURN output with Rosen metadata; coin selection includes that output's serialized size. Immediately before broadcast, the signed raw transaction is checked against every prepared input and output, the exact mining fee, and exactly one zero-value OP_RETURN containing the derived metadata. Missing, changed, duplicate, or extra transaction data is rejected. Spark sends reject OP_RETURN data. A FIRO payout must also use a transparent mainnet address.

rsFIRO deposits call the token's ERC20 `transfer(lockAddress, amount)` function with Rosen metadata appended to the calldata and zero ETH value. The sender needs ETH for gas. Funding checks the mainnet chain ID, token decimals, pending transfer result, EIP-1559 envelope, gas balance, destination, amount, metadata, nonce, and current source-chain height. The signed transaction, its locally derived txid, and the funding wallet are saved before submission; a mined failed token transaction marks the swap failed. Selecting an Ethereum receiving wallet registers rsFIRO in its token list. Ethereum funding retains the wallet's existing restriction on Tor.

Bridge records use the existing swap persistence and polling. The exact signed transaction is journaled as submitting before broadcast. After restart, polling looks up its locally derived txid and, when absent and the saved fees are still current, resubmits only the same signed bytes before marking it broadcast. A rejected or ambiguous submission remains `Verifying`, is rechecked without rebuilding, and blocks another Rosen deposit from that wallet. It becomes `Failed` only when a sufficiently confirmed conflicting spend proves the saved transaction can no longer confirm. Polling matches the exact source transaction, source token, chains, destination, amount and encoded fees. `COMPLETED` requires a payout transaction ID before the swap becomes `Finished`; an unobserved transaction remains pending.

The confirmation screen distinguishes a saved-but-unverified deposit from a successful broadcast. An accepted deposit that later disappears is rechecked and may only be resubmitted using its original signed bytes and still-valid fees. A rejected, malformed or unexpected-response deposit is checked for later observation but is not automatically resubmitted. A fee-mismatch deposit can be resubmitted only if its saved fees become valid again.

For a permanently stuck deposit, do not delete its swap record or send another bridge deposit. Deleting a record cannot revoke signed bytes that might still reach the network. Contact support before creating a normal replacement transaction: FIRO must spend at least one of the **same transparent inputs**, and Ethereum must use the **same sender and nonce**. Once that conflicting transaction has 10 FIRO or 50 Ethereum confirmations, recovery can mark the saved deposit failed and release its wallet guard. A different input or nonce does not cancel the deposit. If the original deposit is found instead, it remains the active swap.

## Protocol and production configuration

Verified on 2026-10-08 against the Rosen production app, configuration version **7.1.1**:

| Value | Mainnet configuration |
| --- | --- |
| rsFIRO Ethereum contract | `0x2744ea5ac9b11cb5e3cd63d3a88e858336aeddc2` |
| Ethereum lock address | `0x451698faa07fc68301af622a3ad42205f13c6e4b` |
| FIRO lock address | `aEF6fyd5jjCPcbiEBZJ2g8583caUme8T7Y` |
| FIRO Ergo token | `581d7df25808881b2b8b9b4e03e2f637c46a94f74a69a5da36434125bacb4e08` |
| Minimum-fee configuration token | `e2ed4d64393222db666f20e67803e9e6fbe6d64531e14ff52ddd95615b0cbf17` |

These identifiers are pinned in `rosen_api.dart`. Reverify them against Rosen's deployed configuration when updating the integration. The [production configuration bundle](https://app.rosen.tech/_next/static/immutable/chunks/3uty44qqykf4p.js) supplied these values; the repository's generated configuration file intentionally contains empty defaults.

Metadata is `destinationChain:uint8 || bridgeFee:uint64BE || networkFee:uint64BE || addressLength:uint8 || addressBytes`. Ethereum's chain code is `3`, and its address is 20 raw bytes. FIRO's chain code is `7`, and its address is encoded as the P2PKH or P2SH output script.

Fee configuration comes from the unspent Ergo box containing both the pinned minimum-fee token and FIRO Ergo token. R4–R7 and R9 are decoded directly from serialized Sigma values. The applicable schedule activates strictly after its source-chain height. The bridge fee is `max(baseFee, amount * feeRatio ~/ 10000)`; the network fee is selected for the destination chain. All calculations use `BigInt`. Discovery uses Rosen scanner heights; funding rechecks with the wallet's source-chain tip. A scheduled fee change within 10 FIRO or 50 Ethereum blocks prevents funding until a stable quote is available.

Recovery waits 10 FIRO or 50 Ethereum confirmations before treating a conflicting spend as final. The FIRO threshold is deliberately more conservative than the [guard's published default of 8](https://github.com/rosen-bridge/guard-service/blob/d138ca7581f2e50f7d7797a39d3f147f35e83358/services/guard-service/config/default.yaml); guard operators can override their defaults.

Status requests use `https://app.rosen.tech/api/v1/events` with the `sourceTxId*` filter and exact matching after the response. Missing or unrecognized status data never marks a swap complete.

## Quote validity and refresh

Rosen FIRO/Ethereum requests have no fixed expiry timestamp or guaranteed duration in minutes. The encoded fees must satisfy the fee schedule applicable at the source transaction's mining height. Rosen's UI caches fee calculations for 10 minutes; that cache duration is not a request validity period. Its FIRO/Ethereum fee lookahead is 10/50 blocks, respectively, and its QR dialog warns that delayed submission can fail when fees change.

Stack re-fetches Rosen fees when creating a swap, preparing funding and confirming the send. A change to either fee component requires refreshing the quote, including decreases or changes that leave the total fee unchanged. Initial confirmation refreshes through the existing estimate API and shows the updated amount for review. An unfunded saved swap refreshes its fees, payout amount and bridge metadata together, rebuilds the transaction and returns to confirmation. It never automatically broadcasts the refreshed transaction. A funded swap cannot be refreshed and remains an ongoing swap while the bridge processes it.

Pinned upstream implementation references:

- [FIRO metadata and payment URI](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/networks/firo/src/utils.ts)
- [Ethereum transfer calldata](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/networks/evm/src/generateTxParameters.ts)
- [Fee schedule selection](https://github.com/rosen-bridge/utils/blob/7379a610271f34e54cedad68b6c2bb89b792b44d/packages/minimum-fee/lib/minimumFeeBox.ts) and [register layout](https://github.com/rosen-bridge/utils/blob/7379a610271f34e54cedad68b6c2bb89b792b44d/packages/minimum-fee/lib/utils.ts)
- [FIRO address codec](https://github.com/rosen-bridge/utils/blob/7379a610271f34e54cedad68b6c2bb89b792b44d/packages/address-codec-chains/firo/lib/firo.ts)
- [Event response and status mapping](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/apps/rosen/src/backend/events/repository.ts)
- [Time-sensitive request warning](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/apps/rosen/src/app/%28main%29/%28bridge%29/SubmitButton.tsx#L154-L156) and [fee cache lifetime](https://github.com/rosen-bridge/ui/blob/8d8183c61cc1ab588a3cce08e74fbf2e9bc2e1c1/apps/rosen/src/networks/firo/server.ts#L25-L28)

## Verification

After completing the repository's [build setup](building.md), including generated configuration, dependencies and required native libraries:

```sh
bash scripts/ensure_test_app_config.sh
flutter test test/services/exchange/rosen/ test/services/exchange/rosen_registration_test.dart test/services/exchange/rosen_token_registration_test.dart test/wallets/firo_op_return_test.dart test/wallets/broadcast_callback_test.dart
```
7 changes: 6 additions & 1 deletion lib/exceptions/exchange/exchange_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@

import '../sw_exception.dart';

enum ExchangeExceptionType { generic, serializeResponseError, orderNotFound }
enum ExchangeExceptionType {
generic,
serializeResponseError,
orderNotFound,
quoteChanged,
}

class ExchangeException extends SWException {
ExchangeExceptionType type;
Expand Down
7 changes: 4 additions & 3 deletions lib/models/isar/exchange_cache/currency.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '../../../services/exchange/exchange.dart';
import '../../../services/exchange/exolix/exolix_exchange.dart';
import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart';
import '../../../services/exchange/nanswap/nanswap_exchange.dart';
import '../../../services/exchange/rosen/rosen_exchange.dart';
import '../../../services/exchange/trocador/trocador_exchange.dart';
import '../../../services/exchange/wizard_swap/wizard_swap_exchange.dart';
import 'pair.dart';
Expand Down Expand Up @@ -80,13 +81,13 @@ class Currency {
return ticker.toLowerCase();
}

return _fuzzyCache ??= switch (Exchange.fromName(
exchangeName,
).runtimeType) {
return _fuzzyCache ??= switch (Exchange.fromName(exchangeName)
.runtimeType) {
// already lower case ticker basically
const (ChangeNowExchange) => network,

const (ExolixExchange) => network.toLowerCase(),
const (RosenExchange) => network.toLowerCase(),

// not used at the time being
// case const (SimpleSwapExchange):
Expand Down
179 changes: 97 additions & 82 deletions lib/pages/exchange_view/choose_address_from_stack_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_balance.dart';
import '../../widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart';

class ChooseAddressFromStackView extends ConsumerStatefulWidget {
const ChooseAddressFromStackView({super.key, required this.coin});
const ChooseAddressFromStackView({
super.key,
required this.coin,
this.transparentOnly = false,
});

final CryptoCurrency coin;
final bool transparentOnly;

static const String routeName = "/chooseFromStack";

Expand Down Expand Up @@ -92,6 +97,7 @@ class _ChooseFromStackViewState
padding: const EdgeInsets.symmetric(vertical: 5.0),
child: _WalletAddressSelectCard(
walletId: walletIds[index],
transparentOnly: widget.transparentOnly,
),
),
),
Expand All @@ -103,9 +109,13 @@ class _ChooseFromStackViewState
}

class _WalletAddressSelectCard extends ConsumerStatefulWidget {
const _WalletAddressSelectCard({required this.walletId});
const _WalletAddressSelectCard({
required this.walletId,
required this.transparentOnly,
});

final String walletId;
final bool transparentOnly;

@override
ConsumerState<_WalletAddressSelectCard> createState() =>
Expand Down Expand Up @@ -189,94 +199,99 @@ class _WalletAddressSelectCardState
],
),
const SizedBox(height: 10),
RawMaterialButton(
splashColor: Theme.of(context).extension<StackColors>()!.highlight,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
if (!widget.transparentOnly)
RawMaterialButton(
splashColor: Theme.of(context)
.extension<StackColors>()!
.highlight,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
),
padding: const EdgeInsets.all(0),
elevation: 0,
onPressed: () async {
Future<String?> _future() async {
final wallet =
ref.read(pWallets).getWallet(widget.walletId)
as SparkInterface;
final sparkAddress = await wallet
.getCurrentReceivingSparkAddress();
if (sparkAddress != null) {
return sparkAddress.value;
padding: const EdgeInsets.all(0),
elevation: 0,
onPressed: () async {
Future<String?> _future() async {
final wallet =
ref.read(pWallets).getWallet(widget.walletId)
as SparkInterface;
final sparkAddress = await wallet
.getCurrentReceivingSparkAddress();
if (sparkAddress != null) {
return sparkAddress.value;
}

return (await wallet.generateNextSparkAddress(saveToDB: true))
.value;
}

return (await wallet.generateNextSparkAddress(
saveToDB: true,
)).value;
}
Exception? ex;
final sparkAddress = await showLoading(
context: context,
message: "Fetching Spark address",
rootNavigator: Util.isDesktop,
delay: const Duration(milliseconds: 1200),
whileFutureAlt: _future,
onException: (e) => ex = e,
);

Exception? ex;
final sparkAddress = await showLoading(
context: context,
message: "Fetching Spark address",
rootNavigator: Util.isDesktop,
delay: const Duration(milliseconds: 1200),
whileFutureAlt: _future,
onException: (e) => ex = e,
);

if (context.mounted) {
if (ex != null) {
await showDialog<void>(
context: context,
builder: (context) => StackOkDialog(
title: "Error",
message: ex
.toString()
.replaceFirst("Exception:", "")
.trim(),
),
);
} else {
Navigator.of(context).pop((
walletId: widget.walletId,
address: sparkAddress,
walletName:
"${ref.read(pWalletName(widget.walletId))} (Spark)",
));
}
}
},
child: Row(
crossAxisAlignment: .center,
mainAxisAlignment: .spaceBetween,
children: [
Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text("Spark address", style: STextStyles.w500_12(context)),
const SizedBox(height: 2),
WalletInfoRowBalance(
if (context.mounted) {
if (ex != null) {
await showDialog<void>(
context: context,
builder: (context) => StackOkDialog(
title: "Error",
message: ex
.toString()
.replaceFirst("Exception:", "")
.trim(),
),
);
} else {
Navigator.of(context).pop((
walletId: widget.walletId,
balanceType: .private,
),
],
),
SizedBox(
width: 25,
height: 25,
child: SvgPicture.asset(
Assets.svg.chevronRight,
colorFilter: ColorFilter.mode(
Theme.of(context).extension<StackColors>()!.textDark,
BlendMode.srcIn,
address: sparkAddress,
walletName:
"${ref.read(pWalletName(widget.walletId))} (Spark)",
));
}
}
},
child: Row(
crossAxisAlignment: .center,
mainAxisAlignment: .spaceBetween,
children: [
Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
"Spark address",
style: STextStyles.w500_12(context),
),
const SizedBox(height: 2),
WalletInfoRowBalance(
walletId: widget.walletId,
balanceType: .private,
),
],
),
SizedBox(
width: 25,
height: 25,
child: SvgPicture.asset(
Assets.svg.chevronRight,
colorFilter: ColorFilter.mode(
Theme.of(context).extension<StackColors>()!.textDark,
BlendMode.srcIn,
),
),
),
),
],
],
),
),
),
const SizedBox(height: 8),
RawMaterialButton(
splashColor: Theme.of(context).extension<StackColors>()!.highlight,
Expand Down
Loading
Loading