mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-12 14:09:25 +00:00
Compare commits
5 Commits
ee10692b23
...
ba657e23d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba657e23d1 | ||
|
|
32c24917c4 | ||
|
|
4ba961b2cb | ||
|
|
c59be46e2f | ||
|
|
2c165e19ae |
2
.github/actions/test-dependencies/action.yml
vendored
2
.github/actions/test-dependencies/action.yml
vendored
@@ -10,7 +10,7 @@ inputs:
|
|||||||
bitcoin-version:
|
bitcoin-version:
|
||||||
description: "Bitcoin version to download and run as a regtest node"
|
description: "Bitcoin version to download and run as a regtest node"
|
||||||
required: false
|
required: false
|
||||||
default: "27.0"
|
default: "27.1"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4781,7 +4781,6 @@ dependencies = [
|
|||||||
"monero-primitives",
|
"monero-primitives",
|
||||||
"rand_core",
|
"rand_core",
|
||||||
"std-shims",
|
"std-shims",
|
||||||
"subtle",
|
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ thiserror = { version = "1", default-features = false, optional = true }
|
|||||||
|
|
||||||
rand_core = { version = "0.6", default-features = false }
|
rand_core = { version = "0.6", default-features = false }
|
||||||
zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] }
|
zeroize = { version = "^1.5", default-features = false, features = ["zeroize_derive"] }
|
||||||
subtle = { version = "^2.4", default-features = false }
|
|
||||||
|
|
||||||
# Cryptographic dependencies
|
# Cryptographic dependencies
|
||||||
curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] }
|
curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "zeroize"] }
|
||||||
@@ -47,7 +46,6 @@ std = [
|
|||||||
|
|
||||||
"rand_core/std",
|
"rand_core/std",
|
||||||
"zeroize/std",
|
"zeroize/std",
|
||||||
"subtle/std",
|
|
||||||
|
|
||||||
"monero-io/std",
|
"monero-io/std",
|
||||||
"monero-generators/std",
|
"monero-generators/std",
|
||||||
|
|||||||
@@ -167,19 +167,18 @@ impl<'a> AggregateRangeStatement<'a> {
|
|||||||
|
|
||||||
let (y, z) = Self::transcript_A_S(transcript, A, S);
|
let (y, z) = Self::transcript_A_S(transcript, A, S);
|
||||||
transcript = z;
|
transcript = z;
|
||||||
|
let z = ScalarVector::powers(z, 3 + padded_pow_of_2);
|
||||||
|
|
||||||
let twos = ScalarVector::powers(Scalar::from(2u8), COMMITMENT_BITS);
|
let twos = ScalarVector::powers(Scalar::from(2u8), COMMITMENT_BITS);
|
||||||
|
|
||||||
let l = [aL - z, sL];
|
let l = [aL - z[1], sL];
|
||||||
let y_pow_n = ScalarVector::powers(y, aR.len());
|
let y_pow_n = ScalarVector::powers(y, aR.len());
|
||||||
let mut r = [((aR + z) * &y_pow_n), sR * &y_pow_n];
|
let mut r = [((aR + z[1]) * &y_pow_n), sR * &y_pow_n];
|
||||||
{
|
{
|
||||||
let mut z_current = z * z;
|
|
||||||
for j in 0 .. padded_pow_of_2 {
|
for j in 0 .. padded_pow_of_2 {
|
||||||
for i in 0 .. COMMITMENT_BITS {
|
for i in 0 .. COMMITMENT_BITS {
|
||||||
r[0].0[(j * COMMITMENT_BITS) + i] += z_current * twos[i];
|
r[0].0[(j * COMMITMENT_BITS) + i] += z[2 + j] * twos[i];
|
||||||
}
|
}
|
||||||
z_current *= z;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let t1 = (l[0].clone().inner_product(&r[1])) + (r[0].clone().inner_product(&l[1]));
|
let t1 = (l[0].clone().inner_product(&r[1])) + (r[0].clone().inner_product(&l[1]));
|
||||||
@@ -216,10 +215,8 @@ impl<'a> AggregateRangeStatement<'a> {
|
|||||||
let t_hat = l.clone().inner_product(&r);
|
let t_hat = l.clone().inner_product(&r);
|
||||||
let mut tau_x = ((tau_2 * x) + tau_1) * x;
|
let mut tau_x = ((tau_2 * x) + tau_1) * x;
|
||||||
{
|
{
|
||||||
let mut z_current = z * z;
|
for (i, commitment) in witness.commitments.iter().enumerate() {
|
||||||
for commitment in &witness.commitments {
|
tau_x += z[2 + i] * commitment.mask;
|
||||||
tau_x += z_current * commitment.mask;
|
|
||||||
z_current *= z;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mu = alpha + (rho * x);
|
let mu = alpha + (rho * x);
|
||||||
@@ -268,6 +265,7 @@ impl<'a> AggregateRangeStatement<'a> {
|
|||||||
|
|
||||||
let (y, z) = Self::transcript_A_S(transcript, proof.A, proof.S);
|
let (y, z) = Self::transcript_A_S(transcript, proof.A, proof.S);
|
||||||
transcript = z;
|
transcript = z;
|
||||||
|
let z = ScalarVector::powers(z, 3 + padded_pow_of_2);
|
||||||
transcript = Self::transcript_T12(transcript, proof.T1, proof.T2);
|
transcript = Self::transcript_T12(transcript, proof.T1, proof.T2);
|
||||||
let x = transcript;
|
let x = transcript;
|
||||||
transcript = Self::transcript_tau_x_mu_t_hat(transcript, proof.tau_x, proof.mu, proof.t_hat);
|
transcript = Self::transcript_tau_x_mu_t_hat(transcript, proof.tau_x, proof.mu, proof.t_hat);
|
||||||
@@ -293,18 +291,14 @@ impl<'a> AggregateRangeStatement<'a> {
|
|||||||
// These will now sum to 0 if equal
|
// These will now sum to 0 if equal
|
||||||
let weight = -weight;
|
let weight = -weight;
|
||||||
|
|
||||||
verifier.0.h += weight * (z - (z * z)) * y_pow_n.sum();
|
verifier.0.h += weight * (z[1] - (z[2])) * y_pow_n.sum();
|
||||||
|
|
||||||
let mut z_current = z * z;
|
for (i, commitment) in commitments.iter().enumerate() {
|
||||||
for commitment in &commitments {
|
verifier.0.other.push((weight * z[2 + i], *commitment));
|
||||||
verifier.0.other.push((weight * z_current, *commitment));
|
|
||||||
z_current *= z;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut z_current = z * z * z;
|
for i in 0 .. padded_pow_of_2 {
|
||||||
for _ in 0 .. padded_pow_of_2 {
|
verifier.0.h -= weight * z[3 + i] * twos.clone().sum();
|
||||||
verifier.0.h -= weight * z_current * twos.clone().sum();
|
|
||||||
z_current *= z;
|
|
||||||
}
|
}
|
||||||
verifier.0.other.push((weight * x, proof.T1));
|
verifier.0.other.push((weight * x, proof.T1));
|
||||||
verifier.0.other.push((weight * (x * x), proof.T2));
|
verifier.0.other.push((weight * (x * x), proof.T2));
|
||||||
@@ -315,22 +309,23 @@ impl<'a> AggregateRangeStatement<'a> {
|
|||||||
// 66
|
// 66
|
||||||
verifier.0.other.push((ip_weight, proof.A));
|
verifier.0.other.push((ip_weight, proof.A));
|
||||||
verifier.0.other.push((ip_weight * x, proof.S));
|
verifier.0.other.push((ip_weight * x, proof.S));
|
||||||
// TODO: g_sum
|
// We can replace these with a g_sum, h_sum scalar in the batch verifier
|
||||||
|
// It'd trade `2 * ip_rows` scalar additions (per proof) for one scalar addition and an
|
||||||
|
// additional term in the MSM
|
||||||
|
let ip_z = ip_weight * z[1];
|
||||||
for i in 0 .. ip_rows {
|
for i in 0 .. ip_rows {
|
||||||
verifier.0.g_bold[i] += ip_weight * -z;
|
verifier.0.h_bold[i] += ip_z;
|
||||||
}
|
}
|
||||||
// TODO: h_sum
|
let neg_ip_z = -ip_z;
|
||||||
for i in 0 .. ip_rows {
|
for i in 0 .. ip_rows {
|
||||||
verifier.0.h_bold[i] += ip_weight * z;
|
verifier.0.g_bold[i] += neg_ip_z;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let mut z_current = z * z;
|
|
||||||
for j in 0 .. padded_pow_of_2 {
|
for j in 0 .. padded_pow_of_2 {
|
||||||
for i in 0 .. COMMITMENT_BITS {
|
for i in 0 .. COMMITMENT_BITS {
|
||||||
let full_i = (j * COMMITMENT_BITS) + i;
|
let full_i = (j * COMMITMENT_BITS) + i;
|
||||||
verifier.0.h_bold[full_i] += ip_weight * y_inv_pow_n[full_i] * z_current * twos[i];
|
verifier.0.h_bold[full_i] += ip_weight * y_inv_pow_n[full_i] * z[2 + j] * twos[i];
|
||||||
}
|
}
|
||||||
z_current *= z;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
verifier.0.h += ip_weight * x_ip * proof.t_hat;
|
verifier.0.h += ip_weight * x_ip * proof.t_hat;
|
||||||
|
|||||||
@@ -166,6 +166,17 @@ impl WipStatement {
|
|||||||
let mut g_bold = PointVector(g_bold);
|
let mut g_bold = PointVector(g_bold);
|
||||||
let mut h_bold = PointVector(h_bold);
|
let mut h_bold = PointVector(h_bold);
|
||||||
|
|
||||||
|
let mut y_inv = {
|
||||||
|
let mut i = 1;
|
||||||
|
let mut to_invert = vec![];
|
||||||
|
while i < g_bold.len() {
|
||||||
|
to_invert.push(y[i - 1]);
|
||||||
|
i *= 2;
|
||||||
|
}
|
||||||
|
Scalar::batch_invert(&mut to_invert);
|
||||||
|
to_invert
|
||||||
|
};
|
||||||
|
|
||||||
// Check P has the expected relationship
|
// Check P has the expected relationship
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
{
|
{
|
||||||
@@ -219,8 +230,7 @@ impl WipStatement {
|
|||||||
let c_l = a1.clone().weighted_inner_product(&b2, &y);
|
let c_l = a1.clone().weighted_inner_product(&b2, &y);
|
||||||
let c_r = (a2.clone() * y_n_hat).weighted_inner_product(&b1, &y);
|
let c_r = (a2.clone() * y_n_hat).weighted_inner_product(&b1, &y);
|
||||||
|
|
||||||
// TODO: Calculate these with a batch inversion
|
let y_inv_n_hat = y_inv.pop().unwrap();
|
||||||
let y_inv_n_hat = y_n_hat.invert();
|
|
||||||
|
|
||||||
let mut L_terms = (a1.clone() * y_inv_n_hat)
|
let mut L_terms = (a1.clone() * y_inv_n_hat)
|
||||||
.0
|
.0
|
||||||
|
|||||||
@@ -102,3 +102,26 @@ async fn test_decoy_rpc() {
|
|||||||
rpc.get_output_distribution(1 .. 0).await.unwrap_err();
|
rpc.get_output_distribution(1 .. 0).await.unwrap_err();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This test passes yet requires a mainnet node, which we don't have reliable access to in CI.
|
||||||
|
/*
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_zero_out_tx_o_indexes() {
|
||||||
|
use monero_rpc::Rpc;
|
||||||
|
|
||||||
|
let rpc = SimpleRequestRpc::new("https://node.sethforprivacy.com".to_string()).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rpc
|
||||||
|
.get_o_indexes(
|
||||||
|
hex::decode("17ce4c8feeb82a6d6adaa8a89724b32bf4456f6909c7f84c8ce3ee9ebba19163")
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap()
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Vec::<u64>::new()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use zeroize::Zeroize;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use curve25519_dalek::edwards::EdwardsPoint;
|
use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint};
|
||||||
|
|
||||||
use serde::{Serialize, Deserialize, de::DeserializeOwned};
|
use serde::{Serialize, Deserialize, de::DeserializeOwned};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -28,6 +28,7 @@ use monero_serai::{
|
|||||||
io::*,
|
io::*,
|
||||||
transaction::{Input, Timelock, Transaction},
|
transaction::{Input, Timelock, Transaction},
|
||||||
block::Block,
|
block::Block,
|
||||||
|
DEFAULT_LOCK_WINDOW,
|
||||||
};
|
};
|
||||||
use monero_address::Address;
|
use monero_address::Address;
|
||||||
|
|
||||||
@@ -188,19 +189,24 @@ struct TransactionsResponse {
|
|||||||
txs: Vec<TransactionResponse>,
|
txs: Vec<TransactionResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The response to an output query.
|
/// The response to an query for the information of a RingCT output.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub struct OutputResponse {
|
pub struct OutputInformation {
|
||||||
/// The height of the block this output was added to the chain in.
|
/// The block number of the block this output was added to the chain in.
|
||||||
|
///
|
||||||
|
/// This is equivalent to he height of the blockchain at the time the block was added.
|
||||||
pub height: usize,
|
pub height: usize,
|
||||||
/// If the output is unlocked, per the node's local view.
|
/// If the output is unlocked, per the node's local view.
|
||||||
pub unlocked: bool,
|
pub unlocked: bool,
|
||||||
/// The output's key.
|
/// The output's key.
|
||||||
pub key: String,
|
///
|
||||||
|
/// This is a CompressedEdwardsY, not an EdwardsPoint, as it may be invalid. CompressedEdwardsY
|
||||||
|
/// only asserts validity on decompression and allows representing compressed types.
|
||||||
|
pub key: CompressedEdwardsY,
|
||||||
/// The output's commitment.
|
/// The output's commitment.
|
||||||
pub mask: String,
|
pub commitment: EdwardsPoint,
|
||||||
/// The transaction which created this output.
|
/// The transaction which created this output.
|
||||||
pub txid: String,
|
pub transaction: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rpc_hex(value: &str) -> Result<Vec<u8>, RpcError> {
|
fn rpc_hex(value: &str) -> Result<Vec<u8>, RpcError> {
|
||||||
@@ -497,7 +503,6 @@ pub trait Rpc: Sync + Clone + Debug {
|
|||||||
/// This may be manipulated to unsafe levels and MUST be sanity checked.
|
/// This may be manipulated to unsafe levels and MUST be sanity checked.
|
||||||
///
|
///
|
||||||
/// This MUST NOT be expected to be deterministic in any way.
|
/// This MUST NOT be expected to be deterministic in any way.
|
||||||
// TODO: Take a sanity check argument
|
|
||||||
async fn get_fee_rate(&self, priority: FeePriority) -> Result<FeeRate, RpcError> {
|
async fn get_fee_rate(&self, priority: FeePriority) -> Result<FeeRate, RpcError> {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct FeeResponse {
|
struct FeeResponse {
|
||||||
@@ -788,7 +793,6 @@ pub trait Rpc: Sync + Clone + Debug {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the Vec was empty, it would've been omitted, hence the unwrap_or
|
// If the Vec was empty, it would've been omitted, hence the unwrap_or
|
||||||
// TODO: Test against a 0-output TX, such as the ones found in block 202612
|
|
||||||
Ok(res.unwrap_or(vec![]))
|
Ok(res.unwrap_or(vec![]))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -821,7 +825,7 @@ pub trait DecoyRpc: Sync + Clone + Debug {
|
|||||||
) -> Result<Vec<u64>, RpcError>;
|
) -> Result<Vec<u64>, RpcError>;
|
||||||
|
|
||||||
/// Get the specified outputs from the RingCT (zero-amount) pool.
|
/// Get the specified outputs from the RingCT (zero-amount) pool.
|
||||||
async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputResponse>, RpcError>;
|
async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputInformation>, RpcError>;
|
||||||
|
|
||||||
/// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their
|
/// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their
|
||||||
/// timelock has been satisfied.
|
/// timelock has been satisfied.
|
||||||
@@ -829,16 +833,16 @@ pub trait DecoyRpc: Sync + Clone + Debug {
|
|||||||
/// The timelock being satisfied is distinct from being free of the 10-block lock applied to all
|
/// The timelock being satisfied is distinct from being free of the 10-block lock applied to all
|
||||||
/// Monero transactions.
|
/// Monero transactions.
|
||||||
///
|
///
|
||||||
/// The node is trusted for if the output is unlocked unless `fingerprintable_canonical` is set
|
/// The node is trusted for if the output is unlocked unless `fingerprintable_deterministic` is
|
||||||
/// to true. If `fingerprintable_canonical` is set to true, the node's local view isn't used, yet
|
/// set to true. If `fingerprintable_deterministic` is set to true, the node's local view isn't
|
||||||
/// the transaction's timelock is checked to be unlocked at the specified `height`. This offers a
|
/// used, yet the transaction's timelock is checked to be unlocked at the specified `height`.
|
||||||
/// canonical decoy selection, yet is fingerprintable as time-based timelocks aren't evaluated
|
/// This offers a deterministic decoy selection, yet is fingerprintable as time-based timelocks
|
||||||
/// (and considered locked, preventing their selection).
|
/// aren't evaluated (and considered locked, preventing their selection).
|
||||||
async fn get_unlocked_outputs(
|
async fn get_unlocked_outputs(
|
||||||
&self,
|
&self,
|
||||||
indexes: &[u64],
|
indexes: &[u64],
|
||||||
height: usize,
|
height: usize,
|
||||||
fingerprintable_canonical: bool,
|
fingerprintable_deterministic: bool,
|
||||||
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError>;
|
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +945,16 @@ impl<R: Rpc> DecoyRpc for R {
|
|||||||
Ok(distribution)
|
Ok(distribution)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputResponse>, RpcError> {
|
async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputInformation>, RpcError> {
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct OutputResponse {
|
||||||
|
height: usize,
|
||||||
|
unlocked: bool,
|
||||||
|
key: String,
|
||||||
|
mask: String,
|
||||||
|
txid: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct OutsResponse {
|
struct OutsResponse {
|
||||||
status: String,
|
status: String,
|
||||||
@@ -965,24 +978,38 @@ impl<R: Rpc> DecoyRpc for R {
|
|||||||
Err(RpcError::InvalidNode("bad response to get_outs".to_string()))?;
|
Err(RpcError::InvalidNode("bad response to get_outs".to_string()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(res.outs)
|
Ok(
|
||||||
|
res
|
||||||
|
.outs
|
||||||
|
.into_iter()
|
||||||
|
.map(|output| {
|
||||||
|
Ok(OutputInformation {
|
||||||
|
height: output.height,
|
||||||
|
unlocked: output.unlocked,
|
||||||
|
key: CompressedEdwardsY(
|
||||||
|
rpc_hex(&output.key)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| RpcError::InvalidNode("output key wasn't 32 bytes".to_string()))?,
|
||||||
|
),
|
||||||
|
commitment: rpc_point(&output.mask)?,
|
||||||
|
transaction: hash_hex(&output.txid)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, RpcError>>()?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_unlocked_outputs(
|
async fn get_unlocked_outputs(
|
||||||
&self,
|
&self,
|
||||||
indexes: &[u64],
|
indexes: &[u64],
|
||||||
height: usize,
|
height: usize,
|
||||||
fingerprintable_canonical: bool,
|
fingerprintable_deterministic: bool,
|
||||||
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError> {
|
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError> {
|
||||||
let outs: Vec<OutputResponse> = self.get_outs(indexes).await?;
|
let outs = self.get_outs(indexes).await?;
|
||||||
|
|
||||||
// Only need to fetch txs to do canonical check on timelock
|
// Only need to fetch txs to do deterministic check on timelock
|
||||||
let txs = if fingerprintable_canonical {
|
let txs = if fingerprintable_deterministic {
|
||||||
self
|
self.get_transactions(&outs.iter().map(|out| out.transaction).collect::<Vec<_>>()).await?
|
||||||
.get_transactions(
|
|
||||||
&outs.iter().map(|out| hash_hex(&out.txid)).collect::<Result<Vec<_>, _>>()?,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
@@ -996,19 +1023,20 @@ impl<R: Rpc> DecoyRpc for R {
|
|||||||
// decoy
|
// decoy
|
||||||
// Only valid keys can be used in CLSAG proofs, hence the need for re-selection, yet
|
// Only valid keys can be used in CLSAG proofs, hence the need for re-selection, yet
|
||||||
// invalid keys may honestly exist on the blockchain
|
// invalid keys may honestly exist on the blockchain
|
||||||
// Only a recent hard fork checked output keys were valid points
|
let Some(key) = out.key.decompress() else {
|
||||||
let Some(key) = decompress_point(
|
|
||||||
rpc_hex(&out.key)?
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| RpcError::InvalidNode("non-32-byte point".to_string()))?,
|
|
||||||
) else {
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(Some([key, rpc_point(&out.mask)?]).filter(|_| {
|
Ok(Some([key, out.commitment]).filter(|_| {
|
||||||
if fingerprintable_canonical {
|
if fingerprintable_deterministic {
|
||||||
// TODO: Are timelock blocks by height or number?
|
// https://github.com/monero-project/monero/blob
|
||||||
// TODO: This doesn't check the default timelock has been passed
|
// /cc73fe71162d564ffda8e549b79a350bca53c454/src/cryptonote_core/blockchain.cpp#L90
|
||||||
Timelock::Block(height) >= txs[i].prefix().additional_timelock
|
const ACCEPTED_TIMELOCK_DELTA: usize = 1;
|
||||||
|
|
||||||
|
// https://github.com/monero-project/monero/blob
|
||||||
|
// /cc73fe71162d564ffda8e549b79a350bca53c454/src/cryptonote_core/blockchain.cpp#L3836
|
||||||
|
((out.height + DEFAULT_LOCK_WINDOW) <= height) &&
|
||||||
|
(Timelock::Block(height - 1 + ACCEPTED_TIMELOCK_DELTA) >=
|
||||||
|
txs[i].prefix().additional_timelock)
|
||||||
} else {
|
} else {
|
||||||
out.unlocked
|
out.unlocked
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ impl AddressType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The payment ID within this address.
|
/// The payment ID within this address.
|
||||||
// TODO: wallet-core PaymentId? TX extra crate imported here?
|
|
||||||
pub fn payment_id(&self) -> Option<[u8; 8]> {
|
pub fn payment_id(&self) -> Option<[u8; 8]> {
|
||||||
if let AddressType::LegacyIntegrated(id) = self {
|
if let AddressType::LegacyIntegrated(id) = self {
|
||||||
Some(*id)
|
Some(*id)
|
||||||
@@ -164,15 +163,19 @@ impl AddressBytes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Cite origin
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
||||||
|
// /src/cryptonote_config.h#L216-L225
|
||||||
|
// https://gist.github.com/kayabaNerve/01c50bbc35441e0bbdcee63a9d823789 for featured
|
||||||
const MONERO_MAINNET_BYTES: AddressBytes = match AddressBytes::new(18, 19, 42, 70) {
|
const MONERO_MAINNET_BYTES: AddressBytes = match AddressBytes::new(18, 19, 42, 70) {
|
||||||
Some(bytes) => bytes,
|
Some(bytes) => bytes,
|
||||||
None => panic!("mainnet byte constants conflicted"),
|
None => panic!("mainnet byte constants conflicted"),
|
||||||
};
|
};
|
||||||
|
// https://github.com/monero-project/monero/blob/master/src/cryptonote_config.h#L277-L281
|
||||||
const MONERO_STAGENET_BYTES: AddressBytes = match AddressBytes::new(24, 25, 36, 86) {
|
const MONERO_STAGENET_BYTES: AddressBytes = match AddressBytes::new(24, 25, 36, 86) {
|
||||||
Some(bytes) => bytes,
|
Some(bytes) => bytes,
|
||||||
None => panic!("stagenet byte constants conflicted"),
|
None => panic!("stagenet byte constants conflicted"),
|
||||||
};
|
};
|
||||||
|
// https://github.com/monero-project/monero/blob/master/src/cryptonote_config.h#L262-L266
|
||||||
const MONERO_TESTNET_BYTES: AddressBytes = match AddressBytes::new(53, 54, 63, 111) {
|
const MONERO_TESTNET_BYTES: AddressBytes = match AddressBytes::new(53, 54, 63, 111) {
|
||||||
Some(bytes) => bytes,
|
Some(bytes) => bytes,
|
||||||
None => panic!("testnet byte constants conflicted"),
|
None => panic!("testnet byte constants conflicted"),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async fn select_n(
|
|||||||
height: usize,
|
height: usize,
|
||||||
real_output: u64,
|
real_output: u64,
|
||||||
ring_len: usize,
|
ring_len: usize,
|
||||||
fingerprintable_canonical: bool,
|
fingerprintable_deterministic: bool,
|
||||||
) -> Result<Vec<(u64, [EdwardsPoint; 2])>, RpcError> {
|
) -> Result<Vec<(u64, [EdwardsPoint; 2])>, RpcError> {
|
||||||
if height < DEFAULT_LOCK_WINDOW {
|
if height < DEFAULT_LOCK_WINDOW {
|
||||||
Err(RpcError::InternalError("not enough blocks to select decoys".to_string()))?;
|
Err(RpcError::InternalError("not enough blocks to select decoys".to_string()))?;
|
||||||
@@ -141,7 +141,7 @@ async fn select_n(
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (i, output) in rpc
|
for (i, output) in rpc
|
||||||
.get_unlocked_outputs(&candidates, height, fingerprintable_canonical)
|
.get_unlocked_outputs(&candidates, height, fingerprintable_deterministic)
|
||||||
.await?
|
.await?
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -172,8 +172,7 @@ async fn select_decoys<R: RngCore + CryptoRng>(
|
|||||||
ring_len: usize,
|
ring_len: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
input: &WalletOutput,
|
input: &WalletOutput,
|
||||||
// TODO: Decide "canonical" or "deterministic" (updating RPC terminology accordingly)
|
fingerprintable_deterministic: bool,
|
||||||
fingerprintable_canonical: bool,
|
|
||||||
) -> Result<Decoys, RpcError> {
|
) -> Result<Decoys, RpcError> {
|
||||||
// Select all decoys for this transaction, assuming we generate a sane transaction
|
// Select all decoys for this transaction, assuming we generate a sane transaction
|
||||||
// We should almost never naturally generate an insane transaction, hence why this doesn't
|
// We should almost never naturally generate an insane transaction, hence why this doesn't
|
||||||
@@ -184,7 +183,7 @@ async fn select_decoys<R: RngCore + CryptoRng>(
|
|||||||
height,
|
height,
|
||||||
input.relative_id.index_on_blockchain,
|
input.relative_id.index_on_blockchain,
|
||||||
ring_len,
|
ring_len,
|
||||||
fingerprintable_canonical,
|
fingerprintable_deterministic,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,10 @@ impl Extra {
|
|||||||
///
|
///
|
||||||
/// This returns all keys specified with `PublicKey` and the first set of keys specified with
|
/// This returns all keys specified with `PublicKey` and the first set of keys specified with
|
||||||
/// `PublicKeys`, so long as they're well-formed.
|
/// `PublicKeys`, so long as they're well-formed.
|
||||||
// TODO: Cite this
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c45
|
||||||
|
// /src/wallet/wallet2.cpp#L2290-L2300
|
||||||
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
||||||
|
// /src/wallet/wallet2.cpp#L2337-L2340
|
||||||
pub fn keys(&self) -> Option<(Vec<EdwardsPoint>, Option<Vec<EdwardsPoint>>)> {
|
pub fn keys(&self) -> Option<(Vec<EdwardsPoint>, Option<Vec<EdwardsPoint>>)> {
|
||||||
let mut keys = vec![];
|
let mut keys = vec![];
|
||||||
let mut additional = None;
|
let mut additional = None;
|
||||||
@@ -255,18 +258,24 @@ impl Extra {
|
|||||||
|
|
||||||
pub(crate) fn new(key: EdwardsPoint, additional: Vec<EdwardsPoint>) -> Extra {
|
pub(crate) fn new(key: EdwardsPoint, additional: Vec<EdwardsPoint>) -> Extra {
|
||||||
let mut res = Extra(Vec::with_capacity(3));
|
let mut res = Extra(Vec::with_capacity(3));
|
||||||
res.push(ExtraField::PublicKey(key));
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
||||||
|
// /src/cryptonote_basic/cryptonote_format_utils.cpp#L627-L633
|
||||||
|
// We only support pushing nonces which come after these in the sort order
|
||||||
|
res.0.push(ExtraField::PublicKey(key));
|
||||||
if !additional.is_empty() {
|
if !additional.is_empty() {
|
||||||
res.push(ExtraField::PublicKeys(additional));
|
res.0.push(ExtraField::PublicKeys(additional));
|
||||||
}
|
}
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn push(&mut self, field: ExtraField) {
|
pub(crate) fn push_nonce(&mut self, nonce: Vec<u8>) {
|
||||||
self.0.push(field);
|
self.0.push(ExtraField::Nonce(nonce));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the Extra.
|
/// Write the Extra.
|
||||||
|
///
|
||||||
|
/// This is not of deterministic length nor length-prefixed. It should only be written to a
|
||||||
|
/// buffer which will be delimited.
|
||||||
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
|
||||||
for field in &self.0 {
|
for field in &self.0 {
|
||||||
field.write(w)?;
|
field.write(w)?;
|
||||||
@@ -281,17 +290,19 @@ impl Extra {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Is this supposed to silently drop trailing gibberish?
|
|
||||||
/// Read an `Extra`.
|
/// Read an `Extra`.
|
||||||
|
///
|
||||||
|
/// This is not of deterministic length nor length-prefixed. It should only be read from a buffer
|
||||||
|
/// already delimited.
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
pub fn read<R: BufRead>(r: &mut R) -> io::Result<Extra> {
|
pub fn read<R: BufRead>(r: &mut R) -> io::Result<Extra> {
|
||||||
let mut res = Extra(vec![]);
|
let mut res = Extra(vec![]);
|
||||||
let mut field;
|
// Extra reads until EOF
|
||||||
while {
|
// We take a BufRead so we can detect when the buffer is empty
|
||||||
field = ExtraField::read(r);
|
// `fill_buf` returns the current buffer, filled if empty, only empty if the reader is
|
||||||
field.is_ok()
|
// exhausted
|
||||||
} {
|
while !r.fill_buf()?.is_empty() {
|
||||||
res.0.push(field.unwrap());
|
res.0.push(ExtraField::read(r)?);
|
||||||
}
|
}
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ impl SharedKeyDerivations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// H(8Ra || 0x8d)
|
// H(8Ra || 0x8d)
|
||||||
// TODO: Make this itself a PaymentId
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn payment_id_xor(ecdh: Zeroizing<EdwardsPoint>) -> [u8; 8] {
|
fn payment_id_xor(ecdh: Zeroizing<EdwardsPoint>) -> [u8; 8] {
|
||||||
// 8Ra
|
// 8Ra
|
||||||
|
|||||||
@@ -135,9 +135,8 @@ impl InternalScanner {
|
|||||||
// This will be None if there's no additional keys, Some(None) if there's additional keys
|
// This will be None if there's no additional keys, Some(None) if there's additional keys
|
||||||
// yet not one for this output (which is non-standard), and Some(Some(_)) if there's an
|
// yet not one for this output (which is non-standard), and Some(Some(_)) if there's an
|
||||||
// additional key for this output
|
// additional key for this output
|
||||||
// https://github.com/monero-project/monero/
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
||||||
// blob/04a1e2875d6e35e27bb21497988a6c822d319c28/
|
// /src/cryptonote_basic/cryptonote_format_utils.cpp#L1060-L1070
|
||||||
// src/cryptonote_basic/cryptonote_format_utils.cpp#L1062
|
|
||||||
let additional = additional.as_ref().map(|additional| additional.get(o));
|
let additional = additional.as_ref().map(|additional| additional.get(o));
|
||||||
|
|
||||||
#[allow(clippy::manual_let_else)]
|
#[allow(clippy::manual_let_else)]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
RctProofs,
|
RctProofs,
|
||||||
},
|
},
|
||||||
transaction::{Input, Output, Timelock, TransactionPrefix, Transaction},
|
transaction::{Input, Output, Timelock, TransactionPrefix, Transaction},
|
||||||
extra::{ARBITRARY_DATA_MARKER, PaymentId, ExtraField, Extra},
|
extra::{ARBITRARY_DATA_MARKER, PaymentId, Extra},
|
||||||
send::{InternalPayment, SignableTransaction, SignableTransactionWithKeyImages},
|
send::{InternalPayment, SignableTransaction, SignableTransactionWithKeyImages},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ impl SignableTransaction {
|
|||||||
let id = (u64::from_le_bytes(id) ^ u64::from_le_bytes(*id_xor)).to_le_bytes();
|
let id = (u64::from_le_bytes(id) ^ u64::from_le_bytes(*id_xor)).to_le_bytes();
|
||||||
let mut id_vec = Vec::with_capacity(1 + 8);
|
let mut id_vec = Vec::with_capacity(1 + 8);
|
||||||
PaymentId::Encrypted(id).write(&mut id_vec).unwrap();
|
PaymentId::Encrypted(id).write(&mut id_vec).unwrap();
|
||||||
extra.push(ExtraField::Nonce(id_vec));
|
extra.push_nonce(id_vec);
|
||||||
} else {
|
} else {
|
||||||
// If there's no payment ID, we push a dummy (as wallet2 does) if there's only one payment
|
// If there's no payment ID, we push a dummy (as wallet2 does) if there's only one payment
|
||||||
if (self.payments.len() == 2) &&
|
if (self.payments.len() == 2) &&
|
||||||
@@ -89,7 +89,7 @@ impl SignableTransaction {
|
|||||||
let mut id_vec = Vec::with_capacity(1 + 8);
|
let mut id_vec = Vec::with_capacity(1 + 8);
|
||||||
// The dummy payment ID is [0; 8], which when xor'd with the mask, is just the mask
|
// The dummy payment ID is [0; 8], which when xor'd with the mask, is just the mask
|
||||||
PaymentId::Encrypted(*payment_id_xor).write(&mut id_vec).unwrap();
|
PaymentId::Encrypted(*payment_id_xor).write(&mut id_vec).unwrap();
|
||||||
extra.push(ExtraField::Nonce(id_vec));
|
extra.push_nonce(id_vec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ impl SignableTransaction {
|
|||||||
for part in &self.data {
|
for part in &self.data {
|
||||||
let mut arb = vec![ARBITRARY_DATA_MARKER];
|
let mut arb = vec![ARBITRARY_DATA_MARKER];
|
||||||
arb.extend(part);
|
arb.extend(part);
|
||||||
extra.push(ExtraField::Nonce(arb));
|
extra.push_nonce(arb);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut serialized = Vec::with_capacity(32 * amount_of_keys);
|
let mut serialized = Vec::with_capacity(32 * amount_of_keys);
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ impl SignableTransaction {
|
|||||||
let mut additional_keys_pub = vec![];
|
let mut additional_keys_pub = vec![];
|
||||||
for (additional_key, payment) in additional_keys.into_iter().zip(&self.payments) {
|
for (additional_key, payment) in additional_keys.into_iter().zip(&self.payments) {
|
||||||
let addr = payment.address();
|
let addr = payment.address();
|
||||||
// TODO: Double check this against wallet2
|
// https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
|
||||||
|
// /src/device/device_default.cpp#L308-L312
|
||||||
if addr.is_subaddress() {
|
if addr.is_subaddress() {
|
||||||
additional_keys_pub.push(additional_key.deref() * addr.spend());
|
additional_keys_pub.push(additional_key.deref() * addr.spend());
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -105,15 +105,13 @@ fn padding_only_max_size() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn padding_only_exceed_max_size() {
|
fn padding_only_exceed_max_size() {
|
||||||
let buf: Vec<u8> = vec![0; MAX_TX_EXTRA_PADDING_COUNT + 1];
|
let buf: Vec<u8> = vec![0; MAX_TX_EXTRA_PADDING_COUNT + 1];
|
||||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap_err();
|
||||||
assert!(extra.0.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_padding_only() {
|
fn invalid_padding_only() {
|
||||||
let buf: Vec<u8> = vec![0, 42];
|
let buf: Vec<u8> = vec![0, 42];
|
||||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap_err();
|
||||||
assert!(extra.0.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -137,8 +135,7 @@ fn extra_nonce_only_wrong_size() {
|
|||||||
let mut buf: Vec<u8> = vec![0; 20];
|
let mut buf: Vec<u8> = vec![0; 20];
|
||||||
buf[0] = 2;
|
buf[0] = 2;
|
||||||
buf[1] = 255;
|
buf[1] = 255;
|
||||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap_err();
|
||||||
assert!(extra.0.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -158,8 +155,7 @@ fn pub_key_and_padding() {
|
|||||||
fn pub_key_and_invalid_padding() {
|
fn pub_key_and_invalid_padding() {
|
||||||
let mut buf: Vec<u8> = PUB_KEY_BYTES.to_vec();
|
let mut buf: Vec<u8> = PUB_KEY_BYTES.to_vec();
|
||||||
buf.extend([0, 1]);
|
buf.extend([0, 1]);
|
||||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap_err();
|
||||||
assert_eq!(extra.0, vec![ExtraField::PublicKey(pub_key())]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -185,8 +181,7 @@ fn extra_mysterious_minergate_only_wrong_size() {
|
|||||||
let mut buf: Vec<u8> = vec![0; 20];
|
let mut buf: Vec<u8> = vec![0; 20];
|
||||||
buf[0] = 222;
|
buf[0] = 222;
|
||||||
buf[1] = 255;
|
buf[1] = 255;
|
||||||
let extra = Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap();
|
Extra::read::<&[u8]>(&mut buf.as_ref()).unwrap_err();
|
||||||
assert!(extra.0.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use monero_simple_request_rpc::SimpleRequestRpc;
|
|||||||
use monero_wallet::{
|
use monero_wallet::{
|
||||||
DEFAULT_LOCK_WINDOW,
|
DEFAULT_LOCK_WINDOW,
|
||||||
transaction::Transaction,
|
transaction::Transaction,
|
||||||
rpc::{OutputResponse, Rpc, DecoyRpc},
|
rpc::{Rpc, DecoyRpc},
|
||||||
WalletOutput,
|
WalletOutput,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,8 +54,7 @@ test!(
|
|||||||
let most_recent_o_index = rpc.get_o_indexes(tx.hash()).await.unwrap().pop().unwrap();
|
let most_recent_o_index = rpc.get_o_indexes(tx.hash()).await.unwrap().pop().unwrap();
|
||||||
|
|
||||||
// Make sure output from tx1 is in the block in which it unlocks
|
// Make sure output from tx1 is in the block in which it unlocks
|
||||||
let out_tx1: OutputResponse =
|
let out_tx1 = rpc.get_outs(&[most_recent_o_index]).await.unwrap().swap_remove(0);
|
||||||
rpc.get_outs(&[most_recent_o_index]).await.unwrap().swap_remove(0);
|
|
||||||
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
|
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
|
||||||
assert!(out_tx1.unlocked);
|
assert!(out_tx1.unlocked);
|
||||||
|
|
||||||
@@ -133,8 +132,7 @@ test!(
|
|||||||
let most_recent_o_index = rpc.get_o_indexes(tx.hash()).await.unwrap().pop().unwrap();
|
let most_recent_o_index = rpc.get_o_indexes(tx.hash()).await.unwrap().pop().unwrap();
|
||||||
|
|
||||||
// Make sure output from tx1 is in the block in which it unlocks
|
// Make sure output from tx1 is in the block in which it unlocks
|
||||||
let out_tx1: OutputResponse =
|
let out_tx1 = rpc.get_outs(&[most_recent_o_index]).await.unwrap().swap_remove(0);
|
||||||
rpc.get_outs(&[most_recent_o_index]).await.unwrap().swap_remove(0);
|
|
||||||
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
|
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
|
||||||
assert!(out_tx1.unlocked);
|
assert!(out_tx1.unlocked);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub fn bitcoin(orchestration_path: &Path, network: Network) {
|
|||||||
const DOWNLOAD_BITCOIN: &str = r#"
|
const DOWNLOAD_BITCOIN: &str = r#"
|
||||||
FROM alpine:latest as bitcoin
|
FROM alpine:latest as bitcoin
|
||||||
|
|
||||||
ENV BITCOIN_VERSION=27.0
|
ENV BITCOIN_VERSION=27.1
|
||||||
|
|
||||||
RUN apk --no-cache add git gnupg
|
RUN apk --no-cache add git gnupg
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user