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
4 changes: 2 additions & 2 deletions crates/mockcore/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,15 +1066,15 @@ 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();
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
```
111 changes: 90 additions & 21 deletions src/subcommand/wallet/offer/accept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ pub struct Output {
}

#[derive(Debug, Parser)]
#[command(group = ArgGroup::new("target")
.required(true)
.multiple(false)
.args(["inscription", "rune"]))]
pub(crate) struct Accept {
#[arg(long, help = "Assert offer is for <AMOUNT>")]
amount: Amount,
#[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 Down Expand Up @@ -46,31 +52,94 @@ impl Accept {
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_id), None) => {
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(inscriptions) = wallet.get_inscriptions_in_output(&outgoing)? else {
bail! {
"index must have inscription index to accept PSBT",
let Some(inscription) = inscriptions.into_iter().next() else {
bail!("outgoing input contains no inscriptions");
};

ensure! {
inscription == inscription_id,
"unexpected outgoing inscription {inscription}",
}
}
};
(None, Some(rune)) => {
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",
);

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

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

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

ensure! {
inscriptions.len() <= 1,
"outgoing input {} contains {} inscriptions", outgoing, inscriptions.len(),
}
let Some(pile) = runes.get(&spaced_rune) else {
bail!(format!(
"outgoing input {} does not contain rune {}",
outgoing, spaced_rune
));
};

let Some(inscription) = inscriptions.into_iter().next() else {
bail!("outgoing input contains no inscriptions");
};
ensure! {
runes.len() == 1,
"outgoing input {} holds multiple runes",
outgoing
}

ensure! {
inscription == self.inscription,
"unexpected outgoing inscription {inscription}",
ensure! {
pile.amount == decimal.value,
"unexpected rune {} balance at outgoing input {} (expected {}, found {})",
spaced_rune,
outgoing,
decimal.value,
pile.amount
}

ensure! {
Runestone::decipher(&psbt.unsigned_tx).is_none(),
"unexpected runestone in PSBT"
}
}
_ => unreachable!("--inscription or --rune must be set, but not both"),
}

let balance_change = wallet.simulate_transaction(&psbt.unsigned_tx)?;
Expand Down
153 changes: 123 additions & 30 deletions src/subcommand/wallet/offer/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,54 +4,146 @@ use super::*;
pub struct Output {
pub psbt: String,
pub seller_address: Address<NetworkUnchecked>,
pub inscription: InscriptionId,
pub inscription: Option<InscriptionId>,
pub rune: Option<Outgoing>,
}

#[derive(Debug, Parser)]
#[command(group = ArgGroup::new("target")
.required(true)
.multiple(false)
.args(["inscription", "rune"]))]
pub(crate) struct Create {
#[arg(long, help = "<INSCRIPTION> to make offer for.")]
inscription: InscriptionId,
inscription: Option<InscriptionId>,
#[arg(long, help = "<DECIMAL:RUNE> to make offer for.", requires = "utxo")]
rune: Option<Outgoing>,
#[arg(long, help = "<AMOUNT> to offer.")]
amount: Amount,
#[arg(long, help = "<FEE_RATE> for finalized transaction.")]
fee_rate: FeeRate,
#[arg(long, help = "UTXO to make an offer for. (format: <TXID:VOUT>)")]
utxo: Option<OutPoint>,
}

impl Create {
pub(crate) fn run(&self, wallet: Wallet) -> SubcommandResult {
ensure!(
!wallet.inscription_info().contains_key(&self.inscription),
"inscription {} already in wallet",
self.inscription
);

let Some(inscription) = wallet.get_inscription(self.inscription)? else {
bail!("inscription {} does not exist", self.inscription);
let (seller_address, postage, utxo) = match (self.inscription, self.rune.clone()) {
(Some(inscription_id), None) => {
ensure!(
!wallet.inscription_info().contains_key(&inscription_id),
"inscription {} already in wallet",
inscription_id
);

let Some(inscription) = wallet.get_inscription(inscription_id)? else {
bail!("inscription {} does not exist", inscription_id);
};

if let Some(utxo) = self.utxo {
ensure! {
inscription.satpoint.outpoint == utxo,
"inscription utxo {} does not match provided utxo {}",
inscription.satpoint.outpoint,
utxo
};
}

let Some(postage) = inscription.value else {
bail!("inscription {} unbound", inscription_id);
};

let Some(seller_address) = inscription.address else {
bail!(
"inscription {} script pubkey not valid address",
inscription_id,
);
};

let seller_address = seller_address
.parse::<Address<NetworkUnchecked>>()
.unwrap()
.require_network(wallet.chain().network())?;

(
seller_address,
Amount::from_sat(postage),
inscription.satpoint.outpoint,
)
}
(None, Some(outgoing)) => {
let (decimal, spaced_rune) = match outgoing {
Outgoing::Rune { decimal, rune } => (decimal, rune),
_ => bail!("invalid format for --rune (must be `DECIMAL:RUNE`)"),
};

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

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

assert!(self.utxo.is_some());

let utxo = self.utxo.unwrap();

ensure!(
!wallet.utxos().contains_key(&utxo),
"utxo {} already in wallet",
utxo
);

ensure! {
wallet.output_exists(utxo)?,
"utxo {} does not exist",
utxo
}

let Some(output_info) = wallet.get_output_info(utxo)? else {
bail!("utxo {} does not exist", utxo);
};

let Some(seller_address) = output_info.address else {
bail!("utxo {} script pubkey not valid address", utxo);
};

let Some(runes) = output_info.runes else {
bail!("utxo {} does not hold any {} runes", utxo, spaced_rune);
};

let Some(pile) = runes.get(&spaced_rune) else {
bail!("utxo {} does not hold any {} runes", utxo, spaced_rune);
};

ensure! {
pile.amount == decimal.value,
"utxo holds unexpected {} balance (expected {}, found {})",
spaced_rune,
decimal.value,
pile.amount
}

ensure! {
runes.len() == 1,
"utxo {} holds multiple runes",
utxo
}

let seller_address = seller_address.require_network(wallet.chain().network())?;

(seller_address, Amount::from_sat(output_info.value), utxo)
}
_ => unreachable!("--inscription or --rune must be set, but not both"),
};

let Some(postage) = inscription.value else {
bail!("inscription {} unbound", self.inscription);
};

let Some(seller_address) = inscription.address else {
bail!(
"inscription {} script pubkey not valid address",
self.inscription,
);
};

let seller_address = seller_address
.parse::<Address<NetworkUnchecked>>()
.unwrap()
.require_network(wallet.chain().network())?;

let postage = Amount::from_sat(postage);

let tx = Transaction {
version: Version(2),
lock_time: LockTime::ZERO,
input: vec![TxIn {
previous_output: inscription.satpoint.outpoint,
previous_output: utxo,
script_sig: ScriptBuf::new(),
sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
witness: Witness::new(),
Expand Down Expand Up @@ -91,8 +183,9 @@ impl Create {

Ok(Some(Box::new(Output {
psbt: result.psbt,
inscription: self.inscription,
seller_address: seller_address.into_unchecked(),
inscription: self.inscription,
rune: self.rune.clone(),
})))
}
}
Loading