Skip to content

Commit

Permalink
- Add gas sponsorship support
Browse files Browse the repository at this point in the history
- Refactor transaction batching flow
- Replace Dio with JsonRPC clients for bundlers and paymasters
- Remove pvg calculation from client app
- Refactor transaction watchdog
- Add websocket subscriptions for user ops events
- Refactor UserOperation receipt handling
- Migrate to Dart 3
- Bump version to 0.1.5
  • Loading branch information
andrewwahid authored and Sednaoui committed Nov 29, 2023
1 parent bda461b commit 0211742
Show file tree
Hide file tree
Showing 43 changed files with 1,153 additions and 685 deletions.
18 changes: 10 additions & 8 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,22 @@ SECURITY_URL="Run candidelabs/security-gateway"
OPTIMISM_BUNDLER_NODE=
GOERLI_BUNDLER_NODE=
OPTIMISM_GOERLI_BUNDLER_NODE=
SEPOLIA_BUNDLER_NODE=

# Node RPC - Add your own RPC endpoints
GOERLI_NODE_RPC_ENDPOINT=https://goerli.llamarpc.com
OPTIMISM_GOERLI_NODE_RPC_ENDPOINT=https://goerli.optimism.io
OPTIMISM_NODE_RPC_ENDPOINT=https://mainnet.optimism.io
SEPOLIA_NODE_RPC_ENDPOINT=https://rpc.sepolia.org
MAINNET_NODE_RPC_ENDPOINT=https://eth.llamarpc.com
# Node HTTP RPC - Add your own HTTP RPC endpoints
GOERLI_NODE_HTTP_RPC_ENDPOINT=https://goerli.llamarpc.com
OPTIMISM_GOERLI_NODE_HTTP_RPC_ENDPOINT=https://goerli.optimism.io
OPTIMISM_NODE_HTTP_RPC_ENDPOINT=https://mainnet.optimism.io
MAINNET_NODE_HTTP_RPC_ENDPOINT=https://eth.llamarpc.com

# Node WSS RPC - Websocket connections, use your own (optional)
GOERLI_NODE_WSS_RPC_ENDPOINT=
OPTIMISM_GOERLI_NODE_WSS_RPC_ENDPOINT=
OPTIMISM_NODE_WSS_RPC_ENDPOINT=

# Paymaster endpoints (optional) - Run candidelabs/Candide-Paymaster-RPC on each network
GOERLI_PAYMASTER=
OPTIMISM_GOERLI_PAYMASTER=
OPTIMISM_PAYMASTER=
SEPOLIA_PAYMASTER=
POLYGON_PAYMASTER=

# Magic Link (optional) - Get a magic link key to try email recovery on testnet
Expand Down
7 changes: 6 additions & 1 deletion analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml

analyzer:
enable-experiment:
- records
- patterns

linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
Expand All @@ -23,7 +28,7 @@ linter:
# producing the lint.
rules:
library_private_types_in_public_api: false
lowercase_with_underscores: false
# lowercase_with_underscores: false
no_leading_underscores_for_local_identifiers: false
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app')
}

task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
30 changes: 25 additions & 5 deletions lib/config/env.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class Env {
static late String optimismGoerliRpcEndpoint;
static late String sepoliaRpcEndpoint;
//
static late String optimismWebsocketsRpcEndpoint;
static late String goerliWebsocketsRpcEndpoint;
static late String optimismGoerliWebsocketsRpcEndpoint;
static late String sepoliaWebsocketsRpcEndpoint;
//
static late String walletConnectProjectId;
static late String magicApiKey;

Expand All @@ -34,6 +39,17 @@ class Env {
}
}

static String getWebsocketsNodeUrlByChainId(int chainId){
switch (chainId){
case 5: return goerliWebsocketsRpcEndpoint;
case 10: return optimismWebsocketsRpcEndpoint;
case 420: return optimismGoerliWebsocketsRpcEndpoint;
case 11155111: return sepoliaWebsocketsRpcEndpoint;
//
default: return optimismWebsocketsRpcEndpoint;
}
}

