Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions integration-tests/tests/translator_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ async fn translate_sv1_to_sv2_successfully() {
shutdown_all!(translator, pool);
}

/// Checks that tProxy mines when payout verification passes for address and donation identities.
/// Checks that tProxy mines when payout verification passes for legacy address and donation
/// identities.
#[tokio::test]
async fn translator_mines_when_payout_matches_address_or_donation_identity() {
start_tracing();
Expand All @@ -111,12 +112,18 @@ async fn translator_mines_when_payout_matches_address_or_donation_identity() {
.unwrap()
.script_pubkey();

let mut solo_coinbase_tx_suffix = hex::decode("feffffff").unwrap();
solo_coinbase_tx_suffix.extend(serialize(&vec![TxOut {
value: Amount::from_sat(5_000_000_000),
script_pubkey: miner_script_pubkey.clone(),
}]));
solo_coinbase_tx_suffix.extend([0, 0, 0, 0]);
let mut legacy_solo_coinbase_tx_suffix = hex::decode("feffffff").unwrap();
legacy_solo_coinbase_tx_suffix.extend(serialize(&vec![
TxOut {
value: Amount::from_sat(4_955_000_000),
script_pubkey: miner_script_pubkey.clone(),
},
TxOut {
value: Amount::from_sat(45_000_000),
script_pubkey: pool_script_pubkey.clone(),
},
]));
legacy_solo_coinbase_tx_suffix.extend([0, 0, 0, 0]);

let mut partial_donation_coinbase_tx_suffix = hex::decode("feffffff").unwrap();
partial_donation_coinbase_tx_suffix.extend(serialize(&vec![
Expand All @@ -135,7 +142,7 @@ async fn translator_mines_when_payout_matches_address_or_donation_identity() {
(
"payout-address",
PAYOUT_VERIFICATION_MINER_ADDRESS.to_string(),
solo_coinbase_tx_suffix,
legacy_solo_coinbase_tx_suffix,
),
(
"payout-donation",
Expand Down
2 changes: 1 addition & 1 deletion miner-apps/translator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Payout verification is disabled by default. Set `verify_payout = true` for solo
donation configurations where `user_identity` intentionally encodes an on-chain payout address:

- `sri/solo/<payout_address>/<worker>`: tProxy verifies every upstream extended job pays 100% of spendable coinbase outputs to `<payout_address>`
- `<payout_address>[.worker]`: legacy solo mode, verified as 100% miner payout when `verify_payout = true`
- `<payout_address>[.worker]`: legacy solo mode, verified by checking that at least 90% of spendable coinbase outputs go to `<payout_address>`
- `sri/donate/<pool_percentage>/<payout_address>/<worker>`: tProxy verifies the miner address receives the remaining percentage
- `sri/donate/<worker>`: full donation mode; keep `verify_payout = false` because no miner payout address is present

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ user_identity = "your_primary_username_here"
address = "107.170.42.64"
port = 3333
authority_pubkey = "9awtMD5KQgvRUh2yFbjVeT7b6hjipWcAsQHd6wEhgtDT9soosna"
user_identity = "your_backup_username_here"
user_identity = "your_backup_username_here"
10 changes: 6 additions & 4 deletions miner-apps/translator/src/lib/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,11 @@ impl TranslatorConfig {
}

match PayoutMode::try_from(user_identity) {
Ok(payout_mode @ (PayoutMode::Solo { .. } | PayoutMode::Donate { .. })) => {
Ok(Some(payout_mode))
}
Ok(
payout_mode @ (PayoutMode::Solo { .. }
| PayoutMode::LegacySolo { .. }
| PayoutMode::Donate { .. }),
) => Ok(Some(payout_mode)),
Ok(PayoutMode::FullDonation) => Err(PayoutModeError::MissingMinerPayout {
user_identity: user_identity.to_string(),
mode: MissingMinerPayoutMode::FullDonation,
Expand Down Expand Up @@ -321,7 +323,7 @@ mod tests {
enabled_config
.expected_payout_distribution(payout_address)
.unwrap(),
Some(PayoutMode::Solo { .. })
Some(PayoutMode::LegacySolo { .. })
));
}

Expand Down
109 changes: 99 additions & 10 deletions stratum-apps/src/payout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ use crate::{
stratum_core::bitcoin::{consensus::Decodable, Amount, ScriptBuf, TxOut},
};

// Legacy solo identities do not encode a fee policy, so allow at most a 10% service fee.
const MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE: u8 = 90;

/// Represents the payout mode encoded by a mining `user_identity`.
///
/// Supported patterns:
/// - `sri/solo/<payout_address>/<worker_name>`: full reward goes to the miner.
/// - `<payout_address>` or `<payout_address>.<worker_name>`: legacy solo mode, full reward goes to
/// the miner.
/// - `<payout_address>` or `<payout_address>.<worker_name>`: legacy solo mode; payout verification
/// checks that the miner address receives at least 90% of spendable coinbase outputs.
/// - `sri/donate/<percentage>/<payout_address>/<worker_name>`: pool receives `percentage`, miner
/// receives the remainder.
/// - `sri/donate/<worker_name>`: full reward goes to the pool.
Expand All @@ -31,6 +34,13 @@ pub enum PayoutMode {
/// Miner payout script.
script: CoinbaseRewardScript,
},
/// Legacy solo mode: miner payout address must receive at least 90% of spendable coinbase outputs.
LegacySolo {
/// Miner payout address as supplied in `user_identity`.
address: String,
/// Miner payout script.
script: CoinbaseRewardScript,
},
/// Donate mode: pool receives specified percentage, miner gets remainder.
Donate {
/// Pool's portion, from 1 to 99.
Expand All @@ -55,6 +65,10 @@ impl PayoutMode {
Self::Solo {
script: coinbase_script,
..
}
| Self::LegacySolo {
script: coinbase_script,
..
} => {
vec![TxOut {
value: Amount::from_sat(total_value),
Expand Down Expand Up @@ -118,6 +132,24 @@ impl PayoutMode {
.filter(|output| output.script_pubkey.as_bytes() == script_pubkey.as_bytes())
.map(|output| output.value.to_sat())
.sum();
if matches!(self, Self::LegacySolo { .. }) {
let expected_miner_sats = self.expected_legacy_solo_miner_sats(total_spendable_sats);
if actual_miner_sats < expected_miner_sats {
return Err(PayoutValidationError::PayoutMismatch {
address: self
.miner_address()
.expect("miner script exists only when miner address exists")
.to_string(),
expected_sats: expected_miner_sats,
expected_percentage: MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE,
total_spendable_sats,
actual_sats: actual_miner_sats,
});
}

return Ok(());
}

let expected_miner_sats = self.expected_miner_sats(total_spendable_sats);
if actual_miner_sats != expected_miner_sats {
return Err(PayoutValidationError::PayoutMismatch {
Expand Down Expand Up @@ -155,36 +187,44 @@ impl PayoutMode {

fn miner_address(&self) -> Option<&str> {
match self {
Self::Solo { address, .. } | Self::Donate { address, .. } => Some(address.as_str()),
Self::Solo { address, .. }
| Self::LegacySolo { address, .. }
| Self::Donate { address, .. } => Some(address.as_str()),
Self::FullDonation => None,
}
}

fn miner_script_pubkey(&self) -> Option<ScriptBuf> {
match self {
Self::Solo { script, .. } | Self::Donate { script, .. } => Some(script.script_pubkey()),
Self::Solo { script, .. }
| Self::LegacySolo { script, .. }
| Self::Donate { script, .. } => Some(script.script_pubkey()),
Self::FullDonation => None,
}
}

fn expected_miner_percentage(&self) -> u8 {
match self {
Self::Solo { .. } => 100,
Self::Solo { .. } | Self::LegacySolo { .. } => 100,
Self::Donate { percentage, .. } => 100 - percentage,
Self::FullDonation => 0,
}
}

fn expected_miner_sats(&self, total_spendable_sats: u64) -> u64 {
match self {
Self::Solo { .. } => total_spendable_sats,
Self::Solo { .. } | Self::LegacySolo { .. } => total_spendable_sats,
Self::Donate { percentage, .. } => {
let pool_sats = (total_spendable_sats * *percentage as u64) / 100;
total_spendable_sats.saturating_sub(pool_sats)
}
Self::FullDonation => 0,
}
}

fn expected_legacy_solo_miner_sats(&self, total_spendable_sats: u64) -> u64 {
(total_spendable_sats * MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE as u64).div_ceil(100)
}
}

impl TryFrom<&str> for PayoutMode {
Expand All @@ -198,7 +238,7 @@ impl TryFrom<&str> for PayoutMode {
let addr = address_part_from_user_identity(user_identity);

if let Ok(script) = script_from_address(addr) {
return Ok(Self::Solo {
return Ok(Self::LegacySolo {
address: addr.to_string(),
script,
});
Expand Down Expand Up @@ -251,6 +291,10 @@ impl fmt::Display for PayoutMode {
Self::Solo { address, .. } => {
write!(f, "100% miner payout to {address}")
}
Self::LegacySolo { address, .. } => write!(
f,
"at least {MIN_LEGACY_SOLO_PAYOUT_PERCENTAGE}% miner payout to {address}"
),
Self::Donate {
percentage,
address,
Expand Down Expand Up @@ -449,19 +493,19 @@ mod tests {
));
assert!(matches!(
PayoutMode::try_from(MINER_ADDRESS),
Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
));
}

#[test]
fn parses_legacy_address_identity_with_worker_suffix() {
assert!(matches!(
PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1").as_str()),
Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
));
assert!(matches!(
PayoutMode::try_from(format!("{MINER_ADDRESS}.worker1.subworker").as_str()),
Ok(PayoutMode::Solo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
Ok(PayoutMode::LegacySolo { script, .. }) if Address::from_script(script.script_pubkey().as_script(), MAINNET.clone()).unwrap().to_string() == MINER_ADDRESS
));
}

Expand Down Expand Up @@ -571,6 +615,51 @@ mod tests {
));
}

#[test]
fn validates_legacy_solo_distribution_with_service_fee_output() {
let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
let suffix = coinbase_suffix(vec![tx_out(991, MINER_ADDRESS), tx_out(9, OTHER_ADDRESS)]);

expected.validate_coinbase_tx_suffix(&suffix).unwrap();
}

#[test]
fn rejects_legacy_solo_distribution_below_minimum() {
let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
let suffix = coinbase_suffix(vec![tx_out(899, MINER_ADDRESS), tx_out(101, OTHER_ADDRESS)]);

let err = expected.validate_coinbase_tx_suffix(&suffix).unwrap_err();

assert!(matches!(
err,
PayoutValidationError::PayoutMismatch {
expected_sats: 900,
expected_percentage: 90,
actual_sats: 899,
..
}
));
}

#[test]
fn rejects_legacy_solo_distribution_without_miner_address() {
let expected = PayoutMode::try_from(format!("{MINER_ADDRESS}.w1").as_str()).unwrap();
let suffix = coinbase_suffix(vec![tx_out(1_000, OTHER_ADDRESS)]);

let err = expected.validate_coinbase_tx_suffix(&suffix).unwrap_err();

assert!(matches!(
err,
PayoutValidationError::PayoutMismatch {
expected_sats: 900,
expected_percentage: 90,
total_spendable_sats: 1000,
actual_sats: 0,
..
}
));
}

#[test]
fn validates_partial_donation_distribution() {
let expected =
Expand Down
Loading