Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5900125
feat: add rsFIRO Ethereum token
reubenyap Sep 16, 2026
276f879
fix: remove invalid final parameter modifiers
reubenyap Sep 16, 2026
69f4682
feat: add Rosen Bridge FIRO swaps with quote refresh
reubenyap Sep 17, 2026
45abd00
fix: repair CI test compilation
reubenyap Sep 17, 2026
ceceae3
feat: add Ethereum assets to Campfire
reubenyap Sep 17, 2026
aa1ac2b
chore: retry CI
reubenyap Sep 17, 2026
2556870
style: format Campfire token views
reubenyap Sep 17, 2026
62f32da
style: match CI Dart formatter
reubenyap Sep 17, 2026
668f7f6
fix: remove invalid final parameter modifiers
reubenyap Sep 16, 2026
156dffb
fix: repair CI test compilation
reubenyap Sep 17, 2026
7a527df
style: match CI Dart formatter
reubenyap Sep 17, 2026
6b79e97
test: skip native coinlib signing on Linux
reubenyap Sep 17, 2026
f677b88
ci: bound Rosen test diagnostics
reubenyap Sep 17, 2026
fbf6ad2
fix: add missing Rosen UI imports
reubenyap Sep 17, 2026
36d7751
ci: serialize Flutter test processes
reubenyap Sep 17, 2026
a64f059
ci: isolate crashing Flutter test processes
reubenyap Sep 17, 2026
4facd0d
ci: bound isolated test diagnostics
reubenyap Sep 17, 2026
b3bf03c
Merge rsFIRO and Campfire support for shared token metadata
reubenyap Sep 18, 2026
caa71eb
refactor: reuse token metadata and existing bridge workflows
reubenyap Sep 18, 2026
0cdf9e4
ci: stop discovering dormant wallet mock suites
reubenyap Sep 19, 2026
b156385
ci: run full suite without crashing coverage collector
reubenyap Sep 19, 2026
9fbb205
fix: use package imports to prevent coverage compiler crash
reubenyap Sep 19, 2026
7858311
ci: support coverage uploads from fork pull requests
reubenyap Sep 19, 2026
585b248
ci: install signature verification tool for coverage uploader
reubenyap Sep 19, 2026
4d434c9
test: cover Rosen funding rejection guards
reubenyap Sep 19, 2026
0718d2f
test: cover Rosen API and exchange workflows
reubenyap Sep 19, 2026
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
25 changes: 10 additions & 15 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ on: [pull_request]
jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: read
Expand All @@ -26,6 +27,9 @@ jobs:
- name: Get dependencies
run: flutter pub get

- name: Install coverage uploader dependency
run: apt-get update && apt-get install -y --no-install-recommends gpg

- name: Create git_versions.dart stubs
run: |
mkdir -p crypto_plugins/flutter_libepiccash/lib
Expand All @@ -45,18 +49,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 Expand Up @@ -84,14 +76,17 @@ jobs:
# - name: Analyze
# run: flutter analyze
- name: Test
timeout-minutes: 20
run: |
bash scripts/ensure_test_app_config.sh
test -s lib/app_config.g.dart
grep -Fq "part of 'app_config.dart';" lib/app_config.g.dart
flutter test --coverage
flutter test --coverage --reporter expanded
- name: Upload to code coverage
uses: codecov/codecov-action@v1.2.2
uses: codecov/codecov-action@v7.1.1
if: success() || failure()
with:
token: ${{secrets.CODECOV_TOKEN}}
file: coverage/lcov.info
files: coverage/lcov.info
disable_search: true
fail_ci_if_error: true
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