Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- Changes are only described (exit code 6) unless `--execute` or `VOLTAGE_EXECUTE=1` asks to send them; the description is built offline and sends nothing.
- `--no-input` and `--quiet`, with interrupt-safe progress and the time waited so far on payment waits and device approval.
- `voltage --version` shows the git build.
- A payment without `--currency` uses its wallet's line of credit currency (or, for a receive, the currency of its `--unit`). An amount without `--unit` is in the currency's base unit (msats or cents), and `--max-fee` without `--fee-unit` is in msats.
- Every command that changes something has examples in `--help`, and every command links its guide.
- Guides for wallets, webhooks, troubleshooting, and getting started, and references for global flags, environment variables, and configuration.

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ voltage wallets delete WALLET_ID --profile prod

The description reports the method, URL, query, `Origin` header, and body, plus a `run` object with what a real run would do next: whether it verifies the wallet's environment, the ID it would journal for recovery, whether a failed payment submission is reconciled, and what it waits for. It reports the same `resource_id` a real run would use, so chained commands see the same IDs in both modes. The result is a normal envelope with `outcome: dry_run` and no `http_status`.

Describing a change authenticates nothing, makes no request for the change itself (including the wallet environment check a real run performs), writes no journal entry, and does not create `--output-file`. On a terminal, a missing organization, environment, or wallet can still open a [picker](../guides/login-and-profiles.md#pick-the-scope-interactively), which lists choices from the API; pass the scope, `--no-input`, or `--json` to stay offline. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request.
Describing a change makes no request for the change itself (including the wallet environment check a real run performs), writes no journal entry, and does not create `--output-file`. It authenticates nothing, with one exception: a payment without `--currency` reads its wallet's currency, as a real run would, so the description shows the same body; pass `--currency` to keep it offline. On a terminal, a missing organization, environment, or wallet can still open a [picker](../guides/login-and-profiles.md#pick-the-scope-interactively), which lists choices from the API; pass the scope, `--no-input`, or `--json` to stay offline. Body and metadata-filter values are replaced with `[OMITTED]` unless the field holds only identifiers, enums, or amounts; `--show-secrets` shows them. `--data -` still reads stdin, and a friendly create generates a fresh ID each run unless you pass `--id`. Passing local validation does not mean the API will accept the request.

## Payment IDs and the request journal

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ voltage payments send --profile prod --wallet WALLET_ID \
--currency btc --address BITCOIN_ADDRESS --amount 1000 --unit sats --execute
```

Amounts are decimals in the unit you name (`msats`, `sats`, `btc`, `cents`, `usd`) and are converted to the API's integer base units exactly; a value with more precision than the unit allows is rejected. `--currency` is the wallet currency for sends and the receive currency for open-amount receives. Fee limits cover network and provider fees only. A send notes on stderr that its fee limit excludes processing fees.
Amounts are decimals in the unit you name with `--unit` (`msats`, `sats`, `btc`, `cents`, `usd`) and are converted to the API's integer base units exactly; a value with more precision than the unit allows is rejected. Without `--unit`, an amount is in the currency's base unit: msats for BTC and cents for USD, so `--amount 1000` is 1 sat. `--max-fee` without `--fee-unit` is in msats. `--currency` is the wallet currency for sends and the receive currency for open-amount receives. Without it, a receive with `--unit` uses that unit's currency, and any other payment reads the wallet and uses its line of credit's currency. That read needs a credential, also when the change is only described, and stderr says which currency was used. Fee limits cover network and provider fees only. A send notes on stderr that its fee limit excludes processing fees.

Run a change without `--execute` to see exactly what it would send, then add `--execute` to send it. There is no confirmation prompt. See the [safety model](../concepts/safety-model.md).

Expand Down
34 changes: 33 additions & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use crate::{
error::{ErrorDetail, ErrorKind},
input::{PaginationMode, Request},
output::{Envelope, Outcome, Output},
payment::{PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget},
payment::{
Currency, PaymentDirection, PaymentView, ReceiveKind, StatusText, WaitProgress, WaitTarget,
},
registry::{AuthScheme, Method, Operation, OperationId},
secret::Secret,
terminal::{self, Terminal},
Expand Down Expand Up @@ -551,6 +553,36 @@ pub async fn wallets(
Ok(api.send(Method::Get, &path, &query, None).await?.body)
}

/// The currency of a wallet's line of credit, which decides how its payments are
/// denominated and whether they need a quote. A wallet has no currency field of its own, so
/// this reads the wallet for its line of credit, then the line of credit's summary.
pub async fn wallet_currency(
global: &GlobalFlags,
credential: ApiCredential,
scope: &Scope,
wallet: Uuid,
) -> Result<Currency> {
let org = scope.require_org()?;
let api = Api::new(global, None, Authorization::Account(credential))?;
let path = format!("/organizations/{org}/wallets/{wallet}");
let read = api.send(Method::Get, &path, &[], None).await?.body;
let unknown = || {
Error::usage("Cannot tell this wallet's currency")
.with_hint("Pass --currency btc or --currency usd.")
};
let line = read
.get("line_of_credit_id")
.and_then(Value::as_str)
.ok_or_else(unknown)?;
let path = format!("/organizations/{org}/lines_of_credit/{line}/summary");
let summary = api.send(Method::Get, &path, &[], None).await?.body;
summary
.get("currency")
.cloned()
.and_then(|currency| serde_json::from_value(currency).ok())
.ok_or_else(unknown)
}

/// Whether an API command sends its request, and what decided it.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Execution {
Expand Down
9 changes: 5 additions & 4 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,8 @@ pub struct PaymentFlags {
/// Optional payment UUID
#[arg(long)]
pub id: Option<Uuid>,
/// Wallet currency for sends, receive currency for any-amount receives
/// Wallet currency for sends, receive currency for any-amount receives. Defaults to the
/// wallet's line of credit currency, which reads the wallet (or a receive's --unit)
#[arg(long, value_enum)]
pub currency: Option<Currency>,
/// BOLT11 invoice to pay
Expand All @@ -438,16 +439,16 @@ pub struct PaymentFlags {
/// Receive kind; onchain and bip21 need the on_chain and bip21 features enabled by Voltage
#[arg(long, value_enum)]
pub kind: Option<ReceiveKind>,
/// Exact decimal amount; pair with --unit
/// Exact decimal amount, in --unit
#[arg(long)]
pub amount: Option<String>,
/// Unit of --amount
/// Unit of --amount. Defaults to the currency's base unit: msats or cents
#[arg(long, value_enum)]
pub unit: Option<AmountUnit>,
/// Maximum network/provider fee; processing fees are additional
#[arg(long)]
pub max_fee: Option<String>,
/// Unit of --max-fee: msats, sats, or btc
/// Unit of --max-fee: msats, sats, or btc. Defaults to msats
#[arg(long, value_enum)]
pub fee_unit: Option<AmountUnit>,
/// Quote UUID required by USD payment flows
Expand Down
9 changes: 9 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ impl Error {
self
}

/// Add a sentence after the hint the error already has, so neither is lost.
pub fn and_hint(mut self, hint: &'static str) -> Self {
self.hint = Some(match self.hint.take() {
Some(existing) => format!("{existing} {hint}").into(),
None => hint.into(),
});
self
}

pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
self.detail = Some(detail);
self
Expand Down
70 changes: 62 additions & 8 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,14 @@ fn create_wallet(flags: &WalletFlags, scope: &Scope) -> Result<CreateWalletBody>
})
}

fn amount_with_unit(
/// An amount in its `--unit`, or in `default` when none is given.
fn amount_in(
amount: Option<&str>,
unit: Option<AmountUnit>,
unit_flag: &str,
default: AmountUnit,
) -> Result<Option<Amount>> {
amount
.map(|value| Amount::parse(value, require(unit, unit_flag)?))
.map(|value| Amount::parse(value, unit.unwrap_or(default)))
.transpose()
}

Expand All @@ -371,7 +372,7 @@ fn receive(flags: &PaymentFlags, wallet: Uuid, currency: Currency) -> Result<Rec
"Receive commands do not accept send destination or fee flags",
));
}
let amount = amount_with_unit(flags.amount.as_deref(), flags.unit, "unit")?;
let amount = amount_in(flags.amount.as_deref(), flags.unit, currency.base_unit())?;
if amount.is_some_and(|amount| amount.currency != currency) {
return Err(Error::usage("Receive currency must match the amount unit"));
}
Expand Down Expand Up @@ -406,14 +407,15 @@ fn send_body(flags: &PaymentFlags, wallet: Uuid, currency: Currency) -> Result<V
if flags.kind.is_some() || flags.expiration.is_some() {
return Err(Error::usage("Send commands do not accept receive flags"));
}
let amount = amount_with_unit(flags.amount.as_deref(), flags.unit, "unit")?;
let amount = amount_in(flags.amount.as_deref(), flags.unit, currency.base_unit())?;
if amount.is_some_and(|amount| amount.amount == 0) {
return Err(Error::usage("Send amount must be greater than zero"));
}
if matches!(destination, SendDestination::Onchain { .. }) && amount.is_none() {
return Err(Error::usage("On-chain sends require --amount and --unit"));
return Err(Error::usage("On-chain sends require --amount"));
}
let max_fee = amount_with_unit(flags.max_fee.as_deref(), flags.fee_unit, "fee-unit")?;
// Network fees are always in BTC, so their base unit needs no currency.
let max_fee = amount_in(flags.max_fee.as_deref(), flags.fee_unit, AmountUnit::Msats)?;
if max_fee.is_some_and(|fee| fee.currency != Currency::Btc) {
return Err(Error::usage("Network fees must use BTC units"));
}
Expand Down Expand Up @@ -877,7 +879,7 @@ mod tests {
]);
assert_eq!(error, "Send commands do not accept receive flags");
let error = request_error(&["payments", "send", "--currency", "btc", "--address", "bc1"]);
assert_eq!(error, "On-chain sends require --amount and --unit");
assert_eq!(error, "On-chain sends require --amount");
let error = request_error(&[
"payments",
"send",
Expand Down Expand Up @@ -911,6 +913,58 @@ mod tests {
);
}

#[test]
fn amounts_without_a_unit_use_the_currency_base_unit_and_fees_use_msats() {
let amount = |args: &[&str]| build(args).unwrap().body.unwrap();
let send = amount(&[
"payments",
"send",
"--currency",
"btc",
"--address",
"bc1",
"--amount",
"1500",
"--max-fee",
"20",
]);
assert_eq!(
send["data"]["amount"],
json!({"currency": "btc", "amount": 1500})
);
assert_eq!(
send["data"]["max_fee"],
json!({"currency": "btc", "amount": 20})
);
let receive = amount(&[
"payments",
"receive",
"--currency",
"usd",
"--kind",
"bolt11",
"--amount",
"250",
"--quote",
WALLET,
]);
assert_eq!(receive["amount"], json!({"currency": "usd", "amount": 250}));
// An explicit unit still wins over the default.
let sats = amount(&[
"payments",
"receive",
"--currency",
"btc",
"--kind",
"bolt11",
"--amount",
"2",
"--unit",
"sats",
]);
assert_eq!(sats["amount"], json!({"currency": "btc", "amount": 2000}));
}

fn request_error(args: &[&str]) -> String {
build(args).expect_err("request should fail").message
}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod registry;
pub mod secret;
mod startup;
mod terminal;
mod wallet_currency;

pub(crate) use error::{Error, Result};
pub use startup::run;
21 changes: 21 additions & 0 deletions src/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ pub enum Currency {
Usd,
}

/// The `--currency` spelling, as in a notice that names the currency in use.
impl std::fmt::Display for Currency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Btc => "btc",
Self::Usd => "usd",
})
}
}

impl Currency {
/// The API's integer unit, which an amount without `--unit` uses: the smallest unit, so a
/// misread amount errs toward too little rather than too much.
pub fn base_unit(self) -> AmountUnit {
match self {
Self::Btc => AmountUnit::Msats,
Self::Usd => AmountUnit::Cents,
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Display for Currency, spelled as --currency takes it. The full std::fmt paths match impl std::fmt::Display for Error in src/error.rs.

Suggested change
}
}
/// The `--currency` spelling, as in a notice that names the currency in use.
impl std::fmt::Display for Currency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Btc => "btc",
Self::Usd => "usd",
})
}
}


/// Unit of a decimal `--amount` or `--max-fee`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum AmountUnit {
Expand Down
58 changes: 34 additions & 24 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::{
pick,
price::{PRICE_URL, PriceService},
terminal::Terminal,
wallet_currency,
};
use serde::Serialize;
use std::process::ExitCode;
Expand Down Expand Up @@ -86,15 +87,17 @@ fn process_exit(code: i32) -> ExitCode {
}

async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result<()> {
let Invocation { global, command } = invocation;
let Invocation {
global,
mut command,
} = invocation;
if let Command::Local(LocalCommand::Completions { shell }) = command {
// The generator panics on a write error, so render to memory and use the normal
// stdout error policy when writing the completed script.
let mut script = Vec::new();
clap_complete::generate(shell, &mut cli::command(), BINARY_NAME, &mut script);
return write_stdout(&script);
}
let terminal = global.terminal();
// `reserve` is false for a description, which goes to stdout and never reserves the file.
let output = |reserve: bool| {
Output::new(
Expand All @@ -104,34 +107,15 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result
)
};
// Price commands need neither configuration nor credentials.
if let Command::Local(LocalCommand::Price(_) | LocalCommand::Convert(_)) = &command {
if let Command::Local(local @ (LocalCommand::Price(_) | LocalCommand::Convert(_))) = command {
let mut out = output(true)?;
let service = PriceService::new(
global.price_url.as_deref().unwrap_or(PRICE_URL),
global.timeout,
)?;
const WAITING: &str = "Waiting for price service...";
let envelope = match command {
Command::Local(LocalCommand::Price(flags)) => Envelope::local(
terminal
.during(WAITING, service.report(flags.at.as_deref()))
.await?,
)?,
Command::Local(LocalCommand::Convert(flags)) => Envelope::local(
terminal
.during(
WAITING,
service.convert(&flags.request(), flags.at.as_deref()),
)
.await?,
)?,
_ => unreachable!("matched above"),
};
let envelope = price_command(local, &global).await?;
return out.write(envelope, &[]);
}
let mut settings = Settings::open(config::directory(global.config_dir.clone())?)?;
let mut scope = settings.scope(global.scope_selection())?;
pick::fill(&command, &global, &settings, &mut scope, submission).await?;
wallet_currency::fill(&mut command, &global, &settings, &scope, submission).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line takes execute to cyclomatic 20, which fails the CRAP budget even at full coverage (execute scores 20.15; it was 19.16 on #22). Tests can't fix that, so the complexity has to come down.

The price and convert block above (lines 110-133) is its own command handler, with its own unreachable! arm. GitHub can't take a suggestion there because this PR doesn't touch those lines, so here is the change. In execute, replace the let terminal = global.terminal(); line and the price block with:

    // Price commands need neither configuration nor credentials.
    if let Command::Local(local @ (LocalCommand::Price(_) | LocalCommand::Convert(_))) = command {
        let mut out = output(true)?;
        let envelope = price_command(local, &global).await?;
        return out.write(envelope, &[]);
    }

and add, before local_command:

/// Read the price service for `price` or `convert`.
async fn price_command(command: LocalCommand, global: &GlobalFlags) -> Result<Envelope> {
    let service = PriceService::new(
        global.price_url.as_deref().unwrap_or(PRICE_URL),
        global.timeout,
    )?;
    let terminal = global.terminal();
    const WAITING: &str = "Waiting for price service...";
    match command {
        LocalCommand::Price(flags) => Envelope::local(
            terminal
                .during(WAITING, service.report(flags.at.as_deref()))
                .await?,
        ),
        LocalCommand::Convert(flags) => Envelope::local(
            terminal
                .during(
                    WAITING,
                    service.convert(&flags.request(), flags.at.as_deref()),
                )
                .await?,
        ),
        _ => unreachable!("only price commands are passed"),
    }
}

That takes execute to cyclomatic 15 (CRAP 15.00 at 100% coverage), with price_command at 5. Checked with fmt, clippy, and the full test suite.

match command {
Command::Api(api) => {
let execution = Execution::resolve(&api, config::execute_variable()?);
Expand All @@ -149,6 +133,32 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result
}
}

/// Read the price service for `price` or `convert`.
async fn price_command(command: LocalCommand, global: &GlobalFlags) -> Result<Envelope> {
let service = PriceService::new(
global.price_url.as_deref().unwrap_or(PRICE_URL),
global.timeout,
)?;
let terminal = global.terminal();
const WAITING: &str = "Waiting for price service...";
match command {
LocalCommand::Price(flags) => Envelope::local(
terminal
.during(WAITING, service.report(flags.at.as_deref()))
.await?,
),
LocalCommand::Convert(flags) => Envelope::local(
terminal
.during(
WAITING,
service.convert(&flags.request(), flags.at.as_deref()),
)
.await?,
),
_ => unreachable!("only price commands are passed"),
}
}

async fn local_command(
command: LocalCommand,
global: &GlobalFlags,
Expand Down
Loading
Loading