Skip to content

Rune buy offers #4282

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
8 changes: 6 additions & 2 deletions crates/mockcore/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,15 +1066,19 @@ impl Api for Server {

let txout = &tx.output[usize::try_from(input.previous_output.vout).unwrap()];

let address = Address::from_script(&txout.script_pubkey, Network::Bitcoin).unwrap();
let address = Address::from_script(&txout.script_pubkey, self.network).unwrap();

if self.state().is_wallet_address(&address) {
balance_change -= i64::try_from(txout.value.to_sat()).unwrap();
}
}

for output in tx.output {
let address = Address::from_script(&output.script_pubkey, Network::Bitcoin).unwrap();
if output.script_pubkey.is_op_return() {
continue;
}

let address = Address::from_script(&output.script_pubkey, self.network).unwrap();
if self.state().is_wallet_address(&address) {
balance_change += i64::try_from(output.value.to_sat()).unwrap();
}
Expand Down
42 changes: 42 additions & 0 deletions docs/src/guides/wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,45 @@ Once the send transaction confirms, you can confirm receipt by running:
```
ord wallet inscriptions
```

Creating an Inscription or Runes Buy Offer
------------------------------------------

Bid `AMOUNT` on the inscription `INSCRIPTION_ID` using:

```
ord wallet offer create --inscription <INSCRIPTION_ID> --fee-rate <FEE_RATE> --amount <AMOUNT>
```

Bid `AMOUNT` on the rune balance `DECIMAL:RUNE` at `UTXO` using:

```
ord wallet offer create --rune <DECIMAL:RUNE> --fee-rate <FEE_RATE> --amount <AMOUNT> --utxo <UTXO>
```

Accepting an Inscription or Runes Buy Offer
-------------------------------------------

Accept the offer to buy the inscription `INSCRIPTION_ID` for `AMOUNT` in `PSBT` using:

```
ord wallet offer accept --inscription <INSCRIPTION_ID> --amount <AMOUNT> --psbt <PSBT>
```

Accept the offer to buy the rune balance `DECIMAL:RUNE` in `PSBT` using:

```
ord wallet offer accept --rune <DECIMAL:RUNE> --amount <AMOUNT> --psbt <PSBT>
```

See the pending transaction with:

```
ord wallet transactions
```

Once the accept transaction confirms, you can view your updated balance with:

```
ord wallet balance
```
193 changes: 158 additions & 35 deletions src/subcommand/wallet/offer/accept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ pub(crate) struct Accept {
#[arg(long, help = "Don't sign or broadcast transaction")]
dry_run: bool,
#[arg(long, help = "Assert offer is for <INSCRIPTION>")]
inscription: InscriptionId,
inscription: Option<InscriptionId>,
#[arg(long, help = "<DECIMAL:RUNE> to make offer for.")]
rune: Option<Outgoing>,
#[arg(long, help = "Accept <PSBT> offer")]
psbt: String,
}
Expand All @@ -37,40 +39,15 @@ impl Accept {
}
}

ensure! {
outgoing.len() <= 1,
"PSBT contains {} inputs owned by wallet", outgoing.len(),
}

let Some((index, outgoing)) = outgoing.into_iter().next() else {
bail!("PSBT contains no inputs owned by wallet");
};

