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
12 changes: 0 additions & 12 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,6 @@ jobs:
run: |
if [ -n "$CHANGE_NOW" ]; then
echo "$CHANGE_NOW" | base64 --decode > lib/external_api_keys.dart
else
cat > lib/external_api_keys.dart << 'EOF'
const String kChangeNowApiKey = "";
const String kSimpleSwapApiKey = "";
const String kNanswapApiKey = "";
const String kNanoSwapRpcApiKey = "";
const String kWizSwapApiKey = "";
const kShopInBitAccessKey = "";
const kShopInBitPartnerSecret = "";
const kCakePayApiToken = "";
const kExolixApiKey = "";
EOF
fi

- name: Ensure app config for tests
Expand Down
55 changes: 55 additions & 0 deletions docs/rosen_bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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.

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 for complete transparent inputs, the exact bridge lock payment and exactly one zero-value OP_RETURN containing the stored metadata. Missing, changed or duplicate metadata 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, token balance, gas estimate, destination, amount, metadata and current source-chain height. 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. Broadcast transaction IDs are captured before local wallet-history updates; saved pending deposits remain ongoing swaps after restart. 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.

## Protocol and production configuration

Verified on 2026-09-17 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–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.

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.

Other Stack providers use the same estimate/confirmation machinery. ChangeNOW's expired `rateId` response is recognized, but previously produced an ordinary trade-creation error; there is no existing automatic stale-quote recovery or expiry timer shared by providers. Rosen uses the existing dialog and confirmation components with an explicit **Refresh quote** action.

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/wallets/firo_op_return_test.dart test/services/exchange/rosen_registration_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
2 changes: 2 additions & 0 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 @@ -87,6 +88,7 @@ class Currency {
const (ChangeNowExchange) => network,

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

// not used at the time being
// case const (SimpleSwapExchange):
Expand Down
122 changes: 71 additions & 51 deletions lib/pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import 'package:isar_community/isar.dart';

import '../../../app_config.dart';
import '../../../db/isar/main_db.dart';
import '../../../models/isar/models/ethereum/eth_contract.dart';
import '../../../models/isar/models/solana/sol_contract.dart';
Expand Down Expand Up @@ -80,6 +81,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
final List<AddTokenListElementData> tokenEntities = [];

final bool isDesktop = Util.isDesktop;
final bool isCampfire = AppConfig.appName == "Campfire";

List<AddTokenListElementData> filter(
String text,
Expand All @@ -100,11 +102,10 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
}

Future<void> onNextPressed() async {
final selectedTokens =
tokenEntities
.where((e) => e.selected)
.map((e) => e.token.address)
.toList();
final selectedTokens = tokenEntities
.where((e) => e.selected)
.map((e) => e.token.address)
.toList();

final wallet = ref.read(pWallets).getWallet(widget.walletId);

Expand Down Expand Up @@ -177,7 +178,9 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
tokenEntities.add(
AddTokenListElementData(contract!)..selected = true,
);
tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name));
tokenEntities.sort(
(a, b) => a.token.name.compareTo(b.token.name),
);
}
});
}
Expand All @@ -199,9 +202,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
),
);
} else {
final result = await Navigator.of(
context,
).pushNamed(
final result = await Navigator.of(context).pushNamed(
AddCustomSolanaTokenView.routeName,
arguments: widget.walletId,
);
Expand All @@ -228,9 +229,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
if (tokenEntities
.where((e) => e.token.address == token!.address)
.isEmpty) {
tokenEntities.add(
AddTokenListElementData(token!)..selected = true,
);
tokenEntities.add(AddTokenListElementData(token!)..selected = true);
tokenEntities.sort((a, b) => a.token.name.compareTo(b.token.name));
}
});
Expand All @@ -244,6 +243,14 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
_searchFocusNode = FocusNode();

final wallet = ref.read(pWallets).getWallet(widget.walletId);
final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId));
final shouldMarkAsSelectedContracts = [
...walletContracts,
...(widget.contractsToMarkSelected ?? []),
];
final selectedContractAddresses = shouldMarkAsSelectedContracts
.map((e) => e.toLowerCase())
.toSet();