static String getBundlerUrlByChainId(int chainId){
switch (chainId){
case 5: return goerliBundlerEndpoint;
Expand Down Expand Up @@ -61,23 +77,27 @@ class Env {
explorerUri = dotenv.get('EXPLORER_URL', fallback: 'http://192.168.1.3:3000');
securityUri = dotenv.get('SECURITY_URL', fallback: 'http://192.168.1.3:3004');
//
goerliRpcEndpoint = dotenv.get('GOERLI_NODE_RPC_ENDPOINT', fallback: '-');
goerliRpcEndpoint = dotenv.get('GOERLI_NODE_HTTP_RPC_ENDPOINT', fallback: '-');
goerliWebsocketsRpcEndpoint = dotenv.get('GOERLI_NODE_WSS_RPC_ENDPOINT', fallback: '-');
goerliBundlerEndpoint = dotenv.get('GOERLI_BUNDLER_NODE', fallback: '-');
goerliPaymasterEndpoint = dotenv.get('GOERLI_PAYMASTER', fallback: '-');
//
optimismGoerliRpcEndpoint = dotenv.get('OPTIMISM_GOERLI_NODE_RPC_ENDPOINT', fallback: '-');
optimismGoerliRpcEndpoint = dotenv.get('OPTIMISM_GOERLI_NODE_HTTP_RPC_ENDPOINT', fallback: '-');
optimismGoerliWebsocketsRpcEndpoint = dotenv.get('OPTIMISM_GOERLI_NODE_WSS_RPC_ENDPOINT', fallback: '-');
optimismGoerliBundlerEndpoint = dotenv.get('OPTIMISM_GOERLI_BUNDLER_NODE', fallback: '-');
optimismGoerliPaymasterEndpoint = dotenv.get('OPTIMISM_GOERLI_PAYMASTER', fallback: '-');
//
sepoliaRpcEndpoint = dotenv.get('SEPOLIA_NODE_RPC_ENDPOINT', fallback: '-');
sepoliaRpcEndpoint = dotenv.get('SEPOLIA_NODE_HTTP_RPC_ENDPOINT', fallback: '-');
sepoliaWebsocketsRpcEndpoint = dotenv.get('SEPOLIA_NODE_WSS_RPC_ENDPOINT', fallback: '-');
sepoliaBundlerEndpoint = dotenv.get('SEPOLIA_BUNDLER_NODE', fallback: '-');
sepoliaPaymasterEndpoint = dotenv.get('SEPOLIA_PAYMASTER', fallback: '-');
//
optimismRpcEndpoint = dotenv.get('OPTIMISM_NODE_RPC_ENDPOINT', fallback: '-');
optimismRpcEndpoint = dotenv.get('OPTIMISM_NODE_HTTP_RPC_ENDPOINT', fallback: '-');
optimismWebsocketsRpcEndpoint = dotenv.get('OPTIMISM_NODE_WSS_RPC_ENDPOINT', fallback: '-');
optimismBundlerEndpoint = dotenv.get('OPTIMISM_BUNDLER_NODE', fallback: '-');
optimismPaymasterEndpoint = dotenv.get('OPTIMISM_PAYMASTER', fallback: '-');
//
mainnetRpcEndpoint = dotenv.get('MAINNET_NODE_RPC_ENDPOINT', fallback: '-');
mainnetRpcEndpoint = dotenv.get('MAINNET_NODE_HTTP_RPC_ENDPOINT', fallback: '-');
//
magicApiKey = dotenv.get('MAGIC_API_KEY', fallback: '-');
walletConnectProjectId = dotenv.get('WALLET_CONNECT_PROJECT_ID', fallback: '-');
Expand Down
54 changes: 40 additions & 14 deletions lib/config/network.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import 'package:candide_mobile_app/config/env.dart';
import 'package:candide_mobile_app/controller/persistent_data.dart';
import 'package:candide_mobile_app/models/gas_estimators/gas_estimator.dart';
import 'package:candide_mobile_app/models/gas_estimators/l1_gas_estimator.dart';
import 'package:candide_mobile_app/models/gas_estimators/l2_gas_estimator.dart';
import 'package:candide_mobile_app/services/bundler.dart';
import 'package:candide_mobile_app/services/paymaster.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:http/http.dart';
import 'package:magic_sdk/magic_sdk.dart';
import 'package:web3dart/web3dart.dart';
import 'package:web_socket_channel/io.dart';

class Networks {
static const List<int> DEFAULT_HIDDEN_NETWORKS = [5, 420];
Expand All @@ -26,6 +26,12 @@ class Networks {
}
}

static bool _hasWebsocketsChannel(int chainId){
var wssEndpoint = Env.getWebsocketsNodeUrlByChainId(chainId).trim();
if (wssEndpoint == "-" || wssEndpoint == "") return false;
return true;
}

static void initialize(){
instances.addAll(
[
Expand All @@ -51,9 +57,15 @@ class Networks {
entrypoint: EthereumAddress.fromHex("0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"),
multiSendCall: EthereumAddress.fromHex("0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B"),
//
gasEstimator: L2GasEstimator(chainId: 10, ovmGasOracle: EthereumAddress.fromHex("0x420000000000000000000000000000000000000F")),
//
client: Web3Client(Env.optimismRpcEndpoint, Client()),
client: Web3Client(
Env.optimismRpcEndpoint,
Client(),
socketConnector: _hasWebsocketsChannel(10) ? () {
return IOWebSocketChannel.connect(Env.optimismWebsocketsRpcEndpoint).cast<String>();
} : null,
),
bundler: Bundler(Env.optimismBundlerEndpoint, Client()),
paymaster: Paymaster(Env.optimismPaymasterEndpoint, Client()),
//
features: {
"deposit": {
Expand Down Expand Up @@ -93,9 +105,15 @@ class Networks {
entrypoint: EthereumAddress.fromHex("0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"),
multiSendCall: EthereumAddress.fromHex("0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B"),
//
gasEstimator: L2GasEstimator(chainId: 420, ovmGasOracle: EthereumAddress.fromHex("0x420000000000000000000000000000000000000F")),
//
client: Web3Client(Env.optimismGoerliRpcEndpoint, Client()),
client: Web3Client(
Env.optimismGoerliRpcEndpoint,
Client(),
socketConnector: _hasWebsocketsChannel(420) ? () {
return IOWebSocketChannel.connect(Env.optimismGoerliWebsocketsRpcEndpoint).cast<String>();
} : null,
),
bundler: Bundler(Env.optimismGoerliBundlerEndpoint, Client()),
paymaster: Paymaster(Env.optimismGoerliPaymasterEndpoint, Client()),
//
features: {
"deposit": {
Expand Down Expand Up @@ -137,9 +155,15 @@ class Networks {
//
ensRegistryWithFallback: EthereumAddress.fromHex("0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"),
//
gasEstimator: L1GasEstimator(chainId: 5),
//
client: Web3Client(Env.goerliRpcEndpoint, Client()),
client: Web3Client(
Env.goerliRpcEndpoint,
Client(),
socketConnector: _hasWebsocketsChannel(5) ? () {
return IOWebSocketChannel.connect(Env.goerliWebsocketsRpcEndpoint).cast<String>();
} : null,
),
bundler: Bundler(Env.goerliBundlerEndpoint, Client()),
paymaster: Paymaster(Env.goerliPaymasterEndpoint, Client()),
//
features: {
"deposit": {
Expand Down Expand Up @@ -232,8 +256,9 @@ class Network{
EthereumAddress entrypoint;
EthereumAddress multiSendCall;
EthereumAddress? ensRegistryWithFallback;
GasEstimator gasEstimator;
Web3Client client;
Bundler bundler;
Paymaster paymaster;
Magic? magicInstance;
Map<String, dynamic> features;
//
Expand All @@ -260,8 +285,9 @@ class Network{
required this.entrypoint,
required this.multiSendCall,
this.ensRegistryWithFallback,
required this.gasEstimator,
required this.client,
required this.bundler,
required this.paymaster,
required this.features,
this.visible=true});

Expand Down
13 changes: 10 additions & 3 deletions lib/controller/persistent_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,22 @@ class PersistentData {
transactionsActivity.add(activity);
}
if (activity.status == "pending"){
TransactionWatchdog.addTransactionActivity(activity);
Network? network = Networks.getByChainId(account.chainId);
TransactionWatchdog.addTransactionActivity(activity, network!);
}
}

static loadTransactionsActivity(Account account){
_loadedChainId = account.chainId;
transactionsActivity.clear();
List transactionsAsJson = Hive.box("activity").get("transactions(${account.address.hex}-${account.chainId})") ?? []; // List<Json>
Network? network = Networks.getByChainId(account.chainId);
if (network == null) return;
for (Map transactionJson in transactionsAsJson){
var activity = TransactionActivity.fromJson(transactionJson);
transactionsActivity.add(activity);
if (activity.status == "pending"){
TransactionWatchdog.addTransactionActivity(activity);
TransactionWatchdog.addTransactionActivity(activity, network);
}
}
}
Expand Down Expand Up @@ -414,6 +417,7 @@ class AccountGuardian {

class TransactionActivity {
String version;
int nonce;
DateTime date;
String action;
String title;
Expand All @@ -425,10 +429,11 @@ class TransactionActivity {
int checkCount = 0; // used for exponential check of user operation receipt in transaction watchdog
late TransactionFeeActivityData fee;

static const String _version = "0.0.1";
static const String _version = "0.0.2";

TransactionActivity({
this.version = _version,
required this.nonce,
required this.date,
required this.action,
required this.title,
Expand All @@ -439,6 +444,7 @@ class TransactionActivity {

TransactionActivity.fromJson(Map json)
: version = json['version'] ?? "0.0.0",
nonce = int.parse(json['nonce'] ?? "-1"),
date = DateTime.fromMillisecondsSinceEpoch(int.parse(json['date'])),
action = json['action'],
title = json['title'],
Expand All @@ -450,6 +456,7 @@ class TransactionActivity {

Map<String, dynamic> toJson() => {
'version': version,
'nonce': nonce.toString(),
'date': date.millisecondsSinceEpoch.toString(),
'action': action,
'title': title,
Expand Down
5 changes: 5 additions & 0 deletions lib/controller/token_info_storage.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:candide_mobile_app/config/network.dart';
import 'package:get/get.dart';
import 'package:hive_flutter/hive_flutter.dart';

Expand Down Expand Up @@ -208,6 +209,10 @@ class TokenInfoStorage {
return null;
}

static TokenInfo getNativeTokenForNetwork(Network network){
return TokenInfoStorage.getTokenByAddress(network.nativeCurrencyAddress.hex, chainId: network.chainId.toInt())!;
}

}

class TokenInfo {
Expand Down
19 changes: 9 additions & 10 deletions lib/controller/transaction_confirm_controller.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:bot_toast/bot_toast.dart';
import 'package:candide_mobile_app/config/network.dart';
import 'package:candide_mobile_app/config/theme.dart';
import 'package:candide_mobile_app/controller/persistent_data.dart';
import 'package:candide_mobile_app/controller/signers_controller.dart';
Expand All @@ -7,7 +8,6 @@ import 'package:candide_mobile_app/models/relay_response.dart';
import 'package:candide_mobile_app/screens/home/activity/components/transaction_activity_details_card.dart';
import 'package:candide_mobile_app/screens/onboard/create_account/pin_entry_screen.dart';
import 'package:candide_mobile_app/services/bundler.dart';
import 'package:candide_mobile_app/services/explorer.dart';
import 'package:candide_mobile_app/utils/constants.dart';
import 'package:candide_mobile_app/utils/events.dart';
import 'package:candide_mobile_app/utils/utils.dart';
Expand Down Expand Up @@ -59,11 +59,9 @@ class TransactionConfirmController {
}
}
//
await Explorer.fetchAddressOverview(account: PersistentData.selectedAccount, skipBalances: true);
UserOperation unsignedUserOperation = await batch.toUserOperation(
BigInt.from(PersistentData.accountStatus.nonce),
proxyDeployed: PersistentData.accountStatus.proxyDeployed,
);
Network network = Networks.selected();
await batch.finalize();
UserOperation unsignedUserOperation = batch.userOperation;
//
var signedUserOperation = await Bundler.signUserOperations(
credentials,
Expand All @@ -79,7 +77,7 @@ class TransactionConfirmController {
align: Alignment.topCenter,
);
//
RelayResponse? response = await Bundler.relayUserOperation(signedUserOperation, PersistentData.selectedAccount.chainId);
RelayResponse? response = await network.bundler.sendUserOperation(signedUserOperation);
if (response?.status.toLowerCase() == "pending"){
Utils.showBottomStatus(
"Transaction still pending",
Expand Down Expand Up @@ -126,10 +124,11 @@ class TransactionConfirmController {
}
transactionActivity.hash = response?.hash;
transactionActivity.status = response?.status ?? "failed-to-submit";
bool includesPaymaster = signedUserOperation.paymasterAndData.replaceAll("0x", "").isNotEmpty;
transactionActivity.fee = TransactionFeeActivityData(
paymasterAddress: batch.includesPaymaster ? batch.paymasterResponse.paymasterData.paymaster.hexEip55 : Constants.addressZeroHex,
sponsoredEventTopic: batch.includesPaymaster ? batch.paymasterResponse.paymasterData.eventTopic : "0x",
currencyAddress: batch.getFeeToken(),
paymasterAddress: includesPaymaster ? batch.paymasterResponse.paymasterData.address.hexEip55 : Constants.addressZeroHex,
sponsoredEventTopic: includesPaymaster ? batch.paymasterResponse.paymasterData.sponsoredEventTopic : "0x",
currencyAddress: batch.selectedFeeToken?.token.address ?? network.nativeCurrencyAddress.hex,
fee: batch.getFee(),
);
transactionActivity.date = DateTime.now();
Expand Down
8 changes: 5 additions & 3 deletions lib/controller/wallet_connect/wallet_connect_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,11 @@ class WalletConnectController {
);
wcBatch.transactions.add(transaction);
//
await wcBatch.fetchPaymasterResponse();
await wcBatch.prepare();
//
cancelLoad();
TransactionActivity transactionActivity = TransactionActivity(
nonce: wcBatch.userOperation.nonce.toInt(),
date: DateTime.now(),
action: isTransfer ? "transfer" : "wc-transaction",
title: isTransfer ? "Sent ETH" : "Contract Interaction",
Expand Down Expand Up @@ -351,10 +352,11 @@ class WalletConnectController {
wcBatch.transactions.add(transaction);
}
//
await wcBatch.fetchPaymasterResponse();
await wcBatch.prepare(checkSponsorshipEligibility: true);
//
cancelLoad();
TransactionActivity transactionActivity = TransactionActivity(
nonce: wcBatch.userOperation.nonce.toInt(),
date: DateTime.now(),
action: "wc-transaction",
title: "Contract Interaction",
Expand Down Expand Up @@ -401,7 +403,7 @@ class WalletConnectController {
void walletGetBundleStatus(JsonRpcRequest? payload) async {
if (payload == null) return;
if (payload.params == null) return;
Map? result = await TransactionWatchdog.getBundleStatus(payload.params![0]);
Map? result = await TransactionWatchdog.getBundleStatus(payload.params![0], Networks.selected());
if (result == null){
connector.rejectRequest(id: payload.id);
return;
Expand Down
Loading

0 comments on commit 0211742

Please sign in to comment.