Skip to content
47 changes: 47 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Anything after the `--` double dash (the "slop") is parsed as arguments to the c
- `config` — Manage CLI configuration
- `snapshot` — Download a snapshot of a ledger from an archive
- `token` — Interact with SEP-41 tokens and Stellar Asset Contracts
- `checkout` — Pay for and fetch an x402-protected resource
- `tx` — Sign, Simulate, and Send transactions
- `xdr` — Decode and encode XDR
- `strkey` — Decode and encode strkey
Expand Down Expand Up @@ -2501,6 +2502,52 @@ Calls the token's Stellar Asset Contract `set_authorized` function. A non-SAC co
- `--fee <FEE>` — ⚠️ Deprecated, use `--inclusion-fee`. Fee amount for transaction, in stroops. 1 stroop = 0.0000001 xlm
- `--inclusion-fee <INCLUSION_FEE>` — Maximum fee amount for transaction inclusion, in stroops. 1 stroop = 0.0000001 xlm. Defaults to 100 if no arg, env, or config value is provided

## `stellar checkout`

Pay for and fetch an x402-protected resource

**Usage:** `stellar checkout [OPTIONS] --source-account <SOURCE_ACCOUNT> <URL>`

###### **Arguments:**

- `<URL>` — The x402-protected resource to fetch and pay for

###### **Global Options:**

- `--config-dir <CONFIG_DIR>` — Location of config directory. By default, it uses `$XDG_CONFIG_HOME/stellar` if set, falling back to `~/.config/stellar` otherwise. Contains configuration files, aliases, and other persistent settings

###### **Options:**

- `--source-account <SOURCE_ACCOUNT>` [alias: `source`] — Account that pays for the resource. Signs and authorizes the on-chain payment, so it must be an identity or key you control
- `--max-amount <MAX_AMOUNT>` — Maximum amount to pay, in the asset's display units (e.g. `1` = 1.0 USDC, roughly $1). Aborts before signing if the challenge asks for more. Set to `none` to disable the cap

Default value: `1`

- `--build-only` — Build and authorization-sign the payment and print the proof, without fetching the paid resource
- `--output <OUTPUT>` — Format of the output

Default value: `text`

Possible values:
- `text`: Human-readable text
- `json`: Compact, single-line JSON output
- `json-formatted`: Formatted (multiline) JSON output

###### **RPC Options:**

- `--rpc-url <RPC_URL>` — RPC server endpoint
- `--rpc-header <RPC_HEADERS>` — RPC Header(s) to include in requests to the RPC provider, example: "X-API-Key: abc123". Multiple headers can be added by passing the option multiple times
- `--network-passphrase <NETWORK_PASSPHRASE>` — Network passphrase to sign the transaction sent to the rpc server
- `-n`, `--network <NETWORK>` — Name of network to use from config

###### **Signing Options:**

