Skip to content

Commit

Permalink
Prevent ordinals that are being sent from being spent as fees (ordina…
Browse files Browse the repository at this point in the history
  • Loading branch information
terror authored Aug 30, 2022
1 parent 050c2f1 commit 5f376ea
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 37 deletions.
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use {
FeeRate, KeychainKind, LocalUtxo,
},
bitcoin::{
blockdata::constants::COIN_VALUE,
blockdata::{constants::COIN_VALUE, transaction::TxOut},
consensus::{Decodable, Encodable},
hash_types::BlockHash,
hashes::{sha256, sha256d, Hash, HashEngine},
Expand All @@ -41,7 +41,7 @@ use {
schnorr::Signature,
KeyPair, Secp256k1, XOnlyPublicKey,
},
util::key::PrivateKey,
util::{key::PrivateKey, psbt::PartiallySignedTransaction},
Address, Block, Network, OutPoint, Transaction, Txid,
},
chrono::{DateTime, NaiveDateTime, Utc},
Expand Down
46 changes: 15 additions & 31 deletions src/purse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,47 +69,31 @@ impl Purse {
Ok(Self { blockchain, wallet })
}

pub(crate) fn find(&self, options: &Options, ordinal: Ordinal) -> Result<LocalUtxo> {
let index = Index::index(options)?;

pub(crate) fn find(&self, index: &Index, ordinal: Ordinal) -> Result<LocalUtxo> {
for utxo in self.wallet.list_unspent()? {
match index.list(utxo.outpoint)? {
Some(List::Unspent(ranges)) => {
for (start, end) in ranges {
if ordinal.0 >= start && ordinal.0 < end {
return Ok(utxo);
}
}
}
Some(List::Spent(txid)) => {
return Err(anyhow!(
"UTXO unspent in wallet but spent in index by transaction {txid}"
));
}
None => {
return Err(anyhow!("UTXO unspent in wallet but not found in index"));
for (start, end) in Self::list_unspent(index, utxo.outpoint)? {
if ordinal.0 >= start && ordinal.0 < end {
return Ok(utxo);
}
}
}

bail!("No utxo contains {}˚.", ordinal);
}

pub(crate) fn special_ordinals(
&self,
options: &Options,
outpoint: OutPoint,
) -> Result<Vec<Ordinal>> {
let index = Index::index(options)?;
pub(crate) fn special_ordinals(&self, index: &Index, outpoint: OutPoint) -> Result<Vec<Ordinal>> {
Ok(
Self::list_unspent(index, outpoint)?
.into_iter()
.map(|(start, _end)| Ordinal(start))
.filter(|ordinal| ordinal.rarity() > Rarity::Common)
.collect(),
)
}

pub(crate) fn list_unspent(index: &Index, outpoint: OutPoint) -> Result<Vec<(u64, u64)>> {
match index.list(outpoint)? {
Some(List::Unspent(ranges)) => Ok(
ranges
.into_iter()
.map(|(start, _end)| Ordinal(start))
.filter(|ordinal| ordinal.rarity() > Rarity::Common)
.collect(),
),
Some(List::Unspent(ranges)) => Ok(ranges),
Some(List::Spent(txid)) => Err(anyhow!(
"UTXO {} unspent in wallet but spent in index by transaction {txid}",
outpoint
Expand Down
4 changes: 3 additions & 1 deletion src/subcommand/wallet/identify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ use super::*;
pub(crate) fn run(options: Options) -> Result {
let purse = Purse::load(&options)?;

let index = Index::index(&options)?;

let mut ordinals = purse
.wallet
.list_unspent()?
.into_iter()
.map(|utxo| {
purse
.special_ordinals(&options, utxo.outpoint)
.special_ordinals(&index, utxo.outpoint)
.map(|ordinals| {
ordinals
.into_iter()
Expand Down
62 changes: 60 additions & 2 deletions src/subcommand/wallet/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ impl Send {
pub(crate) fn run(self, options: Options) -> Result {
let purse = Purse::load(&options)?;

let utxo = purse.find(&options, self.ordinal)?;
let index = Index::index(&options)?;

let ordinals = purse.special_ordinals(&options, utxo.outpoint)?;
let utxo = purse.find(&index, self.ordinal)?;

let ordinals = purse.special_ordinals(&index, utxo.outpoint)?;

if !ordinals.is_empty() && (ordinals.len() > 1 || ordinals[0] != self.ordinal) {
bail!(
Expand All @@ -40,6 +42,62 @@ impl Send {
builder.finish()?
};

fn iter_funding_utxos(
psbt: &PartiallySignedTransaction,
) -> impl Iterator<Item = Result<&TxOut>> {
assert_eq!(psbt.inputs.len(), psbt.unsigned_tx.input.len());

psbt
.unsigned_tx
.input
.iter()
.zip(&psbt.inputs)
.map(|(tx_input, psbt_input)| {
match (&psbt_input.witness_utxo, &psbt_input.non_witness_utxo) {
(Some(witness_utxo), _) => Ok(witness_utxo),
(None, Some(non_witness_utxo)) => {
let vout = tx_input.previous_output.vout as usize;
non_witness_utxo
.output
.get(vout)
.context("PSBT UTXO out of bounds")
}
(None, None) => Err(anyhow!("Missing UTXO")),
}
})
}

let input_value = iter_funding_utxos(&psbt)
.map(|result| result.map(|utxo| utxo.value))
.sum::<Result<u64>>()?;

let output_value = psbt
.unsigned_tx
.output
.iter()
.map(|output| output.value)
.sum::<u64>();

let mut offset = 0;

for (start, end) in Purse::list_unspent(&index, utxo.outpoint)? {
if start <= self.ordinal.n() && self.ordinal.n() < end {
offset += self.ordinal.n() - start;
break;
} else {
offset += end - start;
}
}

if offset >= output_value {
bail!(
"Ordinal {} is {} sat away from the end of the output which is within the {} sat fee range",
self.ordinal,
input_value - offset,
input_value - output_value
);
}

if !purse.wallet.sign(&mut psbt, SignOptions::default())? {
bail!("Failed to sign transaction.");
}
Expand Down
7 changes: 6 additions & 1 deletion src/subcommand/wallet/utxos.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
use super::*;

pub(crate) fn run(options: Options) -> Result {
for utxo in Purse::load(&options)?.wallet.list_unspent()? {
let mut utxos = Purse::load(&options)?.wallet.list_unspent()?;

utxos.sort_by_key(|utxo| utxo.outpoint);

for utxo in utxos {
println!(
"{}:{} {}",
utxo.outpoint.txid, utxo.outpoint.vout, utxo.txout.value
);
}

Ok(())
}
75 changes: 75 additions & 0 deletions tests/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,78 @@ fn send_non_unique_uncommon_ordinal() {
.expected_stderr("error: Trying to send ordinal 5000000000 but UTXO also contains ordinal(s) 5000000000 (uncommon), 10000000000 (uncommon)\n")
.run();
}

#[test]
fn protect_ordinal_from_fees() {
let state = Test::new()
.command("--chain regtest wallet init")
.expected_status(0)
.expected_stderr("Wallet initialized.\n")
.output()
.state;

let output = Test::with_state(state)
.command("--chain regtest wallet fund")
.stdout_regex("^bcrt1.*\n")
.output();

let from_address = Address::from_str(
output
.stdout
.strip_suffix('\n')
.ok_or("Failed to strip suffix")
.unwrap(),
)
.unwrap();

output.state.blocks(101);

output.state.transaction(TransactionOptions {
slots: &[(1, 0, 0)],
output_count: 2,
fee: 0,
recipient: Some(from_address.script_pubkey()),
});

output.state.blocks(1);

output
.state
.client
.generate_to_address(
100,
&Address::from_str("bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw").unwrap(),
)
.unwrap();

let output = Test::with_state(output.state)
.command("--chain regtest wallet utxos")
.expected_status(0)
.stdout_regex("[[:xdigit:]]{64}:0 2500000000\n[[:xdigit:]]{64}:1 2500000000\n")
.output();

let wallet = Wallet::new(
Bip84(
(
Mnemonic::parse("book fit fly ketchup also elevator scout mind edit fatal where rookie")
.unwrap(),
None,
),
KeychainKind::External,
),
None,
Network::Regtest,
MemoryDatabase::new(),
)
.unwrap();

let to_address = wallet.get_address(AddressIndex::LastUnused).unwrap();

Test::with_state(output.state)
.command(&format!(
"--chain regtest wallet send --address {to_address} --ordinal 9999999999",
))
.expected_status(1)
.expected_stderr("error: Ordinal 9999999999 is 1 sat away from the end of the output which is within the 220 sat fee range\n")
.run();
}

0 comments on commit 5f376ea

Please sign in to comment.