if let Some(runes) = wallet.get_runes_balances_in_output(&outgoing)? {
ensure! {
runes.is_empty(),
"outgoing input {} contains runes", outgoing,
match (self.inscription, self.rune.clone()) {
(Some(inscription), None) => {
self.check_inscription_buy_offer(&wallet, outgoing.clone(), inscription)?
}
}

let Some(inscriptions) = wallet.get_inscriptions_in_output(&outgoing)? else {
bail! {
"index must have inscription index to accept PSBT",
(None, Some(rune)) => {
self.check_rune_buy_offer(&wallet, psbt.unsigned_tx.clone(), outgoing.clone(), rune)?
}
};

ensure! {
inscriptions.len() <= 1,
"outgoing input {} contains {} inscriptions", outgoing, inscriptions.len(),
}

let Some(inscription) = inscriptions.into_iter().next() else {
bail!("outgoing input contains no inscriptions");
};

ensure! {
inscription == self.inscription,
"unexpected outgoing inscription {inscription}",
(None, None) => bail!("must include either --inscription or --rune"),
(Some(_), Some(_)) => bail!("cannot include both --inscription and --rune"),
}

let balance_change = wallet.simulate_transaction(&psbt.unsigned_tx)?;
Expand All @@ -85,7 +62,7 @@ impl Accept {
for (i, signature) in signatures.iter().enumerate() {
let outpoint = psbt.unsigned_tx.input[i].previous_output;

if i == index {
if outgoing.contains_key(&i) {
ensure! {
signature.is_none(),
"seller input `{outpoint}` is signed: seller input must not be signed",
Expand Down Expand Up @@ -129,7 +106,7 @@ impl Accept {
{
let outpoint = signed_tx.input[i].previous_output;

if i == index {
if outgoing.contains_key(&i) {
ensure! {
new.is_some(),
"seller input `{outpoint}` was not signed by wallet",
Expand All @@ -149,6 +126,152 @@ impl Accept {
Ok(Some(Box::new(Output { txid })))
}

fn check_inscription_buy_offer(
&self,
wallet: &Wallet,
outgoing: BTreeMap<usize, OutPoint>,
inscription_id: InscriptionId,
) -> Result {
ensure! {
outgoing.len() <= 1,
"PSBT contains {} inputs owned by wallet", outgoing.len(),
}

let Some((_, outgoing)) = outgoing.into_iter().next() else {
bail!("PSBT contains no inputs owned by wallet");
};

if let Some(runes) = wallet.get_runes_balances_in_output(&outgoing)? {
ensure! {
runes.is_empty(),
"outgoing input {} contains runes", outgoing,
}
}

let Some(inscriptions) = wallet.get_inscriptions_in_output(&outgoing)? else {
bail! {
"index must have inscription index to accept PSBT",
}
};

ensure! {
inscriptions.len() <= 1,
"outgoing input {} contains {} inscriptions", outgoing, inscriptions.len(),
}

let Some(inscription) = inscriptions.into_iter().next() else {
bail!("outgoing input contains no inscriptions");
};

ensure! {
inscription == inscription_id,
"unexpected outgoing inscription {inscription}",
}

Ok(())
}

fn check_rune_buy_offer(
&self,
wallet: &Wallet,
unsigned_tx: Transaction,
outgoing: BTreeMap<usize, OutPoint>,
rune: Outgoing,
) -> Result {
ensure! {
!outgoing.is_empty(),
"PSBT contains no inputs owned by wallet"
}

let (decimal, spaced_rune) = match rune {
Outgoing::Rune { decimal, rune } => (decimal, rune),
_ => bail!("invalid format for --rune (must be `DECIMAL:RUNE`)"),
};

ensure!(
wallet.has_rune_index(),
"accepting rune offer with `offer` requires index created with `--index-runes` flag",
);

let (id, _, _) = wallet
.get_rune(spaced_rune.rune)?
.with_context(|| format!("rune `{}` has not been etched", spaced_rune.rune))?;

let mut contains_multiple_runes = false;
let mut amount = 0;

for utxo in outgoing.values() {
let Some(runes) = wallet.get_runes_balances_in_output(utxo)? else {
bail! {
"outgoing input {} contains no runes",
utxo
}
};

if let Some(inscriptions) = wallet.get_inscriptions_in_output(utxo)? {
ensure! {
inscriptions.is_empty(),
"outgoing input {} contains {} inscription(s)",
utxo,
inscriptions.len()
}
}

let Some(pile) = runes.get(&spaced_rune) else {
bail!(format!(
"outgoing input {} does not contain rune {}",
utxo, spaced_rune
));
};

contains_multiple_runes = contains_multiple_runes || runes.len() > 1;
amount += pile.amount;
}

ensure! {
amount == decimal.value,
"unexpected rune {} balance at outgoing input(s) ({} vs. {})",
spaced_rune,
amount,
decimal.value
}

if contains_multiple_runes {
let Some(runestone) = Runestone::decipher(&unsigned_tx) else {
bail!("missing runestone in PSBT");
};

let expected_runestone = Runestone {
edicts: vec![Edict {
amount: 0,
id,
output: 2,
}],
..default()
};

ensure! {
runestone == Artifact::Runestone(expected_runestone),
"unexpected runestone in PSBT"
}

ensure! {
!unsigned_tx.output.is_empty() &&
outgoing.values().any(|outgoing| {
unsigned_tx.output[0].script_pubkey == wallet.utxos().get(outgoing).unwrap().script_pubkey
}),
"unexpected seller address in PSBT"
}
} else {
ensure! {
Runestone::decipher(&unsigned_tx).is_none(),
"unexpected runestone in PSBT"
}
}

Ok(())
}

fn psbt_signatures(psbt: &Psbt) -> Result<Vec<Option<Signature>>> {
psbt
.inputs
Expand Down
Loading