Skip to content

Commit b46e08e

Browse files
committed
Test Open CryptoPay settlement decisions
1 parent 0cc1db3 commit b46e08e

2 files changed

Lines changed: 282 additions & 25 deletions

File tree

lib/services/open_crypto_pay/settlement.dart

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,28 @@ class OpenCryptoPaySettlement {
3434
final bool isTokenTx;
3535
final EthTokenWallet? tokenWallet;
3636

37-
bool get shouldCommitTxId {
38-
if (commit.submissionFlow ==
37+
bool get shouldCommitTxId => shouldCommitTxIdFor(
38+
method: commit.method,
39+
submissionFlow: commit.submissionFlow,
40+
cryptoCurrency: wallet.cryptoCurrency,
41+
hasSparkInputs: txData.usedSparkCoins?.isNotEmpty == true,
42+
rawHexLength: txData.raw?.length ?? 0,
43+
);
44+
45+
static bool shouldCommitTxIdFor({
46+
required String method,
47+
required OpenCryptoPaySubmissionFlow submissionFlow,
48+
required CryptoCurrency cryptoCurrency,
49+
required bool hasSparkInputs,
50+
required int rawHexLength,
51+
}) {
52+
if (submissionFlow ==
3953
OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast) {
4054
return true;
4155
}
42-
return commit.method == 'Firo' &&
43-
wallet.cryptoCurrency is Firo &&
44-
(txData.usedSparkCoins?.isNotEmpty == true ||
45-
(txData.raw?.length ?? 0) > maxRawHexQueryLength);
56+
return method == 'Firo' &&
57+
cryptoCurrency is Firo &&
58+
(hasSparkInputs || rawHexLength > maxRawHexQueryLength);
4659
}
4760

4861
bool get shouldSubmitRawHex => !shouldCommitTxId && commit.canCommitRawHex;
@@ -149,18 +162,31 @@ class OpenCryptoPaySettlement {
149162
}
150163

151164
String? _validateTransaction() {
152-
final recipients = _recipients(txData);
165+
return validateTransaction(
166+
cryptoCurrency: wallet.cryptoCurrency,
167+
recipients: _recipients(txData),
168+
recipientAddress: commit.recipientAddress,
169+
amount: commit.amount,
170+
);
171+
}
172+
173+
static String? validateTransaction({
174+
required CryptoCurrency cryptoCurrency,
175+
required List<({String address, Amount amount})> recipients,
176+
required String recipientAddress,
177+
required Decimal amount,
178+
}) {
153179
if (recipients.length != 1) {
154180
return "Open CryptoPay requires exactly one recipient";
155181
}
156182

157183
final actual = recipients.single;
158-
if (_normalizeAddress(actual.address) !=
159-
_normalizeAddress(commit.recipientAddress)) {
184+
if (_normalizeAddress(cryptoCurrency, actual.address) !=
185+
_normalizeAddress(cryptoCurrency, recipientAddress)) {
160186
return "Open CryptoPay recipient changed. Please scan again.";
161187
}
162188

163-
if (actual.amount.decimal != commit.amount) {
189+
if (actual.amount.decimal != amount) {
164190
return "Open CryptoPay amount changed. Please scan again.";
165191
}
166192

@@ -193,40 +219,51 @@ class OpenCryptoPaySettlement {
193219
return null;
194220
}
195221

196-
String? _validateMinFee() {
197-
if (commit.minFee <= Decimal.zero) return null;
198-
199-
if (wallet.cryptoCurrency is Ethereum) {
200-
final gasPrice = txData.web3dartTransaction?.maxFeePerGas?.getInWei;
222+
String? _validateMinFee() => validateMinFee(
223+
cryptoCurrency: wallet.cryptoCurrency,
224+
minFee: commit.minFee,
225+
gasPrice: txData.web3dartTransaction?.maxFeePerGas?.getInWei,
226+
fee: txData.fee,
227+
vSize: txData.vSize,
228+
);
229+
230+
static String? validateMinFee({
231+
required CryptoCurrency cryptoCurrency,
232+
required Decimal minFee,
233+
BigInt? gasPrice,
234+
Amount? fee,
235+
int? vSize,
236+
}) {
237+
if (minFee <= Decimal.zero) return null;
238+
239+
if (cryptoCurrency is Ethereum) {
201240
if (gasPrice == null) {
202241
return "Could not verify Open CryptoPay minimum gas price";
203242
}
204-
if (gasPrice < _ceilDecimalToBigInt(commit.minFee)) {
243+
if (gasPrice < _ceilDecimalToBigInt(minFee)) {
205244
return "Open CryptoPay requires at least "
206-
"${commit.minFee} wei gas price";
245+
"$minFee wei gas price";
207246
}
208247
return null;
209248
}
210249

211-
if (wallet.cryptoCurrency is Bitcoin || wallet.cryptoCurrency is Firo) {
212-
final fee = txData.fee;
213-
final vSize = txData.vSize;
250+
if (cryptoCurrency is Bitcoin || cryptoCurrency is Firo) {
214251
if (fee == null || vSize == null || vSize <= 0) {
215252
return "Could not verify Open CryptoPay minimum fee";
216253
}
217254
final minTotalFee = _ceilDecimalToBigInt(
218-
commit.minFee * Decimal.fromInt(vSize),
255+
minFee * Decimal.fromInt(vSize),
219256
);
220257
if (fee.raw < minTotalFee) {
221258
return "Open CryptoPay requires at least "
222-
"${commit.minFee} sat/vB fee";
259+
"$minFee sat/vB fee";
223260
}
224261
}
225262

226263
return null;
227264
}
228265

229-
BigInt _ceilDecimalToBigInt(Decimal value) {
266+
static BigInt _ceilDecimalToBigInt(Decimal value) {
230267
return value.ceil().toBigInt();
231268
}
232269

@@ -257,8 +294,11 @@ class OpenCryptoPaySettlement {
257294
return recipients;
258295
}
259296

260-
String _normalizeAddress(String address) {
261-
if (wallet.cryptoCurrency is Ethereum) return address.toLowerCase();
297+
static String _normalizeAddress(
298+
CryptoCurrency cryptoCurrency,
299+
String address,
300+
) {
301+
if (cryptoCurrency is Ethereum) return address.toLowerCase();
262302
return address;
263303
}
264304

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import 'package:decimal/decimal.dart';
2+
import 'package:flutter_test/flutter_test.dart';
3+
import 'package:stackwallet/services/open_crypto_pay/models.dart';
4+
import 'package:stackwallet/services/open_crypto_pay/settlement.dart';
5+
import 'package:stackwallet/utilities/amount/amount.dart';
6+
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
7+
8+
void main() {
9+
final bitcoin = Bitcoin(CryptoCurrencyNetwork.main);
10+
final cardano = Cardano(CryptoCurrencyNetwork.main);
11+
final ethereum = Ethereum(CryptoCurrencyNetwork.main);
12+
final firo = Firo(CryptoCurrencyNetwork.main);
13+
14+
group("shouldCommitTxIdFor", () {
15+
test("always uses txid for txid submission flows", () {
16+
expect(
17+
OpenCryptoPaySettlement.shouldCommitTxIdFor(
18+
method: "Cardano",
19+
submissionFlow: OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast,
20+
cryptoCurrency: cardano,
21+
hasSparkInputs: false,
22+
rawHexLength: 0,
23+
),
24+
true,
25+
);
26+
});
27+
28+
test("falls back for Firo Spark spends", () {
29+
expect(
30+
OpenCryptoPaySettlement.shouldCommitTxIdFor(
31+
method: "Firo",
32+
submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider,
33+
cryptoCurrency: firo,
34+
hasSparkInputs: true,
35+
rawHexLength: 100,
36+
),
37+
true,
38+
);
39+
});
40+
41+
test("falls back only above the Firo raw hex query limit", () {
42+
expect(
43+
OpenCryptoPaySettlement.shouldCommitTxIdFor(
44+
method: "Firo",
45+
submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider,
46+
cryptoCurrency: firo,
47+
hasSparkInputs: false,
48+
rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength,
49+
),
50+
false,
51+
);
52+
expect(
53+
OpenCryptoPaySettlement.shouldCommitTxIdFor(
54+
method: "Firo",
55+
submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider,
56+
cryptoCurrency: firo,
57+
hasSparkInputs: false,
58+
rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength + 1,
59+
),
60+
true,
61+
);
62+
});
63+
64+
test("does not use the Firo fallback for other coins", () {
65+
expect(
66+
OpenCryptoPaySettlement.shouldCommitTxIdFor(
67+
method: "Bitcoin",
68+
submissionFlow: OpenCryptoPaySubmissionFlow.rawHexToProvider,
69+
cryptoCurrency: bitcoin,
70+
hasSparkInputs: false,
71+
rawHexLength: OpenCryptoPaySettlement.maxRawHexQueryLength + 1,
72+
),
73+
false,
74+
);
75+
});
76+
});
77+
78+
group("validateMinFee", () {
79+
test("ceil-checks Ethereum wei gas price", () {
80+
expect(
81+
OpenCryptoPaySettlement.validateMinFee(
82+
cryptoCurrency: ethereum,
83+
minFee: Decimal.parse("10.1"),
84+
gasPrice: BigInt.from(10),
85+
),
86+
"Open CryptoPay requires at least 10.1 wei gas price",
87+
);
88+
expect(
89+
OpenCryptoPaySettlement.validateMinFee(
90+
cryptoCurrency: ethereum,
91+
minFee: Decimal.parse("10.1"),
92+
gasPrice: BigInt.from(11),
93+
),
94+
isNull,
95+
);
96+
});
97+
98+
test("requires Ethereum gas price when minFee is set", () {
99+
expect(
100+
OpenCryptoPaySettlement.validateMinFee(
101+
cryptoCurrency: ethereum,
102+
minFee: Decimal.fromInt(1),
103+
),
104+
"Could not verify Open CryptoPay minimum gas price",
105+
);
106+
});
107+
108+
test("ceil-checks Bitcoin sat/vB against total fee", () {
109+
expect(
110+
OpenCryptoPaySettlement.validateMinFee(
111+
cryptoCurrency: bitcoin,
112+
minFee: Decimal.parse("2.5"),
113+
fee: _rawAmount(7),
114+
vSize: 3,
115+
),
116+
"Open CryptoPay requires at least 2.5 sat/vB fee",
117+
);
118+
expect(
119+
OpenCryptoPaySettlement.validateMinFee(
120+
cryptoCurrency: bitcoin,
121+
minFee: Decimal.parse("2.5"),
122+
fee: _rawAmount(8),
123+
vSize: 3,
124+
),
125+
isNull,
126+
);
127+
});
128+
129+
test("requires fee and vSize for sat/vB methods", () {
130+
expect(
131+
OpenCryptoPaySettlement.validateMinFee(
132+
cryptoCurrency: firo,
133+
minFee: Decimal.fromInt(1),
134+
),
135+
"Could not verify Open CryptoPay minimum fee",
136+
);
137+
});
138+
});
139+
140+
group("validateTransaction", () {
141+
test("requires exactly one recipient", () {
142+
expect(
143+
OpenCryptoPaySettlement.validateTransaction(
144+
cryptoCurrency: bitcoin,
145+
recipients: <({String address, Amount amount})>[],
146+
recipientAddress: "bc1qrecipient",
147+
amount: Decimal.fromInt(1),
148+
),
149+
"Open CryptoPay requires exactly one recipient",
150+
);
151+
expect(
152+
OpenCryptoPaySettlement.validateTransaction(
153+
cryptoCurrency: bitcoin,
154+
recipients: [
155+
(address: "bc1qone", amount: _amount("1")),
156+
(address: "bc1qtwo", amount: _amount("1")),
157+
],
158+
recipientAddress: "bc1qrecipient",
159+
amount: Decimal.fromInt(1),
160+
),
161+
"Open CryptoPay requires exactly one recipient",
162+
);
163+
});
164+
165+
test("rejects recipient mismatch", () {
166+
expect(
167+
OpenCryptoPaySettlement.validateTransaction(
168+
cryptoCurrency: bitcoin,
169+
recipients: [(address: "bc1qactual", amount: _amount("1"))],
170+
recipientAddress: "bc1qexpected",
171+
amount: Decimal.fromInt(1),
172+
),
173+
"Open CryptoPay recipient changed. Please scan again.",
174+
);
175+
});
176+
177+
test("rejects amount mismatch", () {
178+
expect(
179+
OpenCryptoPaySettlement.validateTransaction(
180+
cryptoCurrency: bitcoin,
181+
recipients: [(address: "bc1qrecipient", amount: _amount("1.01"))],
182+
recipientAddress: "bc1qrecipient",
183+
amount: Decimal.fromInt(1),
184+
),
185+
"Open CryptoPay amount changed. Please scan again.",
186+
);
187+
});
188+
189+
test("normalizes Ethereum recipient case", () {
190+
expect(
191+
OpenCryptoPaySettlement.validateTransaction(
192+
cryptoCurrency: ethereum,
193+
recipients: [
194+
(
195+
address: "0x9C2242A0B71FD84661FD4BC56B75C90FAC6D10FC",
196+
amount: _amount("1", fractionDigits: 18),
197+
),
198+
],
199+
recipientAddress: "0x9c2242a0b71fd84661fd4bc56b75c90fac6d10fc",
200+
amount: Decimal.fromInt(1),
201+
),
202+
isNull,
203+
);
204+
});
205+
});
206+
}
207+
208+
Amount _amount(String value, {int fractionDigits = 8}) {
209+
return Amount.fromDecimal(
210+
Decimal.parse(value),
211+
fractionDigits: fractionDigits,
212+
);
213+
}
214+
215+
Amount _rawAmount(int value, {int fractionDigits = 8}) {
216+
return Amount(rawValue: BigInt.from(value), fractionDigits: fractionDigits);
217+
}

0 commit comments

Comments
 (0)