- `--sign-with-key <SIGN_WITH_KEY>` — Sign with a local key or key saved in OS secure storage. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path
- `--hd-path <HD_PATH>` — If using a seed phrase to sign, sets which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0`
- `--sign-with-lab` — Sign with https://lab.stellar.org
- `--sign-with-ledger` — Sign with a ledger wallet
- `--auto-sign` — Sign without prompting for approval. Only applies to signatures that require user approval, like non-root Soroban auth entries

## `stellar tx`

Sign, Simulate, and Send transactions
Expand Down
1 change: 1 addition & 0 deletions cmd/crates/soroban-test/tests/it/integration.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod auth;
mod auto_build;
mod bindings;
mod checkout;
mod constructor;
mod contract;
mod cookbook;
Expand Down
198 changes: 198 additions & 0 deletions cmd/crates/soroban-test/tests/it/integration/checkout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
use httpmock::prelude::*;
use serde_json::{json, Value};
use soroban_cli::xdr::{
HostFunction, Int128Parts, Limits, OperationBody, Preconditions, ReadXdr, ScVal,
SorobanCredentials, TransactionEnvelope,
};
use soroban_test::{AssertExt, TestEnv};

use crate::integration::{
token::{deploy_sac, sac_id},
util::{new_account, test_address},
};

/// Base64 JSON `PAYMENT-RESPONSE` for a settled payment with tx hash `abc123`.
const SETTLEMENT_HEADER: &str =
"eyJzdWNjZXNzIjp0cnVlLCJ0cmFuc2FjdGlvbiI6ImFiYzEyMyIsIm5ldHdvcmsiOiJzdGVsbGFyOmxvY2FsIn0=";

/// An x402 challenge asking for `amount` stroops of `asset`, paid to `pay_to`.
fn challenge(asset: &str, pay_to: &str, amount: i128) -> Value {
json!({
"x402Version": 2,
"accepts": [{
"scheme": "exact",
"network": "stellar:local",
"asset": asset,
"payTo": pay_to,
"amount": amount.to_string(),
"maxTimeoutSeconds": 60,
"extra": { "areFeesSponsored": true }
}],
"resource": { "url": "/pay" }
})
}

/// Whether the request carries an x402 payment proof.
fn has_payment_signature(req: &HttpMockRequest) -> bool {
req.headers.as_ref().is_some_and(|headers| {
headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("payment-signature"))
})
}

/// Mock the first, unpaid request with a `402` carrying `challenge`.
fn mock_challenge<'a>(server: &'a MockServer, challenge: &Value) -> httpmock::Mock<'a> {
server.mock(|when, then| {
when.method(GET)
.path("/pay")
.matches(|req| !has_payment_signature(req));
then.status(402).json_body(challenge.clone());
})
}

/// Mock the paid retry: any request carrying a payment proof gets `body`.
fn mock_paid<'a>(server: &'a MockServer, body: &[u8]) -> httpmock::Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/pay").matches(has_payment_signature);
then.status(200)
.header("content-type", "application/octet-stream")
.header("payment-response", SETTLEMENT_HEADER)
.body(body);
})
}

/// Deploy the native asset contract and create the pay-to account, returning
/// `(asset contract id, pay-to address)`.
fn setup(sandbox: &TestEnv) -> (String, String) {
deploy_sac(sandbox, "native", "test");
(sac_id(sandbox, "native"), new_account(sandbox, "merchant"))
}

#[tokio::test]
async fn build_only_emits_a_proof_authorizing_only_the_transfer() {
let sandbox = &TestEnv::new();
let (asset, pay_to) = setup(sandbox);
let payer = test_address(sandbox);
let amount: i128 = 1_000_000;

let server = MockServer::start();
let challenge_mock = mock_challenge(&server, &challenge(&asset, &pay_to, amount));
let paid_mock = mock_paid(&server, b"paid");

let stdout = sandbox
.new_assert_cmd("checkout")
.args([&server.url("/pay"), "--build-only", "--output", "json"])
.assert()
.success()
.stdout_as_str();
challenge_mock.assert();
paid_mock.assert_hits(0);

let proof: Value = serde_json::from_str(&stdout).unwrap();
assert!(proof["payment_signature"].as_str().is_some());
assert_eq!(proof["payload"]["accepted"]["payTo"], json!(pay_to));

let transaction = proof["payload"]["payload"]["transaction"].as_str().unwrap();
let TransactionEnvelope::Tx(envelope) =
TransactionEnvelope::from_xdr_base64(transaction, Limits::none()).unwrap()
else {
panic!("expected a v1 transaction envelope");
};
assert!(envelope.signatures.is_empty());
let Preconditions::Time(time_bounds) = &envelope.tx.cond else {
panic!("expected time bounds, got {:?}", envelope.tx.cond);
};
assert!(time_bounds.max_time.0 > 0);

let [op] = envelope.tx.operations.as_slice() else {
panic!("expected a single operation");
};
let OperationBody::InvokeHostFunction(op) = &op.body else {
panic!("expected an invoke host function operation");
};
let HostFunction::InvokeContract(call) = &op.host_function else {
panic!("expected a contract call");
};
assert_eq!(call.contract_address.to_string(), asset);
assert_eq!(call.function_name.to_string(), "transfer");
let [ScVal::Address(from), ScVal::Address(to), value] = call.args.as_slice() else {
panic!("expected transfer(from, to, amount), got {:?}", call.args);
};
assert_eq!(from.to_string(), payer);
assert_eq!(to.to_string(), pay_to);
assert_eq!(
*value,
ScVal::I128(Int128Parts {
hi: 0,
lo: u64::try_from(amount).unwrap()
})
);

let [auth] = op.auth.as_slice() else {
panic!("expected a single authorization entry");
};
let SorobanCredentials::Address(credentials) = &auth.credentials else {
panic!("expected address credentials");
};
assert_ne!(credentials.signature, ScVal::Void);
assert!(auth.root_invocation.sub_invocations.is_empty());
}

#[tokio::test]
async fn payment_over_the_cap_is_refused_before_signing() {
let sandbox = &TestEnv::new();
let (asset, pay_to) = setup(sandbox);

let server = MockServer::start();
let challenge_mock = mock_challenge(&server, &challenge(&asset, &pay_to, 20_000_000));
let paid_mock = mock_paid(&server, b"paid");

let stdout = sandbox
.new_assert_cmd("checkout")
.args([&server.url("/pay"), "--output", "json"])
.assert()
.failure()
.stdout_as_str();
challenge_mock.assert();
paid_mock.assert_hits(0);

let error: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(error["error"]["type"], json!("max_amount_exceeded"));
}

#[tokio::test]
async fn paid_retry_returns_the_resource_and_receipt() {
let sandbox = &TestEnv::new();
let (asset, pay_to) = setup(sandbox);
let body = [0x89, b'P', b'N', b'G', 0xff, 0x00, b'\n', 0xfe];

let server = MockServer::start();
let challenge_mock = mock_challenge(&server, &challenge(&asset, &pay_to, 1_000_000));
let paid_mock = mock_paid(&server, &body);

let output = sandbox
.new_assert_cmd("checkout")
.arg(server.url("/pay"))
.assert()
.success();
assert_eq!(output.get_output().stdout, body);

let stdout = sandbox
.new_assert_cmd("checkout")
.args([&server.url("/pay"), "--output", "json"])
.assert()
.success()
.stdout_as_str();
challenge_mock.assert_hits(2);
paid_mock.assert_hits(2);

let receipt: Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(receipt["tx_hash"], json!("abc123"));
assert_eq!(receipt["network"], json!("stellar:local"));
assert_eq!(receipt["resource"]["status"], json!(200));
assert_eq!(
receipt["resource"]["content_type"],
json!("application/octet-stream")
);
}
2 changes: 2 additions & 0 deletions cmd/soroban-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ fn json_error_format(cmd: &commands::Cmd) -> Option<crate::output::Format> {
commands::Cmd::Token(token::Cmd::Clawback(cmd)) => cmd.output.into(),
commands::Cmd::Token(token::Cmd::SetAdmin(cmd)) => cmd.output.into(),
commands::Cmd::Token(token::Cmd::SetAuthorized(cmd)) => cmd.output.into(),
commands::Cmd::Checkout(cmd) => cmd.output.into(),
_ => return None,
};

Expand All @@ -168,6 +169,7 @@ fn json_error_format(cmd: &commands::Cmd) -> Option<crate::output::Format> {
fn error_type(err: &commands::Error) -> &'static str {
match err {
commands::Error::Token(e) => e.error_type(),
commands::Error::Checkout(e) => e.error_type(),
_ => "unknown",
}
}
Expand Down
Loading
Loading