feat(evm): add grant management for EVM wallets
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful

This commit is contained in:
hdbg
2026-03-16 04:40:36 +01:00
parent 6ed8150e48
commit 088fa6fe72
31 changed files with 3138 additions and 378 deletions

View File

@@ -0,0 +1,103 @@
import 'dart:async';
import 'dart:convert';
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/features/connection/server_info_storage.dart';
import 'package:arbiter/features/identity/pk_manager.dart';
import 'package:arbiter/proto/arbiter.pbgrpc.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:grpc/grpc.dart';
import 'package:mtcore/markettakers.dart';
Future<Connection> connectAndAuthorize(
StoredServerInfo serverInfo,
KeyHandle key, {
String? bootstrapToken,
}) async {
try {
final connection = await _connect(serverInfo);
talker.info(
'Connected to server at ${serverInfo.address}:${serverInfo.port}',
);
final pubkey = await key.getPublicKey();
final req = AuthChallengeRequest(
pubkey: pubkey,
bootstrapToken: bootstrapToken,
keyType: switch (key.alg) {
KeyAlgorithm.rsa => KeyType.KEY_TYPE_RSA,
KeyAlgorithm.ecdsa => KeyType.KEY_TYPE_ECDSA_SECP256K1,
KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519,
},
);
await connection.send(UserAgentRequest(authChallengeRequest: req));
talker.info(
"Sent auth challenge request with pubkey ${base64Encode(pubkey)}",
);
final response = await connection.receive();
talker.info('Received response from server, checking auth flow...');
if (response.hasAuthOk()) {
talker.info('Authentication successful, connection established');
return connection;
}
if (!response.hasAuthChallenge()) {
throw Exception(
'Expected AuthChallengeResponse, got ${response.whichPayload()}',
);
}
final challenge = _formatChallenge(response.authChallenge, pubkey);
talker.info(
'Received auth challenge, signing with key ${base64Encode(pubkey)}',
);
final signature = await key.sign(challenge);
await connection.send(
UserAgentRequest(authChallengeSolution: AuthChallengeSolution(signature: signature)),
);
talker.info('Sent auth challenge solution, waiting for server response...');
final solutionResponse = await connection.receive();
if (!solutionResponse.hasAuthOk()) {
throw Exception(
'Expected AuthChallengeSolutionResponse, got ${solutionResponse.whichPayload()}',
);
}
talker.info('Authentication successful, connection established');
return connection;
} catch (e) {
throw Exception('Failed to connect to server: $e');
}
}
Future<Connection> _connect(StoredServerInfo serverInfo) async {
final channel = ClientChannel(
serverInfo.address,
port: serverInfo.port,
options: ChannelOptions(
connectTimeout: const Duration(seconds: 10),
credentials: ChannelCredentials.secure(
onBadCertificate: (cert, host) {
return true;
},
),
),
);
final client = ArbiterServiceClient(channel);
final tx = StreamController<UserAgentRequest>();
final rx = client.userAgent(tx.stream);
return Connection(channel: channel, tx: tx, rx: rx);
}
List<int> _formatChallenge(AuthChallenge challenge, List<int> pubkey) {
final encodedPubkey = base64Encode(pubkey);
final payload = "${challenge.nonce}:$encodedPubkey";
return utf8.encode(payload);
}

View File