if (wallet is SolanaWallet) {
final contracts = MainDB.instance
Expand All @@ -266,29 +273,36 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
.getEthContracts()
.sortByName()
.findAllSync();

if (contracts.isEmpty) {
contracts.addAll(DefaultTokens.list);
final defaults = DefaultTokens.forApp(AppConfig.appName);
final existingAddresses = contracts
.map((e) => e.address.toLowerCase())
.toSet();
final missingDefaults = defaults
.where((token) => existingAddresses.add(token.address.toLowerCase()))
.toList();

if (missingDefaults.isNotEmpty) {
contracts.addAll(missingDefaults);
MainDB.instance
.putEthContracts(contracts)
.putEthContracts(missingDefaults)
.then(
(_) => ref.read(priceAnd24hChangeNotifierProvider).updatePrice(),
);
}

contracts.retainWhere(
(token) =>
DefaultTokens.isAllowedForApp(AppConfig.appName, token) ||
selectedContractAddresses.contains(token.address.toLowerCase()),
);

tokenEntities.addAll(contracts.map((e) => AddTokenListElementData(e)));
}

// Get token addresses.
final walletContracts = ref.read(pWalletTokenAddresses(widget.walletId));

final shouldMarkAsSelectedContracts = [
...walletContracts,
...(widget.contractsToMarkSelected ?? []),
];

for (final e in tokenEntities) {
e.selected = shouldMarkAsSelectedContracts.contains(e.token.address);
e.selected = wallet is EthereumWallet
? selectedContractAddresses.contains(e.token.address.toLowerCase())
: shouldMarkAsSelectedContracts.contains(e.token.address);
}

super.initState();
Expand Down Expand Up @@ -318,7 +332,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
walletName,
style: STextStyles.desktopSubtitleH2(context),
),
trailing: widget.contractsToMarkSelected == null
trailing: widget.contractsToMarkSelected == null && !isCampfire
? Padding(
padding: const EdgeInsets.only(right: 24),
child: SizedBox(
Expand Down Expand Up @@ -410,14 +424,15 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
children: [
Expanded(
child: SecondaryButton(
label: "Add custom token",
buttonHeight: ButtonHeight.l,
onPressed: _addToken,
if (!isCampfire)
Expanded(
child: SecondaryButton(
label: "Add custom token",
buttonHeight: ButtonHeight.l,
onPressed: _addToken,
),
),
),
const SizedBox(width: 16),
if (!isCampfire) const SizedBox(width: 16),
Expanded(
child: PrimaryButton(
label: "Done",
Expand Down Expand Up @@ -526,28 +541,33 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
},
),
actions: [
Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10, right: 20),
child: AspectRatio(
aspectRatio: 1,
child: AppBarIconButton(
size: 36,
shadows: const [],
color: Theme.of(
context,
).extension<StackColors>()!.background,
icon: SvgPicture.asset(
Assets.svg.circlePlusFilled,
if (!isCampfire)
Padding(
padding: const EdgeInsets.only(
top: 10,
bottom: 10,
right: 20,
),
child: AspectRatio(
aspectRatio: 1,
child: AppBarIconButton(
size: 36,
shadows: const [],
color: Theme.of(
context,
).extension<StackColors>()!.topNavIconPrimary,
width: 20,
height: 20,
).extension<StackColors>()!.background,
icon: SvgPicture.asset(
Assets.svg.circlePlusFilled,
color: Theme.of(
context,
).extension<StackColors>()!.topNavIconPrimary,
width: 20,
height: 20,
),
onPressed: _addToken,
),
onPressed: _addToken,
),
),
),
],
),
body: SafeArea(
Expand Down Expand Up @@ -620,7 +640,7 @@ class _EditWalletTokensViewState extends ConsumerState<EditWalletTokensView> {
child: AddTokenList(
walletId: widget.walletId,
items: filter(_searchTerm, tokenEntities),
addFunction: _addToken,
addFunction: isCampfire ? null : _addToken,
),
),
const SizedBox(height: 16),
Expand Down
Loading
Loading