diff --git a/src/crypto/vrf.cpp b/src/crypto/vrf.cpp index b15e65f..22480db 100644 --- a/src/crypto/vrf.cpp +++ b/src/crypto/vrf.cpp @@ -47,11 +47,15 @@ struct Sha512 { }; // --------------------------------------------------------------------------------------------- -// edwards25519 on public data: RFC 8032 decoding and encoding, and the complete addition law in +// edwards25519 on public data: RFC 8032 decoding and encoding, and the twisted Edwards formulas in // extended coordinates, over OpenSSL big integers. Variable time, so only for public values (keys, // proofs, hash outputs). Every result is fixed by the curve alone. libsodium's point validation is // not used here: its answer for points outside the prime-order subgroup changed in a security fix, // and Ubuntu shipped the fix under the same 1.0.18 version, so builds disagreed on VRF proofs. +// +// Point coordinates are kept in Montgomery form (x*R mod p) for OpenSSL's fast modular +// multiplication; decode() and encode() convert at the boundary. Points are updated in place with +// per-thread scratch values, so the scalar-multiplication loops do not allocate. struct BnDeleter { void operator()(BIGNUM* b) const { BN_free(b); } @@ -70,12 +74,6 @@ Bn bn_le(const std::uint8_t* p, std::size_t n) { return b; } -Bn bn_dup(const BIGNUM* a) { - Bn b(BN_dup(a)); - if (!b) throw std::bad_alloc(); - return b; -} - BN_CTX* bn_ctx() { struct CtxDeleter { void operator()(BN_CTX* c) const { BN_CTX_free(c); } @@ -85,90 +83,151 @@ BN_CTX* bn_ctx() { return ctx.get(); } -struct Ext { - Bn x, y, z, t; // x/z, y/z, xy = t/z -}; - struct Curve { - Bn p, d2, d, sqrt_m1, sqrt_exp, l; - Ext base; + Bn p, d, sqrt_m1, sqrt_exp, l; // ordinary form + Bn d2_m, one_m; // Montgomery form + std::unique_ptr mont{nullptr, BN_MONT_CTX_free}; }; const Curve& curve(); -// r = a op b mod p -void fmul(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { BN_mod_mul(r, a, b, curve().p.get(), bn_ctx()); } -void fadd(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { BN_mod_add(r, a, b, curve().p.get(), bn_ctx()); } -void fsub(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { BN_mod_sub(r, a, b, curve().p.get(), bn_ctx()); } - -Ext identity() { - Ext e{bn_new(), bn_new(), bn_new(), bn_new()}; - BN_zero(e.x.get()); - BN_one(e.y.get()); - BN_one(e.z.get()); - BN_zero(e.t.get()); - return e; +// Montgomery-form field operations; inputs are reduced, so the quick add/sub variants apply. +void fmul(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { + BN_mod_mul_montgomery(r, a, b, curve().mont.get(), bn_ctx()); } +void fadd(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { BN_mod_add_quick(r, a, b, curve().p.get()); } +void fsub(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { BN_mod_sub_quick(r, a, b, curve().p.get()); } +// Ordinary-form multiplication, for decoding and encoding. +void nmul(BIGNUM* r, const BIGNUM* a, const BIGNUM* b) { BN_mod_mul(r, a, b, curve().p.get(), bn_ctx()); } -// add-2008-hwcd-3 (a = -1): complete on edwards25519, so it also doubles. -Ext add(const Ext& p, const Ext& q) { - const Curve& c = curve(); - Bn a = bn_new(), b = bn_new(), cc = bn_new(), d = bn_new(), t1 = bn_new(), t2 = bn_new(); - fsub(t1.get(), p.y.get(), p.x.get()); - fsub(t2.get(), q.y.get(), q.x.get()); - fmul(a.get(), t1.get(), t2.get()); - fadd(t1.get(), p.y.get(), p.x.get()); - fadd(t2.get(), q.y.get(), q.x.get()); - fmul(b.get(), t1.get(), t2.get()); - fmul(t1.get(), p.t.get(), q.t.get()); - fmul(cc.get(), t1.get(), c.d2.get()); - fmul(t1.get(), p.z.get(), q.z.get()); - fadd(d.get(), t1.get(), t1.get()); - Bn e = bn_new(), f = bn_new(), g = bn_new(), h = bn_new(); - fsub(e.get(), b.get(), a.get()); - fsub(f.get(), d.get(), cc.get()); - fadd(g.get(), d.get(), cc.get()); - fadd(h.get(), b.get(), a.get()); - Ext r{bn_new(), bn_new(), bn_new(), bn_new()}; - fmul(r.x.get(), e.get(), f.get()); - fmul(r.y.get(), g.get(), h.get()); - fmul(r.t.get(), e.get(), h.get()); - fmul(r.z.get(), f.get(), g.get()); +struct Ext { + Bn x, y, z, t; // x/z, y/z, xy = t/z, all in Montgomery form +}; + +Ext ext_new() { return Ext{bn_new(), bn_new(), bn_new(), bn_new()}; } + +Ext ext_copy(const Ext& p) { + Ext r = ext_new(); + BN_copy(r.x.get(), p.x.get()); + BN_copy(r.y.get(), p.y.get()); + BN_copy(r.z.get(), p.z.get()); + BN_copy(r.t.get(), p.t.get()); return r; } +void set_identity(Ext& r) { + BN_zero(r.x.get()); + BN_copy(r.y.get(), curve().one_m.get()); + BN_copy(r.z.get(), curve().one_m.get()); + BN_zero(r.t.get()); +} + +struct Scratch { + Bn a = bn_new(), b = bn_new(), c = bn_new(), d = bn_new(), e = bn_new(), f = bn_new(), g = bn_new(), + h = bn_new(); +}; + +Scratch& scratch() { + thread_local Scratch s; + return s; +} + +// r = p + q, add-2008-hwcd-3 (a = -1, complete on edwards25519). r may alias p or q: every input +// is read before r is written. +void add_to(Ext& r, const Ext& p, const Ext& q) { + Scratch& s = scratch(); + fsub(s.e.get(), p.y.get(), p.x.get()); + fsub(s.f.get(), q.y.get(), q.x.get()); + fmul(s.a.get(), s.e.get(), s.f.get()); // A = (Y1-X1)(Y2-X2) + fadd(s.e.get(), p.y.get(), p.x.get()); + fadd(s.f.get(), q.y.get(), q.x.get()); + fmul(s.b.get(), s.e.get(), s.f.get()); // B = (Y1+X1)(Y2+X2) + fmul(s.e.get(), p.t.get(), q.t.get()); + fmul(s.c.get(), s.e.get(), curve().d2_m.get()); // C = 2d T1 T2 + fmul(s.e.get(), p.z.get(), q.z.get()); + fadd(s.d.get(), s.e.get(), s.e.get()); // D = 2 Z1 Z2 + fsub(s.e.get(), s.b.get(), s.a.get()); // E = B - A + fsub(s.f.get(), s.d.get(), s.c.get()); // F = D - C + fadd(s.g.get(), s.d.get(), s.c.get()); // G = D + C + fadd(s.h.get(), s.b.get(), s.a.get()); // H = B + A + fmul(r.x.get(), s.e.get(), s.f.get()); + fmul(r.y.get(), s.g.get(), s.h.get()); + fmul(r.t.get(), s.e.get(), s.h.get()); + fmul(r.z.get(), s.f.get(), s.g.get()); +} + +// r = 2p, dbl-2008-hwcd (a = -1). r may alias p. +void double_to(Ext& r, const Ext& p) { + Scratch& s = scratch(); + fmul(s.a.get(), p.x.get(), p.x.get()); // A = X1^2 + fmul(s.b.get(), p.y.get(), p.y.get()); // B = Y1^2 + fmul(s.e.get(), p.z.get(), p.z.get()); + fadd(s.c.get(), s.e.get(), s.e.get()); // C = 2 Z1^2 + fadd(s.e.get(), p.x.get(), p.y.get()); + fmul(s.h.get(), s.e.get(), s.e.get()); + fsub(s.h.get(), s.h.get(), s.a.get()); + fsub(s.e.get(), s.h.get(), s.b.get()); // E = (X1+Y1)^2 - A - B + fsub(s.g.get(), s.b.get(), s.a.get()); // G = D + B with D = -A + fsub(s.f.get(), s.g.get(), s.c.get()); // F = G - C + BN_zero(s.d.get()); + fsub(s.d.get(), s.d.get(), s.a.get()); + fsub(s.h.get(), s.d.get(), s.b.get()); // H = D - B + fmul(r.x.get(), s.e.get(), s.f.get()); + fmul(r.y.get(), s.g.get(), s.h.get()); + fmul(r.t.get(), s.e.get(), s.h.get()); + fmul(r.z.get(), s.f.get(), s.g.get()); +} + Ext negate(const Ext& p) { - Bn zero = bn_new(); - BN_zero(zero.get()); - Ext r{bn_new(), bn_dup(p.y.get()), bn_dup(p.z.get()), bn_new()}; - fsub(r.x.get(), zero.get(), p.x.get()); - fsub(r.t.get(), zero.get(), p.t.get()); + Ext r = ext_copy(p); + BN_zero(r.x.get()); + fsub(r.x.get(), r.x.get(), p.x.get()); + BN_zero(r.t.get()); + fsub(r.t.get(), r.t.get(), p.t.get()); return r; } -Ext mul(const Ext& p, const BIGNUM* k) { - Ext r = identity(); - for (int i = BN_num_bits(k) - 1; i >= 0; --i) { - r = add(r, r); - if (BN_is_bit_set(k, i)) r = add(r, p); +// a*P + b*Q in one double-and-add pass over the bits of both scalars (Straus/Shamir). +Ext joint_mul(const BIGNUM* a, const Ext& p, const BIGNUM* b, const Ext& q) { + Ext pq = ext_new(); + add_to(pq, p, q); + Ext r = ext_new(); + set_identity(r); + for (int i = std::max(BN_num_bits(a), BN_num_bits(b)) - 1; i >= 0; --i) { + double_to(r, r); + const bool ba = BN_is_bit_set(a, i) != 0; + const bool bb = BN_is_bit_set(b, i) != 0; + if (ba && bb) { + add_to(r, r, pq); + } else if (ba) { + add_to(r, r, p); + } else if (bb) { + add_to(r, r, q); + } } return r; } Ext mul_cofactor(const Ext& p) { - Ext r = add(p, p); - r = add(r, r); - return add(r, r); + Ext r = ext_copy(p); + double_to(r, r); + double_to(r, r); + double_to(r, r); + return r; } bool is_identity(const Ext& p) { return BN_is_zero(p.x.get()) && BN_cmp(p.y.get(), p.z.get()) == 0; } Point encode(const Ext& p) { - Bn zi(BN_mod_inverse(nullptr, p.z.get(), curve().p.get(), bn_ctx())); + const Curve& c = curve(); + Bn x = bn_new(), y = bn_new(), z = bn_new(); + BN_from_montgomery(x.get(), p.x.get(), c.mont.get(), bn_ctx()); + BN_from_montgomery(y.get(), p.y.get(), c.mont.get(), bn_ctx()); + BN_from_montgomery(z.get(), p.z.get(), c.mont.get(), bn_ctx()); + Bn zi(BN_mod_inverse(nullptr, z.get(), c.p.get(), bn_ctx())); if (!zi) throw std::bad_alloc(); - Bn x = bn_new(), y = bn_new(); - fmul(x.get(), p.x.get(), zi.get()); - fmul(y.get(), p.y.get(), zi.get()); + nmul(x.get(), x.get(), zi.get()); + nmul(y.get(), y.get(), zi.get()); Point out{}; BN_bn2lebinpad(y.get(), out.data(), static_cast(out.size())); if (BN_is_odd(x.get())) out[31] |= 0x80; @@ -184,37 +243,39 @@ std::optional decode(const std::uint8_t* s) { b[31] &= 0x7f; Bn y = bn_le(b.data(), b.size()); if (BN_cmp(y.get(), c.p.get()) >= 0) return std::nullopt; - Bn one = bn_new(), y2 = bn_new(), u = bn_new(), v = bn_new(), t = bn_new(); + Bn one = bn_new(), y2 = bn_new(), u = bn_new(), v = bn_new(), t = bn_new(), zero = bn_new(); BN_one(one.get()); - fmul(y2.get(), y.get(), y.get()); - fsub(u.get(), y2.get(), one.get()); - fmul(t.get(), c.d.get(), y2.get()); - fadd(v.get(), t.get(), one.get()); + BN_zero(zero.get()); + nmul(y2.get(), y.get(), y.get()); + BN_mod_sub_quick(u.get(), y2.get(), one.get(), c.p.get()); + nmul(t.get(), c.d.get(), y2.get()); + BN_mod_add_quick(v.get(), t.get(), one.get(), c.p.get()); // x = u v^3 (u v^7)^((p-5)/8) Bn v3 = bn_new(), v7 = bn_new(), x = bn_new(); - fmul(t.get(), v.get(), v.get()); - fmul(v3.get(), t.get(), v.get()); - fmul(t.get(), v3.get(), v3.get()); - fmul(v7.get(), t.get(), v.get()); - fmul(t.get(), u.get(), v7.get()); + nmul(t.get(), v.get(), v.get()); + nmul(v3.get(), t.get(), v.get()); + nmul(t.get(), v3.get(), v3.get()); + nmul(v7.get(), t.get(), v.get()); + nmul(t.get(), u.get(), v7.get()); BN_mod_exp(t.get(), t.get(), c.sqrt_exp.get(), c.p.get(), bn_ctx()); - fmul(x.get(), u.get(), v3.get()); - fmul(x.get(), x.get(), t.get()); - Bn vx2 = bn_new(), neg_u = bn_new(), zero = bn_new(); - BN_zero(zero.get()); - fmul(t.get(), x.get(), x.get()); - fmul(vx2.get(), v.get(), t.get()); - fsub(neg_u.get(), zero.get(), u.get()); + nmul(x.get(), u.get(), v3.get()); + nmul(x.get(), x.get(), t.get()); + Bn vx2 = bn_new(), neg_u = bn_new(); + nmul(t.get(), x.get(), x.get()); + nmul(vx2.get(), v.get(), t.get()); + BN_mod_sub_quick(neg_u.get(), zero.get(), u.get(), c.p.get()); if (BN_cmp(vx2.get(), u.get()) != 0) { if (BN_cmp(vx2.get(), neg_u.get()) != 0) return std::nullopt; - fmul(x.get(), x.get(), c.sqrt_m1.get()); + nmul(x.get(), x.get(), c.sqrt_m1.get()); } if (BN_is_zero(x.get()) && x0) return std::nullopt; - if ((BN_is_odd(x.get()) != 0) != x0) fsub(x.get(), zero.get(), x.get()); - Ext p{std::move(x), std::move(y), bn_new(), bn_new()}; - BN_one(p.z.get()); - fmul(p.t.get(), p.x.get(), p.y.get()); - return p; + if ((BN_is_odd(x.get()) != 0) != x0) BN_mod_sub_quick(x.get(), zero.get(), x.get(), c.p.get()); + Ext pt = ext_new(); + BN_to_montgomery(pt.x.get(), x.get(), c.mont.get(), bn_ctx()); + BN_to_montgomery(pt.y.get(), y.get(), c.mont.get(), bn_ctx()); + BN_copy(pt.z.get(), c.one_m.get()); + fmul(pt.t.get(), pt.x.get(), pt.y.get()); + return pt; } const Curve& curve() { @@ -224,6 +285,8 @@ const Curve& curve() { k.p = bn_new(); BN_set_bit(k.p.get(), 255); BN_sub_word(k.p.get(), 19); + k.mont.reset(BN_MONT_CTX_new()); + if (!k.mont || BN_MONT_CTX_set(k.mont.get(), k.p.get(), ctx) != 1) throw std::bad_alloc(); // d = -121665 / 121666 Bn num = bn_new(), den = bn_new(); BN_set_word(num.get(), 121665); @@ -233,8 +296,14 @@ const Curve& curve() { if (!inv) throw std::bad_alloc(); k.d = bn_new(); BN_mod_mul(k.d.get(), num.get(), inv.get(), k.p.get(), ctx); - k.d2 = bn_new(); - BN_mod_add(k.d2.get(), k.d.get(), k.d.get(), k.p.get(), ctx); + Bn d2 = bn_new(); + BN_mod_add(d2.get(), k.d.get(), k.d.get(), k.p.get(), ctx); + k.d2_m = bn_new(); + BN_to_montgomery(k.d2_m.get(), d2.get(), k.mont.get(), ctx); + Bn one = bn_new(); + BN_one(one.get()); + k.one_m = bn_new(); + BN_to_montgomery(k.one_m.get(), one.get(), k.mont.get(), ctx); // sqrt(-1) = 2^((p-1)/4); exponent (p-5)/8 for the square root in decoding Bn two = bn_new(), e = bn_new(); BN_set_word(two.get(), 2); @@ -364,8 +433,8 @@ std::optional verify_impl(const PubKey32& pk, const VrfProof80& proof if (BN_cmp(s.get(), curve().l.get()) >= 0) return std::nullopt; const auto h = encode_to_curve(pk, alpha); if (!h) return std::nullopt; - const Ext u = add(mul(base_point(), s.get()), mul(negate(*y), c.get())); // s*B - c*Y - const Ext v = add(mul(*h, s.get()), mul(negate(*gamma), c.get())); // s*H - c*Gamma + const Ext u = joint_mul(s.get(), base_point(), c.get(), negate(*y)); // s*B - c*Y + const Ext v = joint_mul(s.get(), *h, c.get(), negate(*gamma)); // s*H - c*Gamma Point gamma_str{}; std::copy_n(proof.begin(), gamma_str.size(), gamma_str.begin()); const auto expected = challenge(pk, encode(*h), gamma_str, encode(u), encode(v)); @@ -423,6 +492,25 @@ bool self_test() { other.push_back(0x00); if (verify_impl(pk, expected_pi, other).has_value()) return false; } + // The decoder is part of the consensus rule: y >= p, x = 0 with the sign bit set, a point off the + // curve and a small-order key must all be rejected (tests/test_vrf.cpp has the full set). + static constexpr const char* kRejectedKeys[] = { + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "0100000000000000000000000000000000000000000000000000000000000080", + "0200000000000000000000000000000000000000000000000000000000000000", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", + }; + const auto pi = hex_decode(kVectors[0].pi); + if (!pi || pi->size() != kVrfProofBytes) return false; + VrfProof80 proof{}; + std::copy(pi->begin(), pi->end(), proof.begin()); + for (const char* key : kRejectedKeys) { + const auto enc = hex_decode(key); + if (!enc || enc->size() != 32) return false; + PubKey32 pk{}; + std::copy(enc->begin(), enc->end(), pk.begin()); + if (verify_impl(pk, proof, Bytes{}).has_value()) return false; + } return true; } diff --git a/testnet/JOIN.md b/testnet/JOIN.md index 40ce5cb..13eae8b 100644 --- a/testnet/JOIN.md +++ b/testnet/JOIN.md @@ -137,8 +137,14 @@ It prints the key's `address=tsc1…` (where your bond goes) and its `balance`, ### 4.2 Ask for testnet FLS -Post your `tsc1…` address in **[Discord](https://discord.gg/8rKfKc7QR9)** and ask for validator -funds. You need the bond plus a small fee: +In the faucet channel on **[Discord](https://discord.gg/8rKfKc7QR9)**, type: + +``` +/faucet address:tsc1… +``` + +The faucet bot sends the bond plus 1 FLS for fees, one grant per Discord account and per address. +The bond depends on how many validators are active: | active validators | bond | |---|---| @@ -146,10 +152,12 @@ funds. You need the bond plus a small fee: | 4 | 2,000 FLS | | 16 or more | 1,000 FLS | -The bond shrinks as the validator set grows, so early validators need more. We send a little extra -for fees. +Testnet FLS exist only as block rewards (there is no premine, also on testnet), so bonds are paid +as the faucet earns them, in request order. The bot tells you your place in the queue and an +estimated time, and pings you when the coins are sent and again when they are final. Meanwhile your +node keeps running and syncing as a plain node. -Once we confirm, run `getBalance` again to see the coins arrive. +When the bot says the payment is final, run `getBalance` again to see the coins. ### 4.3 Register diff --git a/tests/test_integration.cpp b/tests/test_integration.cpp index 4f3b243..1c41683 100644 --- a/tests/test_integration.cpp +++ b/tests/test_integration.cpp @@ -9368,9 +9368,12 @@ TEST(test_follower_sync_does_not_reject_canonical_block_due_to_local_epoch_ticke ASSERT_TRUE(follower.init()); follower.start(); + // Pausing stops new proposals, but one already proposed can still finalize after the snapshot + // above, so the follower is compared with the validators' live tip (never below the snapshot). ASSERT_TRUE(wait_for([&]() { const auto sf = follower.status(); - return sf.height == validator_status.height && sf.transition_hash == validator_status.transition_hash; + const auto sv = validators[0]->status(); + return sv.height >= validator_status.height && sf.height == sv.height && sf.transition_hash == sv.transition_hash; }, ci_timeout_seconds(30))); const auto local_pub = follower.local_validator_pubkey_for_test(); diff --git a/tests/test_vrf.cpp b/tests/test_vrf.cpp index 0e2645a..e23b98b 100644 --- a/tests/test_vrf.cpp +++ b/tests/test_vrf.cpp @@ -82,6 +82,51 @@ TEST(test_vrf_outputs_differ_per_key_and_alpha) { ASSERT_TRUE(out_a1 != out_b1); } +// The decoder is part of the consensus rule, and the RFC vectors only cover valid proofs. These +// are the encodings Ed25519 implementations are known to disagree on ("Taming the many EdDSAs", +// Chalkias et al. 2020), each classified with an independent RFC 8032 implementation: a public key +// or Gamma with any of them must be rejected. +TEST(test_vrf_rejects_non_canonical_off_curve_and_small_order_encodings) { + ASSERT_TRUE(crypto::vrf_init()); + const auto kp = key_from_byte(0x37); + const auto proof = *crypto::vrf_prove(kp.private_key, text("alpha")); + ASSERT_TRUE(crypto::vrf_verify(kp.public_key, proof, text("alpha")).has_value()); + struct Case { + const char* what; + const char* hex; + bool decodes; + }; + const Case cases[] = { + {"y = p", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", false}, + {"y = p + 1 (alias of the identity)", "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", false}, + {"y = 2^255 - 1", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", false}, + {"identity with the sign bit set", "0100000000000000000000000000000000000000000000000000000000000080", false}, + {"(0, -1) with the sign bit set", "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", false}, + {"y = 2, not on the curve", "0200000000000000000000000000000000000000000000000000000000000000", false}, + {"identity (order 1)", "0100000000000000000000000000000000000000000000000000000000000000", true}, + {"(0, -1) (order 2)", "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", true}, + {"y = 0 (order 4)", "0000000000000000000000000000000000000000000000000000000000000000", true}, + {"y = 0, negative x (order 4)", "0000000000000000000000000000000000000000000000000000000000000080", true}, + {"order 8", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", true}, + {"order 8", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", true}, + {"order 8", "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", true}, + {"order 8", "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", true}, + }; + for (const auto& c : cases) { + const Bytes enc = *hex_decode(c.hex); + PubKey32 pk{}; + std::copy(enc.begin(), enc.end(), pk.begin()); + if (crypto::vrf_verify(pk, proof, text("alpha")).has_value()) throw std::runtime_error(std::string("accepted key: ") + c.what); + auto bad = proof; + std::copy(enc.begin(), enc.end(), bad.begin()); + if (crypto::vrf_verify(kp.public_key, bad, text("alpha")).has_value()) + throw std::runtime_error(std::string("accepted Gamma: ") + c.what); + // Only a Gamma that does not decode has no output (all zero); a small-order one still decodes. + const bool zero_output = crypto::vrf_proof_to_output(bad) == crypto::VrfOutput{}; + if (zero_output == c.decodes) throw std::runtime_error(std::string("decode mismatch: ") + c.what); + } +} + // RFC 9381 appendix B.3 (ECVRF-EDWARDS25519-SHA512-TAI), examples 16-18: the published proofs and // outputs, so the construction is the standard one and any change to it is caught here. TEST(test_vrf_matches_rfc9381_edwards25519_tai_vectors) { diff --git a/tools/discord-faucet/README.md b/tools/discord-faucet/README.md new file mode 100644 index 0000000..bbf1026 --- /dev/null +++ b/tools/discord-faucet/README.md @@ -0,0 +1,66 @@ +# Testnet bond faucet (Discord bot) + +`/faucet address:tsc1…` in the faucet channel queues one free validator bond per Discord account and +per address. The bot pays the current minimum bond (`finalis-cli economics_status`) plus 1 FLS for +fees, one payment at a time and in request order, and pings the user when the payment is sent and +again when it is final. + +Testnet coins only exist as block rewards (there is no premine, also on testnet), so the faucet can +only pay out what the validator has earned: about 5.75 FLS per block, while a bond is 4,000 FLS with +one validator. Requests wait in the queue until the faucet holds enough; the bot tells each user their +place and an estimated time. + +- `faucet_core.py`: queue, limits and payments (no Discord code). `python3 -m unittest test_faucet_core` + runs its offline tests. +- `faucet_bot.py`: the Discord front end. +- The bot signs with its **own key**, never the validator's. `finalis-faucet-sweep.timer` moves the + validator's rewards to the faucet address every hour. + +## Setup on the node host + +1. **Discord application.** At : New Application → Bot → + Reset Token (copy it). Under OAuth2 → URL Generator pick the scopes `bot` and `applications.commands` + and the permission *Send Messages*, open the generated URL and add the bot to your server. Enable + Developer Mode in Discord (Settings → Advanced) to copy the server id, the faucet channel id and + your own user id (right-click → Copy ID). + +2. **Faucet key** (testnet only; the bot runs unattended, so it is stored without a passphrase): + + ```bash + mkdir -p ~/finalis-faucet && chmod 700 ~/finalis-faucet + ~/finalis-core/build/finalis-cli --testnet wallet_create --out ~/finalis-faucet/faucet.json + ``` + + Note the printed `address=tsc1…`. + +3. **Python environment:** + + ```bash + sudo apt install -y python3-venv + python3 -m venv ~/finalis-faucet/venv + ~/finalis-faucet/venv/bin/pip install -r ~/finalis-core/tools/discord-faucet/requirements.txt + ``` + +4. **Configuration:** copy `faucet.env.example` to `/etc/finalis-faucet.env`, fill in the token, ids + and faucet address, then `sudo chmod 600 /etc/finalis-faucet.env`. + +5. **Services:** + + ```bash + cd ~/finalis-core/tools/discord-faucet + sudo cp finalis-faucet.service finalis-faucet-sweep.service finalis-faucet-sweep.timer /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable --now finalis-faucet-sweep.timer finalis-faucet + ``` + +## Day to day + +```bash +sudo journalctl -u finalis-faucet -f # bot log +sudo journalctl -u finalis-faucet-sweep -n 20 # last sweeps +sqlite3 ~/finalis-faucet/faucet.sqlite 'SELECT id, user_id, address, state, txid FROM grants' +``` + +A payment that fails is marked `failed` and never retried automatically, so an unclear broadcast can +not pay twice; the bot pings `FAUCET_ADMIN_USER_ID`. After checking the txid, retry it with +`UPDATE grants SET state = 'queued' WHERE id = `. diff --git a/tools/discord-faucet/faucet.env.example b/tools/discord-faucet/faucet.env.example new file mode 100644 index 0000000..17a04f5 --- /dev/null +++ b/tools/discord-faucet/faucet.env.example @@ -0,0 +1,28 @@ +# /etc/finalis-faucet.env (chmod 600): read by finalis-faucet.service and finalis-faucet-sweep.service. + +# Discord: bot token, server (guild) id, the channel /faucet works in, and who to ping on failures. +FAUCET_DISCORD_TOKEN= +FAUCET_GUILD_ID= +FAUCET_CHANNEL_ID= +FAUCET_ADMIN_USER_ID= + +# Node on this machine. +FAUCET_RPC=http://127.0.0.1:19544/rpc +FAUCET_CLI=/home/ubuntu/finalis-core/build/finalis-cli +FAUCET_NODE_DB=/home/ubuntu/.finalis/testnet + +# The faucet's own key (not the validator key) and its address. +FAUCET_KEY_FILE=/home/ubuntu/finalis-faucet/faucet.json +FAUCET_KEY_PASS= +FAUCET_ADDRESS= + +# Where requests are kept. +FAUCET_DB=/home/ubuntu/finalis-faucet/faucet.sqlite + +# Sweep: the validator key whose rewards move to the faucet every hour. +SWEEP_VALIDATOR_KEY_FILE=/home/ubuntu/.finalis/testnet/keystore/validator.json + +# Optional: fee buffer added to each bond (units; default 1 FLS) and the faucet's income per block, +# used only for the ETA shown to users (default 5.75 FLS). +#FAUCET_FEE_BUFFER_UNITS=100000000 +#FAUCET_INCOME_UNITS_PER_BLOCK=575000000 diff --git a/tools/discord-faucet/faucet_bot.py b/tools/discord-faucet/faucet_bot.py new file mode 100644 index 0000000..026460f --- /dev/null +++ b/tools/discord-faucet/faucet_bot.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Discord front end of the testnet bond faucet (logic in faucet_core.py). See README.md. + +Slash command: /faucet address:. Works only in the configured channel. Every minute the bot +finishes or starts one payment and pings the user in that channel. +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +import discord +from discord import app_commands + +from faucet_core import Cli, Faucet, Rpc, Store + + +def env(name: str, default: str | None = None) -> str: + value = os.environ.get(name, default) + if value is None: + raise SystemExit(f"missing environment variable {name}") + return value + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + rpc_url = env("FAUCET_RPC", "http://127.0.0.1:19544/rpc") + guild_id = int(env("FAUCET_GUILD_ID")) + channel_id = int(env("FAUCET_CHANNEL_ID")) + admin_user_id = env("FAUCET_ADMIN_USER_ID", "") + faucet = Faucet( + store=Store(env("FAUCET_DB", "/var/lib/finalis-faucet/faucet.sqlite")), + rpc=Rpc(rpc_url), + cli=Cli(env("FAUCET_CLI"), env("FAUCET_NODE_DB"), env("FAUCET_KEY_FILE"), env("FAUCET_KEY_PASS", ""), rpc_url), + faucet_address=env("FAUCET_ADDRESS"), + fee_buffer_units=int(env("FAUCET_FEE_BUFFER_UNITS", "100000000")), # 1 FLS + income_units_per_block=int(env("FAUCET_INCOME_UNITS_PER_BLOCK", "575000000")), # ~5.75 FLS + ) + lock = asyncio.Lock() # faucet calls run one at a time, in a worker thread + + client = discord.Client(intents=discord.Intents.default()) + tree = app_commands.CommandTree(client) + guild = discord.Object(id=guild_id) + + async def run(fn, *args): + async with lock: + return await asyncio.to_thread(fn, *args) + + @tree.command(name="faucet", description="Get a free testnet validator bond (one per account)", guild=guild) + @app_commands.describe(address="Your validator address (tsc1...), from finalis-cli --testnet getBalance") + async def faucet_cmd(interaction: discord.Interaction, address: str) -> None: + if interaction.channel_id != channel_id: + await interaction.response.send_message(f"Please use <#{channel_id}>.", ephemeral=True) + return + await interaction.response.defer(thinking=True) + try: + reply = await run(faucet.request, str(interaction.user.id), address) + except Exception: + logging.exception("request failed") + reply = "Something went wrong on my side; please try again later." + await interaction.followup.send(f"{interaction.user.mention} {reply}") + + async def ticker() -> None: + await client.wait_until_ready() + channel = client.get_channel(channel_id) or await client.fetch_channel(channel_id) + while not client.is_closed(): + try: + for event in await run(faucet.tick): + await channel.send(f"<@{event.user_id}> {event.text}") + if "failed" in event.text and admin_user_id: + await channel.send(f"<@{admin_user_id}> a faucet payment failed; see the bot log.") + except Exception: + logging.exception("tick failed") + await asyncio.sleep(60) + + started = False + + @client.event + async def on_ready() -> None: + # on_ready fires again after every reconnect; one payment loop only. + nonlocal started + if started: + return + started = True + await tree.sync(guild=guild) + logging.info("logged in as %s; /faucet synced to guild %s", client.user, guild_id) + client.loop.create_task(ticker()) + + client.run(env("FAUCET_DISCORD_TOKEN"), log_handler=None) + + +if __name__ == "__main__": + main() diff --git a/tools/discord-faucet/faucet_core.py b/tools/discord-faucet/faucet_core.py new file mode 100644 index 0000000..7c7798f --- /dev/null +++ b/tools/discord-faucet/faucet_core.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: MIT +"""Testnet validator-bond faucet: queue, limits, funding and sending, with no Discord code. + +Each Discord account and each address gets one grant. A grant is the current minimum validator bond +(from `finalis-cli economics_status`) plus a small fee buffer. Grants are paid in request order, one +at a time: the next payment waits until the previous one is finalized, because the faucet's change +output is only spendable once final. While the faucet holds too little, requests wait in the queue. +""" + +from __future__ import annotations + +import json +import sqlite3 +import subprocess +import time +import urllib.request +from dataclasses import dataclass +from typing import Callable, Optional + +UNITS_PER_FLS = 100_000_000 +BLOCK_SECONDS = 180 + + +def fls(units: int) -> str: + return f"{units / UNITS_PER_FLS:,.2f} FLS" + + +class Rpc: + """Lightserver JSON-RPC.""" + + def __init__(self, url: str, timeout: float = 20.0): + self.url = url + self.timeout = timeout + + def call(self, method: str, params: dict) -> dict: + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode() + req = urllib.request.Request(self.url, body, {"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + reply = json.load(resp) + if reply.get("error"): + raise RuntimeError(reply["error"].get("message", "rpc error")) + return reply["result"] + + def validate_address(self, address: str) -> dict: + return self.call("validate_address", {"address": address}) + + def balance_units(self, address: str) -> int: + scripthash = self.validate_address(address)["scripthash_hex"] + return sum(u["value"] for u in self.call("get_utxos", {"scripthash_hex": scripthash})) + + def tx_finalized(self, txid: str) -> bool: + return bool(self.call("get_tx_status", {"txid": txid}).get("finalized")) + + +class Cli: + """finalis-cli for the two things the RPC does not offer: the current minimum bond and signing.""" + + def __init__(self, cli: str, node_db: str, key_file: str, key_pass: str, rpc_url: str): + self.cli, self.node_db, self.key_file, self.key_pass, self.rpc_url = cli, node_db, key_file, key_pass, rpc_url + + def _run(self, args: list[str]) -> tuple[int, dict[str, str], str]: + proc = subprocess.run([self.cli, "--testnet", *args], capture_output=True, text=True, timeout=120) + fields = {} + for line in proc.stdout.splitlines(): + key, sep, value = line.partition("=") + if sep: + fields[key.strip()] = value.strip() + return proc.returncode, fields, (proc.stderr.strip() or proc.stdout.strip()) + + def min_bond_units(self) -> int: + code, fields, err = self._run(["economics_status", "--db", self.node_db]) + if code != 0 or "min_bond" not in fields: + raise RuntimeError(f"economics_status failed: {err}") + return int(fields["min_bond"]) + + def send(self, address: str, units: int) -> tuple[Optional[str], bool, str]: + """(txid, accepted, error). accepted is False also when the broadcast outcome is unknown.""" + args = ["send", "--to", address, "--amount-units", str(units), "--file", self.key_file, "--rpc", self.rpc_url] + if self.key_pass: + args += ["--pass", self.key_pass] + code, fields, err = self._run(args) + txid = fields.get("txid") + accepted = code == 0 and fields.get("accepted") not in ("no", "unknown") + return txid, accepted, "" if accepted else (fields.get("error_message") or err) + + +@dataclass +class Grant: + id: int + user_id: str + address: str + state: str # queued | sent | done | failed + units: int + txid: Optional[str] + error: Optional[str] + + +@dataclass +class Event: + """Something to tell a user about.""" + user_id: str + text: str + + +class Store: + def __init__(self, path: str): + # The bot calls in from one worker thread at a time (it holds a lock), never concurrently. + self.db = sqlite3.connect(path, check_same_thread=False) + self.db.execute( + """CREATE TABLE IF NOT EXISTS grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + address TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + units INTEGER NOT NULL DEFAULT 0, + txid TEXT, + error TEXT, + created REAL NOT NULL, + updated REAL NOT NULL)""" + ) + self.db.commit() + + def _grant(self, row) -> Optional[Grant]: + return Grant(*row) if row else None + + _COLS = "id, user_id, address, state, units, txid, error" + + def by_user(self, user_id: str) -> Optional[Grant]: + return self._grant(self.db.execute(f"SELECT {self._COLS} FROM grants WHERE user_id = ?", (user_id,)).fetchone()) + + def by_address(self, address: str) -> Optional[Grant]: + return self._grant(self.db.execute(f"SELECT {self._COLS} FROM grants WHERE address = ?", (address,)).fetchone()) + + def add(self, user_id: str, address: str) -> Grant: + now = time.time() + self.db.execute("INSERT INTO grants (user_id, address, state, created, updated) VALUES (?, ?, 'queued', ?, ?)", + (user_id, address, now, now)) + self.db.commit() + return self.by_user(user_id) + + def first(self, state: str) -> Optional[Grant]: + return self._grant(self.db.execute(f"SELECT {self._COLS} FROM grants WHERE state = ? ORDER BY id LIMIT 1", + (state,)).fetchone()) + + def position(self, grant: Grant) -> int: + """1 = next to be paid.""" + return self.db.execute("SELECT COUNT(*) FROM grants WHERE state = 'queued' AND id <= ?", (grant.id,)).fetchone()[0] + + def update(self, grant_id: int, **fields) -> None: + sets = ", ".join(f"{k} = ?" for k in fields) + self.db.execute(f"UPDATE grants SET {sets}, updated = ? WHERE id = ?", (*fields.values(), time.time(), grant_id)) + self.db.commit() + + +class Faucet: + def __init__(self, store: Store, rpc: Rpc, cli: Cli, faucet_address: str, fee_buffer_units: int, + income_units_per_block: int, clock: Callable[[], float] = time.time): + self.store, self.rpc, self.cli = store, rpc, cli + self.faucet_address = faucet_address + self.fee_buffer_units = fee_buffer_units + self.income_units_per_block = income_units_per_block + self.clock = clock + + def grant_units(self) -> int: + return self.cli.min_bond_units() + self.fee_buffer_units + + def request(self, user_id: str, address: str) -> str: + """Reply text for a /faucet request.""" + address = address.strip() + existing = self.store.by_user(user_id) + if existing: + return "You already have a grant. " + self.describe(existing) + if self.store.by_address(address): + return "That address already has a grant. Each address can be funded once." + try: + check = self.rpc.validate_address(address) + except Exception as exc: # RPC down or malformed reply + return f"I couldn't check that address right now ({exc}). Please try again in a few minutes." + if not check.get("valid"): + return f"That is not a valid address: {check.get('error') or 'invalid'}." + if check.get("server_network_match") is not True: + return "That address is not a testnet address. Testnet addresses start with `tsc1`." + grant = self.store.add(user_id, check.get("normalized_address") or address) + return "You're in the queue. " + self.describe(grant) + + def describe(self, grant: Grant) -> str: + if grant.state == "done": + return (f"{fls(grant.units)} reached `{grant.address}` in tx `{grant.txid}`. " + "Next: `finalis-cli --testnet validator-register` (see testnet/JOIN.md, step 4.3).") + if grant.state == "sent": + return f"Payment `{grant.txid}` is on its way; it is final within a block or two (~3 min each)." + if grant.state == "failed": + return f"The payment failed ({grant.error}). The maintainer has been told." + position = self.store.position(grant) + try: + need = self.grant_units() * position + have = self.rpc.balance_units(self.faucet_address) + except Exception: + return f"Your place in the queue: {position}." + if have >= need: + return f"Your place in the queue: {position}. Funds are ready; payment goes out within a few minutes." + blocks = -(-(need - have) // max(1, self.income_units_per_block)) + hours = blocks * BLOCK_SECONDS / 3600 + return (f"Your place in the queue: {position}. The faucet holds {fls(have)} and a bond is " + f"{fls(self.grant_units())}, so funds for your grant arrive in about {hours:.0f} h " + f"({blocks} blocks). I'll ping you when it is sent.") + + def tick(self) -> list[Event]: + """One step: finish the payment in flight, or start the next one if funds allow.""" + sent = self.store.first("sent") + if sent: + if self.rpc.tx_finalized(sent.txid): + self.store.update(sent.id, state="done") + return [Event(sent.user_id, "Your validator bond is final. " + self.describe(self.store.by_user(sent.user_id)))] + return [] + grant = self.store.first("queued") + if not grant: + return [] + units = self.grant_units() + if self.rpc.balance_units(self.faucet_address) < units: + return [] + txid, accepted, error = self.cli.send(grant.address, units) + if accepted and txid: + self.store.update(grant.id, state="sent", units=units, txid=txid) + return [Event(grant.user_id, f"Sent {fls(units)} to `{grant.address}` (tx `{txid}`). " + "I'll ping you again when it is final.")] + self.store.update(grant.id, state="failed", units=units, txid=txid, error=error[:300]) + return [Event(grant.user_id, f"Sending your grant failed: {error[:300]}. The maintainer has been told.")] diff --git a/tools/discord-faucet/finalis-faucet-sweep.service b/tools/discord-faucet/finalis-faucet-sweep.service new file mode 100644 index 0000000..5d88279 --- /dev/null +++ b/tools/discord-faucet/finalis-faucet-sweep.service @@ -0,0 +1,10 @@ +[Unit] +Description=Move the testnet validator's rewards to the faucet address +After=finalis-testnet.service + +[Service] +Type=oneshot +User=ubuntu +EnvironmentFile=/etc/finalis-faucet.env +# Exits 1 with "no spendable finalized inputs" when there is nothing to move; that is harmless. +ExecStart=/bin/sh -c 'exec "$FAUCET_CLI" --testnet send --max --to "$FAUCET_ADDRESS" --file "$SWEEP_VALIDATOR_KEY_FILE" --rpc "$FAUCET_RPC"' diff --git a/tools/discord-faucet/finalis-faucet-sweep.timer b/tools/discord-faucet/finalis-faucet-sweep.timer new file mode 100644 index 0000000..09489f3 --- /dev/null +++ b/tools/discord-faucet/finalis-faucet-sweep.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Hourly sweep of validator rewards to the faucet + +[Timer] +OnBootSec=10min +OnUnitActiveSec=1h + +[Install] +WantedBy=timers.target diff --git a/tools/discord-faucet/finalis-faucet.service b/tools/discord-faucet/finalis-faucet.service new file mode 100644 index 0000000..7807afc --- /dev/null +++ b/tools/discord-faucet/finalis-faucet.service @@ -0,0 +1,16 @@ +[Unit] +Description=Finalis testnet bond faucet (Discord bot) +After=network-online.target finalis-testnet.service +Wants=network-online.target + +[Service] +Type=simple +User=ubuntu +EnvironmentFile=/etc/finalis-faucet.env +WorkingDirectory=/home/ubuntu/finalis-core/tools/discord-faucet +ExecStart=/home/ubuntu/finalis-faucet/venv/bin/python faucet_bot.py +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/tools/discord-faucet/requirements.txt b/tools/discord-faucet/requirements.txt new file mode 100644 index 0000000..22b5eca --- /dev/null +++ b/tools/discord-faucet/requirements.txt @@ -0,0 +1 @@ +discord.py==2.4.0 diff --git a/tools/discord-faucet/test_faucet_core.py b/tools/discord-faucet/test_faucet_core.py new file mode 100644 index 0000000..51f82b8 --- /dev/null +++ b/tools/discord-faucet/test_faucet_core.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: MIT +"""Offline tests of faucet_core with a fake RPC and CLI: python3 -m unittest test_faucet_core""" + +import tempfile +import unittest + +from faucet_core import UNITS_PER_FLS, Faucet, Store + +BOND = 4000 * UNITS_PER_FLS +BUFFER = 1 * UNITS_PER_FLS +FAUCET = "tsc1faucet" + + +class FakeRpc: + def __init__(self): + self.balance = 0 + self.finalized = set() + + def validate_address(self, address): + if address.startswith("tsc1"): + return {"valid": True, "server_network_match": True, "normalized_address": address} + if address.startswith("sc1"): + return {"valid": True, "server_network_match": False} + return {"valid": False, "error": "bad checksum"} + + def balance_units(self, address): + assert address == FAUCET + return self.balance + + def tx_finalized(self, txid): + return txid in self.finalized + + +class FakeCli: + def __init__(self, rpc): + self.rpc = rpc + self.sent = [] + self.fail_next = False + + def min_bond_units(self): + return BOND + + def send(self, address, units): + if self.fail_next: + return "deadbeef", False, "fee too low" + txid = f"tx{len(self.sent)}" + self.sent.append((address, units)) + self.rpc.balance -= units + return txid, True, "" + + +class FaucetTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.NamedTemporaryFile(suffix=".sqlite") + self.rpc = FakeRpc() + self.cli = FakeCli(self.rpc) + self.faucet = Faucet(Store(self.tmp.name), self.rpc, self.cli, FAUCET, BUFFER, 575_000_000) + + def tearDown(self): + self.faucet.store.db.close() + self.tmp.close() + + def test_rejects_invalid_and_mainnet_addresses(self): + self.assertIn("not a valid address", self.faucet.request("u1", "garbage")) + self.assertIn("not a testnet address", self.faucet.request("u1", "sc1abc")) + self.assertIsNone(self.faucet.store.by_user("u1")) + + def test_one_grant_per_account_and_per_address(self): + self.assertIn("queue", self.faucet.request("u1", "tsc1a")) + self.assertIn("already have a grant", self.faucet.request("u1", "tsc1b")) + self.assertIn("already has a grant", self.faucet.request("u2", "tsc1a")) + + def test_waits_for_funds_then_pays_bond_plus_buffer_in_order(self): + self.faucet.request("u1", "tsc1a") + reply = self.faucet.request("u2", "tsc1b") + self.assertIn("place in the queue: 2", reply) + self.rpc.balance = BOND # less than bond + buffer + self.assertEqual(self.faucet.tick(), []) + self.assertEqual(self.cli.sent, []) + self.rpc.balance = 2 * (BOND + BUFFER) + events = self.faucet.tick() + self.assertEqual(self.cli.sent, [("tsc1a", BOND + BUFFER)]) + self.assertEqual(events[0].user_id, "u1") + # Next payment waits until the first is final (its change is not spendable before). + self.assertEqual(self.faucet.tick(), []) + self.assertEqual(len(self.cli.sent), 1) + self.rpc.finalized.add("tx0") + self.assertIn("final", self.faucet.tick()[0].text) + self.faucet.tick() + self.assertEqual(self.cli.sent[1], ("tsc1b", BOND + BUFFER)) + + def test_failed_send_is_not_retried(self): + self.faucet.request("u1", "tsc1a") + self.rpc.balance = 10 * BOND + self.cli.fail_next = True + self.assertIn("failed", self.faucet.tick()[0].text) + self.cli.fail_next = False + self.assertEqual(self.faucet.tick(), []) # never paid twice automatically + self.assertEqual(self.faucet.store.by_user("u1").state, "failed") + + def test_eta_reports_hours_from_income(self): + self.rpc.balance = 0 + reply = self.faucet.request("u1", "tsc1a") + blocks = -(-(BOND + BUFFER) // 575_000_000) + self.assertIn(f"({blocks} blocks)", reply) + + +if __name__ == "__main__": + unittest.main()