@@ -1,21 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'package:arbiter/features/connection/server_info_storage.dart';
import 'package:arbiter/features/identity/pk_manager.dart';
import 'package:arbiter/proto/arbiter.pbgrpc.dart';
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:cryptography/cryptography.dart';
import 'package:grpc/grpc.dart';
import 'package:mtcore/markettakers.dart';
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
class Connection {
final ClientChannel channel;
final StreamController<UserAgentRequest> _tx;
final StreamIterator<UserAgentResponse> _rx;
Future<void> _requestQueue = Future<void>.value();
Connection({
required this.channel,
@@ -25,6 +17,7 @@ class Connection {
_rx = StreamIterator(rx);
Future<void> send(UserAgentRequest request) async {
talker.debug('Sending request: ${request.toDebugString()}');
_tx.add(request);
}
@@ -33,6 +26,7 @@ class Connection {
if (!hasValue) {
throw Exception('Connection closed while waiting for server response.');
}
talker.debug('Received response: ${_rx.current.toDebugString()}');
return _rx.current;
}
@@ -41,258 +35,3 @@ class Connection {
await channel.shutdown();
}
}
Future<Connection> _connect(StoredServerInfo serverInfo) async {
final channel = ClientChannel(
serverInfo.address,
port: serverInfo.port,
options: ChannelOptions(
connectTimeout: const Duration(seconds: 10),
credentials: ChannelCredentials.secure(
onBadCertificate: (cert, host) {
return true;
},
),
),
);
final client = ArbiterServiceClient(channel);
final tx = StreamController<UserAgentRequest>();
final rx = client.userAgent(tx.stream);
return Connection(channel: channel, tx: tx, rx: rx);
}
List<int> formatChallenge(AuthChallenge challenge, List<int> pubkey) {
final encodedPubkey = base64Encode(pubkey);
final payload = "${challenge.nonce}:$encodedPubkey";
return utf8.encode(payload);
}
const _vaultKeyAssociatedData = 'arbiter.vault.password';
Future<List<WalletEntry>> listEvmWallets(Connection connection) async {
await connection.send(UserAgentRequest(evmWalletList: Empty()));
final response = await connection.receive();
if (!response.hasEvmWalletList()) {
throw Exception(
'Expected EVM wallet list response, got ${response.whichPayload()}',
);
}
final result = response.evmWalletList;
switch (result.whichResult()) {
case WalletListResponse_Result.wallets:
return result.wallets.wallets.toList(growable: false);
case WalletListResponse_Result.error:
throw Exception(_describeEvmError(result.error));
case WalletListResponse_Result.notSet:
throw Exception('EVM wallet list response was empty.');
}
}
Future<void> createEvmWallet(Connection connection) async {
await connection.send(UserAgentRequest(evmWalletCreate: Empty()));
final response = await connection.receive();
if (!response.hasEvmWalletCreate()) {
throw Exception(
'Expected EVM wallet create response, got ${response.whichPayload()}',
);
}
final result = response.evmWalletCreate;
switch (result.whichResult()) {
case WalletCreateResponse_Result.wallet:
return;
case WalletCreateResponse_Result.error:
throw Exception(_describeEvmError(result.error));
case WalletCreateResponse_Result.notSet:
throw Exception('Wallet creation returned no result.');
}
}
Future<BootstrapResult> bootstrapVault(
Connection connection,
String password,
) async {
final encryptedKey = await _encryptVaultKeyMaterial(connection, password);
await connection.send(
UserAgentRequest(
bootstrapEncryptedKey: BootstrapEncryptedKey(
nonce: encryptedKey.nonce,
ciphertext: encryptedKey.ciphertext,
associatedData: encryptedKey.associatedData,
),
),
);
final response = await connection.receive();
if (!response.hasBootstrapResult()) {
throw Exception(
'Expected bootstrap result, got ${response.whichPayload()}',
);
}
return response.bootstrapResult;
}
Future<UnsealResult> unsealVault(Connection connection, String password) async {
final encryptedKey = await _encryptVaultKeyMaterial(connection, password);
await connection.send(
UserAgentRequest(
unsealEncryptedKey: UnsealEncryptedKey(
nonce: encryptedKey.nonce,
ciphertext: encryptedKey.ciphertext,
associatedData: encryptedKey.associatedData,
),
),
);
final response = await connection.receive();
if (!response.hasUnsealResult()) {
throw Exception('Expected unseal result, got ${response.whichPayload()}');
}
return response.unsealResult;
}
Future<_EncryptedVaultKey> _encryptVaultKeyMaterial(
Connection connection,
String password,
) async {
final keyExchange = X25519();
final cipher = Xchacha20.poly1305Aead();
final clientKeyPair = await keyExchange.newKeyPair();
final clientPublicKey = await clientKeyPair.extractPublicKey();
await connection.send(
UserAgentRequest(
unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes),
),
);
final handshakeResponse = await connection.receive();
if (!handshakeResponse.hasUnsealStartResponse()) {
throw Exception(
'Expected unseal handshake response, got ${handshakeResponse.whichPayload()}',
);
}
final serverPublicKey = SimplePublicKey(
handshakeResponse.unsealStartResponse.serverPubkey,
type: KeyPairType.x25519,
);
final sharedSecret = await keyExchange.sharedSecretKey(
keyPair: clientKeyPair,
remotePublicKey: serverPublicKey,
);
final secretBox = await cipher.encrypt(
utf8.encode(password),
secretKey: sharedSecret,
nonce: cipher.newNonce(),
aad: utf8.encode(_vaultKeyAssociatedData),
);
return _EncryptedVaultKey(
nonce: secretBox.nonce,
ciphertext: [...secretBox.cipherText, ...secretBox.mac.bytes],
associatedData: utf8.encode(_vaultKeyAssociatedData),
);
}
class _EncryptedVaultKey {
const _EncryptedVaultKey({
required this.nonce,
required this.ciphertext,
required this.associatedData,
});
final List<int> nonce;
final List<int> ciphertext;
final List<int> associatedData;
}
Future<Connection> connectAndAuthorize(
StoredServerInfo serverInfo,
KeyHandle key, {
String? bootstrapToken,
}) async {
try {
final connection = await _connect(serverInfo);
talker.info(
'Connected to server at ${serverInfo.address}:${serverInfo.port}',
);
final pubkey = await key.getPublicKey();
final req = AuthChallengeRequest(
pubkey: pubkey,
bootstrapToken: bootstrapToken,
keyType: switch (key.alg) {
KeyAlgorithm.rsa => KeyType.KEY_TYPE_RSA,
KeyAlgorithm.ecdsa => KeyType.KEY_TYPE_ECDSA_SECP256K1,
KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519,
},
);
await connection.send(UserAgentRequest(authChallengeRequest: req));
talker.info(
"Sent auth challenge request with pubkey ${base64Encode(pubkey)}",
);
final response = await connection.receive();
talker.info('Received response from server, checking auth flow...');
if (response.hasAuthOk()) {
talker.info('Authentication successful, connection established');
return connection;
}
if (!response.hasAuthChallenge()) {
throw Exception(
'Expected AuthChallengeResponse, got ${response.whichPayload()}',
);
}
final challenge = formatChallenge(response.authChallenge, pubkey);
talker.info(
'Received auth challenge, signing with key ${base64Encode(pubkey)}',
);
final signature = await key.sign(challenge);
final solutionReq = AuthChallengeSolution(signature: signature);
await connection.send(UserAgentRequest(authChallengeSolution: solutionReq));
talker.info('Sent auth challenge solution, waiting for server response...');
final solutionResponse = await connection.receive();
if (!solutionResponse.hasAuthOk()) {
throw Exception(
'Expected AuthChallengeSolutionResponse, got ${solutionResponse.whichPayload()}',
);
}
talker.info('Authentication successful, connection established');
return connection;
} catch (e) {
throw Exception('Failed to connect to server: $e');
}
}
String _describeEvmError(EvmError error) {
return switch (error) {
EvmError.EVM_ERROR_VAULT_SEALED =>
'The vault is sealed. Unseal it before using EVM wallets.',
EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED =>
'The server failed to process the EVM request.',
_ => 'The server failed to process the EVM request.',
};
}

View File

@@ -0,0 +1,56 @@
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
Future<List<WalletEntry>> listEvmWallets(Connection connection) async {
await connection.send(UserAgentRequest(evmWalletList: Empty()));
final response = await connection.receive();
if (!response.hasEvmWalletList()) {
throw Exception(
'Expected EVM wallet list response, got ${response.whichPayload()}',
);
}
final result = response.evmWalletList;
switch (result.whichResult()) {
case WalletListResponse_Result.wallets:
return result.wallets.wallets.toList(growable: false);
case WalletListResponse_Result.error:
throw Exception(_describeEvmError(result.error));
case WalletListResponse_Result.notSet:
throw Exception('EVM wallet list response was empty.');
}
}
Future<void> createEvmWallet(Connection connection) async {
await connection.send(UserAgentRequest(evmWalletCreate: Empty()));
final response = await connection.receive();
if (!response.hasEvmWalletCreate()) {
throw Exception(
'Expected EVM wallet create response, got ${response.whichPayload()}',
);
}
final result = response.evmWalletCreate;
switch (result.whichResult()) {
case WalletCreateResponse_Result.wallet:
return;
case WalletCreateResponse_Result.error:
throw Exception(_describeEvmError(result.error));
case WalletCreateResponse_Result.notSet:
throw Exception('Wallet creation returned no result.');
}
}
String _describeEvmError(EvmError error) {
return switch (error) {
EvmError.EVM_ERROR_VAULT_SEALED =>
'The vault is sealed. Unseal it before using EVM wallets.',
EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED =>
'The server failed to process the EVM request.',
_ => 'The server failed to process the EVM request.',
};
}

View File

@@ -0,0 +1,122 @@
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:fixnum/fixnum.dart';
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
Future<List<GrantEntry>> listEvmGrants(
Connection connection, {
int? walletId,
}) async {
final request = EvmGrantListRequest();
if (walletId != null) {
request.walletId = walletId;
}
await connection.send(UserAgentRequest(evmGrantList: request));
final response = await connection.receive();
if (!response.hasEvmGrantList()) {
throw Exception(
'Expected EVM grant list response, got ${response.whichPayload()}',
);
}
final result = response.evmGrantList;
switch (result.whichResult()) {
case EvmGrantListResponse_Result.grants:
return result.grants.grants.toList(growable: false);
case EvmGrantListResponse_Result.error:
throw Exception(_describeGrantError(result.error));
case EvmGrantListResponse_Result.notSet:
throw Exception('EVM grant list response was empty.');
}
}
Future<int> createEvmGrant(
Connection connection, {
required int clientId,
required int walletId,
required Int64 chainId,
DateTime? validFrom,
DateTime? validUntil,
List<int>? maxGasFeePerGas,
List<int>? maxPriorityFeePerGas,
TransactionRateLimit? rateLimit,
required SpecificGrant specific,
}) async {
await connection.send(
UserAgentRequest(
evmGrantCreate: EvmGrantCreateRequest(
clientId: clientId,
shared: SharedSettings(
walletId: walletId,
chainId: chainId,
validFrom: validFrom == null ? null : _toTimestamp(validFrom),
validUntil: validUntil == null ? null : _toTimestamp(validUntil),
maxGasFeePerGas: maxGasFeePerGas,
maxPriorityFeePerGas: maxPriorityFeePerGas,
rateLimit: rateLimit,
),
specific: specific,
),
),
);
final response = await connection.receive();
if (!response.hasEvmGrantCreate()) {
throw Exception(
'Expected EVM grant create response, got ${response.whichPayload()}',
);
}
final result = response.evmGrantCreate;
switch (result.whichResult()) {
case EvmGrantCreateResponse_Result.grantId:
return result.grantId;
case EvmGrantCreateResponse_Result.error:
throw Exception(_describeGrantError(result.error));
case EvmGrantCreateResponse_Result.notSet:
throw Exception('Grant creation returned no result.');
}
}
Future<void> deleteEvmGrant(Connection connection, int grantId) async {
await connection.send(
UserAgentRequest(evmGrantDelete: EvmGrantDeleteRequest(grantId: grantId)),
);
final response = await connection.receive();
if (!response.hasEvmGrantDelete()) {
throw Exception(
'Expected EVM grant delete response, got ${response.whichPayload()}',
);
}
final result = response.evmGrantDelete;
switch (result.whichResult()) {
case EvmGrantDeleteResponse_Result.ok:
return;
case EvmGrantDeleteResponse_Result.error:
throw Exception(_describeGrantError(result.error));
case EvmGrantDeleteResponse_Result.notSet:
throw Exception('Grant revoke returned no result.');
}
}
Timestamp _toTimestamp(DateTime value) {
final utc = value.toUtc();
return Timestamp()
..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000)
..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000;
}
String _describeGrantError(EvmError error) {
return switch (error) {
EvmError.EVM_ERROR_VAULT_SEALED =>
'The vault is sealed. Unseal it before using EVM grants.',
EvmError.EVM_ERROR_INTERNAL || EvmError.EVM_ERROR_UNSPECIFIED =>
'The server failed to process the EVM grant request.',
_ => 'The server failed to process the EVM grant request.',
};
}

View File

@@ -37,6 +37,7 @@ class SecureServerInfoStorage implements ServerInfoStorage {
@override
Future<StoredServerInfo?> load() async {
return null;
final rawValue = await _storage.read(key: _storageKey);
if (rawValue == null) {
return null;

View File

@@ -0,0 +1,107 @@
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/proto/user_agent.pb.dart';
import 'package:cryptography/cryptography.dart';
const _vaultKeyAssociatedData = 'arbiter.vault.password';
Future<BootstrapResult> bootstrapVault(
Connection connection,
String password,
) async {
final encryptedKey = await _encryptVaultKeyMaterial(connection, password);
await connection.send(
UserAgentRequest(
bootstrapEncryptedKey: BootstrapEncryptedKey(
nonce: encryptedKey.nonce,
ciphertext: encryptedKey.ciphertext,
associatedData: encryptedKey.associatedData,
),
),
);
final response = await connection.receive();
if (!response.hasBootstrapResult()) {
throw Exception(
'Expected bootstrap result, got ${response.whichPayload()}',
);
}
return response.bootstrapResult;
}
Future<UnsealResult> unsealVault(Connection connection, String password) async {
final encryptedKey = await _encryptVaultKeyMaterial(connection, password);
await connection.send(
UserAgentRequest(
unsealEncryptedKey: UnsealEncryptedKey(
nonce: encryptedKey.nonce,
ciphertext: encryptedKey.ciphertext,
associatedData: encryptedKey.associatedData,
),
),
);
final response = await connection.receive();
if (!response.hasUnsealResult()) {
throw Exception('Expected unseal result, got ${response.whichPayload()}');
}
return response.unsealResult;
}
Future<_EncryptedVaultKey> _encryptVaultKeyMaterial(
Connection connection,
String password,
) async {
final keyExchange = X25519();
final cipher = Xchacha20.poly1305Aead();
final clientKeyPair = await keyExchange.newKeyPair();
final clientPublicKey = await clientKeyPair.extractPublicKey();
await connection.send(
UserAgentRequest(unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes)),
);
final handshakeResponse = await connection.receive();
if (!handshakeResponse.hasUnsealStartResponse()) {
throw Exception(
'Expected unseal handshake response, got ${handshakeResponse.whichPayload()}',
);
}
final serverPublicKey = SimplePublicKey(
handshakeResponse.unsealStartResponse.serverPubkey,
type: KeyPairType.x25519,
);
final sharedSecret = await keyExchange.sharedSecretKey(
keyPair: clientKeyPair,
remotePublicKey: serverPublicKey,
);
final secretBox = await cipher.encrypt(
password.codeUnits,
secretKey: sharedSecret,
nonce: cipher.newNonce(),
aad: _vaultKeyAssociatedData.codeUnits,
);
return _EncryptedVaultKey(
nonce: secretBox.nonce,
ciphertext: [...secretBox.cipherText, ...secretBox.mac.bytes],
associatedData: _vaultKeyAssociatedData.codeUnits,
);
}
class _EncryptedVaultKey {
const _EncryptedVaultKey({
required this.nonce,
required this.ciphertext,
required this.associatedData,
});
final List<int> nonce;
final List<int> ciphertext;
final List<int> associatedData;
}

View File

@@ -1,3 +1,4 @@
import 'package:arbiter/features/connection/auth.dart';
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/providers/connection/bootstrap_token.dart';
import 'package:arbiter/providers/key.dart';

View File

@@ -1,4 +1,4 @@
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/features/connection/evm.dart';
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

View File

@@ -0,0 +1,120 @@
import 'package:arbiter/features/connection/evm/grants.dart';
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:fixnum/fixnum.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:mtcore/markettakers.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'evm_grants.freezed.dart';
part 'evm_grants.g.dart';
final createEvmGrantMutation = Mutation<int>();
final revokeEvmGrantMutation = Mutation<void>();
@freezed
abstract class EvmGrantsState with _$EvmGrantsState {
const EvmGrantsState._();
const factory EvmGrantsState({
required List<GrantEntry> grants,
@Default(false) bool showRevoked,
}) = _EvmGrantsState;
bool get revokedFilterBackedByServer => false;
}
@riverpod
class EvmGrants extends _$EvmGrants {
@override
Future<EvmGrantsState?> build() async {
final connection = await ref.watch(connectionManagerProvider.future);
if (connection == null) {
return null;
}
try {
final grants = await listEvmGrants(connection);
return EvmGrantsState(grants: grants);
} catch (e, st) {
talker.handle(e, st);
rethrow;
}
}
void toggleShowRevoked(bool value) {
final current = state.asData?.value;
if (current == null) {
return;
}
state = AsyncData(current.copyWith(showRevoked: value));
}
Future<void> refresh() async {
final connection = await ref.read(connectionManagerProvider.future);
if (connection == null) {
state = const AsyncData(null);
return;
}
final previous = state.asData?.value;
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final grants = await listEvmGrants(connection);
return EvmGrantsState(
grants: grants,
showRevoked: previous?.showRevoked ?? false,
);
});
}
}
Future<int> executeCreateEvmGrant(
MutationTarget ref, {
required int clientId,
required int walletId,
required Int64 chainId,
DateTime? validFrom,
DateTime? validUntil,
List<int>? maxGasFeePerGas,
List<int>? maxPriorityFeePerGas,
TransactionRateLimit? rateLimit,
required SpecificGrant specific,
}) {
return createEvmGrantMutation.run(ref, (tsx) async {
final connection = await tsx.get(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
final grantId = await createEvmGrant(
connection,
clientId: clientId,
walletId: walletId,
chainId: chainId,
validFrom: validFrom,
validUntil: validUntil,
maxGasFeePerGas: maxGasFeePerGas,
maxPriorityFeePerGas: maxPriorityFeePerGas,
rateLimit: rateLimit,
specific: specific,
);
await tsx.get(evmGrantsProvider.notifier).refresh();
return grantId;
});
}
Future<void> executeRevokeEvmGrant(MutationTarget ref, {required int grantId}) {
return revokeEvmGrantMutation.run(ref, (tsx) async {
final connection = await tsx.get(connectionManagerProvider.future);
if (connection == null) {
throw Exception('Not connected to the server.');
}
await deleteEvmGrant(connection, grantId);
await tsx.get(evmGrantsProvider.notifier).refresh();
});
}

View File

@@ -0,0 +1,280 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'evm_grants.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$EvmGrantsState {
List<GrantEntry> get grants; bool get showRevoked;
/// Create a copy of EvmGrantsState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$EvmGrantsStateCopyWith<EvmGrantsState> get copyWith => _$EvmGrantsStateCopyWithImpl<EvmGrantsState>(this as EvmGrantsState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is EvmGrantsState&&const DeepCollectionEquality().equals(other.grants, grants)&&(identical(other.showRevoked, showRevoked) || other.showRevoked == showRevoked));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(grants),showRevoked);
@override
String toString() {
return 'EvmGrantsState(grants: $grants, showRevoked: $showRevoked)';
}
}
/// @nodoc
abstract mixin class $EvmGrantsStateCopyWith<$Res> {
factory $EvmGrantsStateCopyWith(EvmGrantsState value, $Res Function(EvmGrantsState) _then) = _$EvmGrantsStateCopyWithImpl;
@useResult
$Res call({
List<GrantEntry> grants, bool showRevoked
});
}
/// @nodoc
class _$EvmGrantsStateCopyWithImpl<$Res>
implements $EvmGrantsStateCopyWith<$Res> {
_$EvmGrantsStateCopyWithImpl(this._self, this._then);
final EvmGrantsState _self;
final $Res Function(EvmGrantsState) _then;
/// Create a copy of EvmGrantsState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? grants = null,Object? showRevoked = null,}) {
return _then(_self.copyWith(
grants: null == grants ? _self.grants : grants // ignore: cast_nullable_to_non_nullable
as List<GrantEntry>,showRevoked: null == showRevoked ? _self.showRevoked : showRevoked // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [EvmGrantsState].
extension EvmGrantsStatePatterns on EvmGrantsState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _EvmGrantsState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _EvmGrantsState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _EvmGrantsState value) $default,){
final _that = this;
switch (_that) {
case _EvmGrantsState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _EvmGrantsState value)? $default,){
final _that = this;
switch (_that) {
case _EvmGrantsState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<GrantEntry> grants, bool showRevoked)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _EvmGrantsState() when $default != null:
return $default(_that.grants,_that.showRevoked);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<GrantEntry> grants, bool showRevoked) $default,) {final _that = this;
switch (_that) {
case _EvmGrantsState():
return $default(_that.grants,_that.showRevoked);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<GrantEntry> grants, bool showRevoked)? $default,) {final _that = this;
switch (_that) {
case _EvmGrantsState() when $default != null:
return $default(_that.grants,_that.showRevoked);case _:
return null;
}
}
}
/// @nodoc
class _EvmGrantsState extends EvmGrantsState {
const _EvmGrantsState({required final List<GrantEntry> grants, this.showRevoked = false}): _grants = grants,super._();
final List<GrantEntry> _grants;
@override List<GrantEntry> get grants {
if (_grants is EqualUnmodifiableListView) return _grants;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_grants);
}
@override@JsonKey() final bool showRevoked;
/// Create a copy of EvmGrantsState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$EvmGrantsStateCopyWith<_EvmGrantsState> get copyWith => __$EvmGrantsStateCopyWithImpl<_EvmGrantsState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _EvmGrantsState&&const DeepCollectionEquality().equals(other._grants, _grants)&&(identical(other.showRevoked, showRevoked) || other.showRevoked == showRevoked));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_grants),showRevoked);
@override
String toString() {
return 'EvmGrantsState(grants: $grants, showRevoked: $showRevoked)';
}
}
/// @nodoc
abstract mixin class _$EvmGrantsStateCopyWith<$Res> implements $EvmGrantsStateCopyWith<$Res> {
factory _$EvmGrantsStateCopyWith(_EvmGrantsState value, $Res Function(_EvmGrantsState) _then) = __$EvmGrantsStateCopyWithImpl;
@override @useResult
$Res call({
List<GrantEntry> grants, bool showRevoked
});
}
/// @nodoc
class __$EvmGrantsStateCopyWithImpl<$Res>
implements _$EvmGrantsStateCopyWith<$Res> {
__$EvmGrantsStateCopyWithImpl(this._self, this._then);
final _EvmGrantsState _self;
final $Res Function(_EvmGrantsState) _then;
/// Create a copy of EvmGrantsState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? grants = null,Object? showRevoked = null,}) {
return _then(_EvmGrantsState(
grants: null == grants ? _self._grants : grants // ignore: cast_nullable_to_non_nullable
as List<GrantEntry>,showRevoked: null == showRevoked ? _self.showRevoked : showRevoked // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@@ -0,0 +1,54 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'evm_grants.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(EvmGrants)
final evmGrantsProvider = EvmGrantsProvider._();
final class EvmGrantsProvider
extends $AsyncNotifierProvider<EvmGrants, EvmGrantsState?> {
EvmGrantsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'evmGrantsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$evmGrantsHash();
@$internal
@override
EvmGrants create() => EvmGrants();
}
String _$evmGrantsHash() => r'd71ec12bbc1b412f11fdbaae27382b289f8a3538';
abstract class _$EvmGrants extends $AsyncNotifier<EvmGrantsState?> {
FutureOr<EvmGrantsState?> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<EvmGrantsState?>, EvmGrantsState?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<EvmGrantsState?>, EvmGrantsState?>,
AsyncValue<EvmGrantsState?>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -10,12 +10,14 @@ class Router extends RootStackRouter {
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
AutoRoute(
page: DashboardRouter.page,
path: '/dashboard',
children: [
AutoRoute(page: EvmRoute.page, path: 'evm'),
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
AutoRoute(page: AboutRoute.page, path: 'about'),
],
),

View File

@@ -10,24 +10,26 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:arbiter/screens/bootstrap.dart' as _i2;
import 'package:arbiter/screens/dashboard.dart' as _i3;
import 'package:arbiter/screens/dashboard.dart' as _i4;
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
import 'package:arbiter/screens/dashboard/evm.dart' as _i4;
import 'package:arbiter/screens/server_connection.dart' as _i5;
import 'package:arbiter/screens/server_info_setup.dart' as _i6;
import 'package:arbiter/screens/vault_setup.dart' as _i7;
import 'package:auto_route/auto_route.dart' as _i8;
import 'package:flutter/material.dart' as _i9;
import 'package:arbiter/screens/dashboard/evm.dart' as _i6;
import 'package:arbiter/screens/dashboard/evm_grant_create.dart' as _i3;
import 'package:arbiter/screens/dashboard/evm_grants.dart' as _i5;
import 'package:arbiter/screens/server_connection.dart' as _i7;
import 'package:arbiter/screens/server_info_setup.dart' as _i8;
import 'package:arbiter/screens/vault_setup.dart' as _i9;
import 'package:auto_route/auto_route.dart' as _i10;
import 'package:flutter/material.dart' as _i11;
/// generated route for
/// [_i1.AboutScreen]
class AboutRoute extends _i8.PageRouteInfo<void> {
const AboutRoute({List<_i8.PageRouteInfo>? children})
class AboutRoute extends _i10.PageRouteInfo<void> {
const AboutRoute({List<_i10.PageRouteInfo>? children})
: super(AboutRoute.name, initialChildren: children);
static const String name = 'AboutRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i1.AboutScreen();
@@ -37,13 +39,13 @@ class AboutRoute extends _i8.PageRouteInfo<void> {
/// generated route for
/// [_i2.Bootstrap]
class Bootstrap extends _i8.PageRouteInfo<void> {
const Bootstrap({List<_i8.PageRouteInfo>? children})
class Bootstrap extends _i10.PageRouteInfo<void> {
const Bootstrap({List<_i10.PageRouteInfo>? children})
: super(Bootstrap.name, initialChildren: children);
static const String name = 'Bootstrap';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i2.Bootstrap();
@@ -52,45 +54,77 @@ class Bootstrap extends _i8.PageRouteInfo<void> {
}
/// generated route for
/// [_i3.DashboardRouter]
class DashboardRouter extends _i8.PageRouteInfo<void> {
const DashboardRouter({List<_i8.PageRouteInfo>? children})
/// [_i3.CreateEvmGrantScreen]
class CreateEvmGrantRoute extends _i10.PageRouteInfo<void> {
const CreateEvmGrantRoute({List<_i10.PageRouteInfo>? children})
: super(CreateEvmGrantRoute.name, initialChildren: children);
static const String name = 'CreateEvmGrantRoute';
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i3.CreateEvmGrantScreen();
},
);
}
/// generated route for
/// [_i4.DashboardRouter]
class DashboardRouter extends _i10.PageRouteInfo<void> {
const DashboardRouter({List<_i10.PageRouteInfo>? children})
: super(DashboardRouter.name, initialChildren: children);
static const String name = 'DashboardRouter';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i3.DashboardRouter();
return const _i4.DashboardRouter();
},
);
}
/// generated route for
/// [_i4.EvmScreen]
class EvmRoute extends _i8.PageRouteInfo<void> {
const EvmRoute({List<_i8.PageRouteInfo>? children})
/// [_i5.EvmGrantsScreen]
class EvmGrantsRoute extends _i10.PageRouteInfo<void> {
const EvmGrantsRoute({List<_i10.PageRouteInfo>? children})
: super(EvmGrantsRoute.name, initialChildren: children);
static const String name = 'EvmGrantsRoute';
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i5.EvmGrantsScreen();
},
);
}
/// generated route for
/// [_i6.EvmScreen]
class EvmRoute extends _i10.PageRouteInfo<void> {
const EvmRoute({List<_i10.PageRouteInfo>? children})
: super(EvmRoute.name, initialChildren: children);
static const String name = 'EvmRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i4.EvmScreen();
return const _i6.EvmScreen();
},
);
}
/// generated route for
/// [_i5.ServerConnectionScreen]
/// [_i7.ServerConnectionScreen]
class ServerConnectionRoute
extends _i8.PageRouteInfo<ServerConnectionRouteArgs> {
extends _i10.PageRouteInfo<ServerConnectionRouteArgs> {
ServerConnectionRoute({
_i9.Key? key,
_i11.Key? key,
String? arbiterUrl,
List<_i8.PageRouteInfo>? children,
List<_i10.PageRouteInfo>? children,
}) : super(
ServerConnectionRoute.name,
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
@@ -99,13 +133,13 @@ class ServerConnectionRoute
static const String name = 'ServerConnectionRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ServerConnectionRouteArgs>(
orElse: () => const ServerConnectionRouteArgs(),
);
return _i5.ServerConnectionScreen(
return _i7.ServerConnectionScreen(
key: args.key,
arbiterUrl: args.arbiterUrl,
);
@@ -116,7 +150,7 @@ class ServerConnectionRoute
class ServerConnectionRouteArgs {
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
final _i9.Key? key;
final _i11.Key? key;
final String? arbiterUrl;
@@ -137,33 +171,33 @@ class ServerConnectionRouteArgs {
}
/// generated route for
/// [_i6.ServerInfoSetupScreen]
class ServerInfoSetupRoute extends _i8.PageRouteInfo<void> {
const ServerInfoSetupRoute({List<_i8.PageRouteInfo>? children})
/// [_i8.ServerInfoSetupScreen]
class ServerInfoSetupRoute extends _i10.PageRouteInfo<void> {
const ServerInfoSetupRoute({List<_i10.PageRouteInfo>? children})
: super(ServerInfoSetupRoute.name, initialChildren: children);
static const String name = 'ServerInfoSetupRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i6.ServerInfoSetupScreen();
return const _i8.ServerInfoSetupScreen();
},
);
}
/// generated route for
/// [_i7.VaultSetupScreen]
class VaultSetupRoute extends _i8.PageRouteInfo<void> {
const VaultSetupRoute({List<_i8.PageRouteInfo>? children})
/// [_i9.VaultSetupScreen]
class VaultSetupRoute extends _i10.PageRouteInfo<void> {
const VaultSetupRoute({List<_i10.PageRouteInfo>? children})
: super(VaultSetupRoute.name, initialChildren: children);
static const String name = 'VaultSetupRoute';
static _i8.PageInfo page = _i8.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i7.VaultSetupScreen();
return const _i9.VaultSetupScreen();
},
);
}

View File

@@ -34,6 +34,6 @@ class Bootstrap extends HookConsumerWidget {
[stages],
);
return bootstrapper;
return Scaffold(body: bootstrapper);
}
}

View File

@@ -5,7 +5,7 @@ import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
const breakpoints = MaterialAdaptiveBreakpoints();
final routes = [EvmRoute(), AboutRoute()];
final routes = [const EvmRoute(), const EvmGrantsRoute(), const AboutRoute()];
@RoutePage()
class DashboardRouter extends StatelessWidget {
@@ -30,6 +30,11 @@ class DashboardRouter extends StatelessWidget {
selectedIcon: Icon(Icons.account_balance_wallet),
label: "Wallets",
),
NavigationDestination(
icon: Icon(Icons.rule_folder_outlined),
selectedIcon: Icon(Icons.rule_folder),
label: "Grants",
),
NavigationDestination(
icon: Icon(Icons.info_outline),
selectedIcon: Icon(Icons.info),

View File

@@ -0,0 +1,824 @@
import 'package:arbiter/proto/evm.pb.dart';
import 'package:arbiter/providers/evm.dart';
import 'package:arbiter/providers/evm_grants.dart';
import 'package:auto_route/auto_route.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/experimental/mutation.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class CreateEvmGrantScreen extends HookConsumerWidget {
const CreateEvmGrantScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final wallets = ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
final createMutation = ref.watch(createEvmGrantMutation);
final selectedWalletIndex = useState<int?>(wallets.isEmpty ? null : 0);
final clientIdController = useTextEditingController();
final chainIdController = useTextEditingController(text: '1');
final gasFeeController = useTextEditingController();
final priorityFeeController = useTextEditingController();
final txCountController = useTextEditingController();
final txWindowController = useTextEditingController();
final recipientsController = useTextEditingController();
final etherVolumeController = useTextEditingController();
final etherVolumeWindowController = useTextEditingController();
final tokenContractController = useTextEditingController();
final tokenTargetController = useTextEditingController();
final validFrom = useState<DateTime?>(null);
final validUntil = useState<DateTime?>(null);
final grantType = useState<SpecificGrant_Grant>(
SpecificGrant_Grant.etherTransfer,
);
final tokenVolumeLimits = useState<List<_VolumeLimitValue>>([
const _VolumeLimitValue(),
]);
Future<void> submit() async {
final selectedWallet = selectedWalletIndex.value;
if (selectedWallet == null) {
_showCreateMessage(context, 'At least one wallet is required.');
return;
}
try {
final clientId = int.parse(clientIdController.text.trim());
final chainId = Int64.parseInt(chainIdController.text.trim());
final rateLimit = _buildRateLimit(
txCountController.text,
txWindowController.text,
);
final specific = switch (grantType.value) {
SpecificGrant_Grant.etherTransfer => SpecificGrant(
etherTransfer: EtherTransferSettings(
targets: _parseAddresses(recipientsController.text),
limit: _buildVolumeLimit(
etherVolumeController.text,
etherVolumeWindowController.text,
),
),
),
SpecificGrant_Grant.tokenTransfer => SpecificGrant(
tokenTransfer: TokenTransferSettings(
tokenContract: _parseHexAddress(tokenContractController.text),
target: tokenTargetController.text.trim().isEmpty
? null
: _parseHexAddress(tokenTargetController.text),
volumeLimits: tokenVolumeLimits.value
.where((item) => item.amount.trim().isNotEmpty)
.map(
(item) => VolumeRateLimit(
maxVolume: _parseBigIntBytes(item.amount),
windowSecs: Int64.parseInt(item.windowSeconds),
),
)
.toList(),
),
),
_ => throw Exception('Unsupported grant type.'),
};
await executeCreateEvmGrant(
ref,
clientId: clientId,
walletId: selectedWallet + 1,
chainId: chainId,
validFrom: validFrom.value,
validUntil: validUntil.value,
maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text),
maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text),
rateLimit: rateLimit,
specific: specific,
);
if (!context.mounted) {
return;
}
context.router.pop();
} catch (error) {
if (!context.mounted) {
return;
}
_showCreateMessage(context, _formatCreateError(error));
}
}
return Scaffold(
appBar: AppBar(title: const Text('Create EVM Grant')),
body: SafeArea(
child: ListView(
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
children: [
_CreateIntroCard(walletCount: wallets.length),
SizedBox(height: 1.8.h),
_CreateSection(
title: 'Shared grant options',
children: [
_WalletPickerField(
wallets: wallets,
selectedIndex: selectedWalletIndex.value,
onChanged: (value) => selectedWalletIndex.value = value,
),
_NumberInputField(
controller: clientIdController,
label: 'Client ID',
hint: '42',
helper:
'Manual for now. The app does not yet expose a client picker.',
),
_NumberInputField(
controller: chainIdController,
label: 'Chain ID',
hint: '1',
),
_ValidityWindowField(
validFrom: validFrom.value,
validUntil: validUntil.value,
onValidFromChanged: (value) => validFrom.value = value,
onValidUntilChanged: (value) => validUntil.value = value,
),
_GasFeeOptionsField(
gasFeeController: gasFeeController,
priorityFeeController: priorityFeeController,
),
_TransactionRateLimitField(
txCountController: txCountController,
txWindowController: txWindowController,
),
],
),
SizedBox(height: 1.8.h),
_GrantTypeSelector(
value: grantType.value,
onChanged: (value) => grantType.value = value,
),
SizedBox(height: 1.8.h),
_CreateSection(
title: 'Grant-specific options',
children: [
if (grantType.value == SpecificGrant_Grant.etherTransfer) ...[
_EtherTargetsField(controller: recipientsController),
_VolumeLimitField(
amountController: etherVolumeController,
windowController: etherVolumeWindowController,
title: 'Ether volume limit',
),
] else ...[
_TokenContractField(controller: tokenContractController),
_TokenRecipientField(controller: tokenTargetController),
_TokenVolumeLimitsField(
values: tokenVolumeLimits.value,
onChanged: (values) => tokenVolumeLimits.value = values,
),
],
],
),
SizedBox(height: 2.2.h),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: createMutation is MutationPending ? null : submit,
icon: createMutation is MutationPending
? SizedBox(
width: 1.8.h,
height: 1.8.h,
child: const CircularProgressIndicator(strokeWidth: 2.2),
)
: const Icon(Icons.check_rounded),
label: Text(
createMutation is MutationPending
? 'Creating...'
: 'Create grant',
),
),
),
],
),
),
);
}
}
class _CreateIntroCard extends StatelessWidget {
const _CreateIntroCard({required this.walletCount});
final int walletCount;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: const LinearGradient(
colors: [Color(0xFFF7F9FC), Color(0xFFFDF5EA)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(color: const Color(0x1A17324A)),
),
child: Text(
'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
),
);
}
}
class _CreateSection extends StatelessWidget {
const _CreateSection({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
color: Colors.white,
border: Border.all(color: const Color(0x1A17324A)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 1.4.h),
...children.map(
(child) => Padding(
padding: EdgeInsets.only(bottom: 1.6.h),
child: child,
),
),
],
),
);
}
}
class _WalletPickerField extends StatelessWidget {
const _WalletPickerField({
required this.wallets,
required this.selectedIndex,
required this.onChanged,
});
final List<WalletEntry> wallets;
final int? selectedIndex;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: const InputDecoration(
labelText: 'Wallet',
helperText:
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
border: OutlineInputBorder(),
),
items: [
for (var i = 0; i < wallets.length; i++)
DropdownMenuItem(
value: i,
child: Text(
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
),
),
],
onChanged: wallets.isEmpty ? null : onChanged,
);
}
}
class _NumberInputField extends StatelessWidget {
const _NumberInputField({
required this.controller,
required this.label,
required this.hint,
this.helper,
});
final TextEditingController controller;
final String label;
final String hint;
final String? helper;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
hintText: hint,
helperText: helper,
border: const OutlineInputBorder(),
),
);
}
}
class _ValidityWindowField extends StatelessWidget {
const _ValidityWindowField({
required this.validFrom,
required this.validUntil,
required this.onValidFromChanged,
required this.onValidUntilChanged,
});
final DateTime? validFrom;
final DateTime? validUntil;
final ValueChanged<DateTime?> onValidFromChanged;
final ValueChanged<DateTime?> onValidUntilChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _DateButtonField(
label: 'Valid from',
value: validFrom,
onChanged: onValidFromChanged,
),
),
SizedBox(width: 1.w),
Expanded(
child: _DateButtonField(
label: 'Valid until',
value: validUntil,
onChanged: onValidUntilChanged,
),
),
],
);
}
}
class _DateButtonField extends StatelessWidget {
const _DateButtonField({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final DateTime? value;
final ValueChanged<DateTime?> onChanged;
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: () async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 10),
initialDate: value ?? now,
);
if (date == null || !context.mounted) {
return;
}
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(value ?? now),
);
if (time == null) {
return;
}
onChanged(
DateTime(date.year, date.month, date.day, time.hour, time.minute),
);
},
onLongPress: value == null ? null : () => onChanged(null),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 1.8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
SizedBox(height: 0.6.h),
Text(value?.toLocal().toString() ?? 'Not set'),
],
),
),
);
}
}
class _GasFeeOptionsField extends StatelessWidget {
const _GasFeeOptionsField({
required this.gasFeeController,
required this.priorityFeeController,
});
final TextEditingController gasFeeController;
final TextEditingController priorityFeeController;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _NumberInputField(
controller: gasFeeController,
label: 'Max gas fee / gas',
hint: '1000000000',
),
),
SizedBox(width: 1.w),
Expanded(
child: _NumberInputField(
controller: priorityFeeController,
label: 'Max priority fee / gas',
hint: '100000000',
),
),
],
);
}
}
class _TransactionRateLimitField extends StatelessWidget {
const _TransactionRateLimitField({
required this.txCountController,
required this.txWindowController,
});
final TextEditingController txCountController;
final TextEditingController txWindowController;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _NumberInputField(
controller: txCountController,
label: 'Tx count limit',
hint: '10',
),
),
SizedBox(width: 1.w),
Expanded(
child: _NumberInputField(
controller: txWindowController,
label: 'Window (seconds)',
hint: '3600',
),
),
],
);
}
}
class _GrantTypeSelector extends StatelessWidget {
const _GrantTypeSelector({required this.value, required this.onChanged});
final SpecificGrant_Grant value;
final ValueChanged<SpecificGrant_Grant> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<SpecificGrant_Grant>(
segments: const [
ButtonSegment(
value: SpecificGrant_Grant.etherTransfer,
label: Text('Ether'),
icon: Icon(Icons.bolt_rounded),
),
ButtonSegment(
value: SpecificGrant_Grant.tokenTransfer,
label: Text('Token'),
icon: Icon(Icons.token_rounded),
),
],
selected: {value},
onSelectionChanged: (selection) => onChanged(selection.first),
);
}
}
class _EtherTargetsField extends StatelessWidget {
const _EtherTargetsField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Ether recipients',
hintText: 'One 0x address per line. Leave empty for unrestricted targets.',
border: OutlineInputBorder(),
),
);
}
}
class _VolumeLimitField extends StatelessWidget {
const _VolumeLimitField({
required this.amountController,
required this.windowController,
required this.title,
});
final TextEditingController amountController;
final TextEditingController windowController;
final String title;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
SizedBox(height: 0.8.h),
Row(
children: [
Expanded(
child: _NumberInputField(
controller: amountController,
label: 'Max volume',
hint: '1000000000000000000',
),
),
SizedBox(width: 1.w),
Expanded(
child: _NumberInputField(
controller: windowController,
label: 'Window (seconds)',
hint: '86400',
),
),
],
),
],
);
}
}
class _TokenContractField extends StatelessWidget {
const _TokenContractField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Token contract',
hintText: '0x...',
border: OutlineInputBorder(),
),
);
}
}
class _TokenRecipientField extends StatelessWidget {
const _TokenRecipientField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Token recipient',
hintText: '0x... or leave empty for any recipient',
border: OutlineInputBorder(),
),
);
}
}
class _TokenVolumeLimitsField extends StatelessWidget {
const _TokenVolumeLimitsField({
required this.values,
required this.onChanged,
});
final List<_VolumeLimitValue> values;
final ValueChanged<List<_VolumeLimitValue>> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Token volume limits',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
TextButton.icon(
onPressed: () =>
onChanged([...values, const _VolumeLimitValue()]),
icon: const Icon(Icons.add_rounded),
label: const Text('Add'),
),
],
),
SizedBox(height: 0.8.h),
for (var i = 0; i < values.length; i++)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: _TokenVolumeLimitRow(
value: values[i],
onChanged: (next) {
final updated = [...values];
updated[i] = next;
onChanged(updated);
},
onRemove: values.length == 1
? null
: () {
final updated = [...values]..removeAt(i);
onChanged(updated);
},
),
),
],
);
}
}
class _TokenVolumeLimitRow extends StatelessWidget {
const _TokenVolumeLimitRow({
required this.value,
required this.onChanged,
required this.onRemove,
});
final _VolumeLimitValue value;
final ValueChanged<_VolumeLimitValue> onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
final amountController = TextEditingController(text: value.amount);
final windowController = TextEditingController(text: value.windowSeconds);
return Row(
children: [
Expanded(
child: TextField(
controller: amountController,
onChanged: (next) =>
onChanged(value.copyWith(amount: next)),
decoration: const InputDecoration(
labelText: 'Max volume',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 1.w),
Expanded(
child: TextField(
controller: windowController,
onChanged: (next) =>
onChanged(value.copyWith(windowSeconds: next)),
decoration: const InputDecoration(
labelText: 'Window (seconds)',
border: OutlineInputBorder(),
),
),
),
SizedBox(width: 0.4.w),
IconButton(
onPressed: onRemove,
icon: const Icon(Icons.remove_circle_outline_rounded),
),
],
);
}
}
class _VolumeLimitValue {
const _VolumeLimitValue({this.amount = '', this.windowSeconds = ''});
final String amount;
final String windowSeconds;
_VolumeLimitValue copyWith({String? amount, String? windowSeconds}) {
return _VolumeLimitValue(
amount: amount ?? this.amount,
windowSeconds: windowSeconds ?? this.windowSeconds,
);
}
}
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return TransactionRateLimit(
count: int.parse(countText.trim()),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
VolumeRateLimit? _buildVolumeLimit(String amountText, String windowText) {
if (amountText.trim().isEmpty || windowText.trim().isEmpty) {
return null;
}
return VolumeRateLimit(
maxVolume: _parseBigIntBytes(amountText),
windowSecs: Int64.parseInt(windowText.trim()),
);
}
List<int>? _optionalBigIntBytes(String value) {
if (value.trim().isEmpty) {
return null;
}
return _parseBigIntBytes(value);
}
List<int> _parseBigIntBytes(String value) {
final number = BigInt.parse(value.trim());
if (number < BigInt.zero) {
throw Exception('Numeric values must be positive.');
}
if (number == BigInt.zero) {
return [0];
}
var remaining = number;
final bytes = <int>[];
while (remaining > BigInt.zero) {
bytes.insert(0, (remaining & BigInt.from(0xff)).toInt());
remaining >>= 8;
}
return bytes;
}
List<List<int>> _parseAddresses(String input) {
final parts = input
.split(RegExp(r'[\n,]'))
.map((part) => part.trim())
.where((part) => part.isNotEmpty);
return parts.map(_parseHexAddress).toList();
}
List<int> _parseHexAddress(String value) {
final normalized = value.trim().replaceFirst(RegExp(r'^0x'), '');
if (normalized.length != 40) {
throw Exception('Expected a 20-byte hex address.');
}
return [
for (var i = 0; i < normalized.length; i += 2)
int.parse(normalized.substring(i, i + 2), radix: 16),
];
}
String _shortAddress(List<int> bytes) {
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
}
void _showCreateMessage(BuildContext context, String message) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
String _formatCreateError(Object error) {
final text = error.toString();
if (text.startsWith('Exception: ')) {
return text.substring('Exception: '.length);
}
return text;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import 'package:arbiter/features/connection/connection.dart';
import 'package:arbiter/features/connection/vault.dart';
import 'package:arbiter/proto/user_agent.pbenum.dart';
import 'package:arbiter/providers/connection/connection_manager.dart';
import 'package:arbiter/providers/vault_state.dart';