open-standards/openpay/crates/op-crypto-extra/src/psbt.rs
Transaction Science 68b8a2dbee ingest wave F: 3 crates · op-crypto-extra + smart-byte-acdc + eoc-memory
OpenPay (1 crate, 3751 LOC, 83 tests)
  op-crypto-extra      Extended crypto rails. Stablecoin catalog (USDC, USDT,
                       PYUSD, DAI, RLUSD, EURC, FDUSD — 26 deployments). L2
                       catalog (12 chains incl. Optimism, Arbitrum, Base,
                       zkSync Era, Polygon zkEVM, Linea, Scroll, Starknet).
                       CCTP v2 BurnMessage encode/decode + domain ids +
                       attestation. ERC-4337 UserOperation v0.7 +
                       PackedUserOperation. EIP-7702 set-EOA-code with RLP
                       signing preimage. Uniswap Permit2 single + batch
                       transfer EIP-712 hashes. EIP-2612 ERC-20 permit.
                       BOLT-11 bech32 invoices + LNURL decode. Bitcoin
                       PSBT v2 (BIP-370). Atomic-swap HTLC. Solana SPL /
                       Aleo / Aztec confidential-transfer sketches.

Smart Byte (1 crate, 2304 LOC, 30 tests)
  smart-byte-acdc      Authentic Chained Data Containers (KERI ecosystem).
                       Acdc + AcdcBuilder (targeted + untargeted) with SAID
                       derivation. JSON Schema 2020-12 validator with
                       oneOf partial-disclosure. TEL (RIP/VRT/ISS/REV)
                       with hash-chained verification. IPEX (apply/offer/
                       agree/grant/admit/spurn) chained by SAID. Edge
                       records (I2I/NI2I/DI2I) + EdgeGraph BFS traversal
                       with cycle detection. Selective disclosure with
                       full/none/subset plans + compact packing.
                       CredentialRegistry trait + InMemoryRegistry over
                       TEL. Composes with smart-byte-core + smart-byte-keri-witness.

EOC (1 crate, 1764 LOC, 16 tests)
  eoc-memory           Long-term memory primitives. Memory trait + MemoryKind +
                       EpisodeId. Episodic event log with TimeIndex range
                       queries. RDF-style Triple store with S/P/O indices.
                       Bounded attention-budget Scratchpad with
                       salience-weighted eviction. Rolling SummaryBuffer +
                       FIFO SlidingWindow. Dream-cycle consolidate()
                       (episodic → semantic). Ebbinghaus forgetting curve
                       with reinforcement. Retriever blending cosine
                       similarity + recency + saturating frequency.
                       MemGpt three-tier shape (Main/Recall/Archival).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:28:24 -04:00

344 lines
11 KiB
Rust

//! Bitcoin PSBT v2 (BIP-370) sketch.
//!
//! PSBT (Partially Signed Bitcoin Transaction) is a binary
//! interchange format that lets multiple parties contribute inputs,
//! outputs, signatures, and metadata to a single transaction
//! without ever needing to broadcast a partial txid. BIP-174 defines
//! v0; BIP-370 defines v2.
//!
//! The wire format is a sequence of typed key-value maps. The first
//! map is the *global* map, then one *input* map per input, then one
//! *output* map per output. Each entry is:
//! ```text
//! varint(key_len) || key_type_byte || key_data
//! varint(value_len) || value_data
//! ```
//! Maps end with a single 0x00 byte. The whole stream is prefixed
//! by the magic bytes `0x70 0x73 0x62 0x74 0xff` ("psbt\xff").
//!
//! This module is a *sketch*: it models the shape, computes the
//! magic + version, and writes / reads simple PSBT v2 envelopes
//! containing only the BIP-370 required fields. Production code
//! should reach for a full PSBT crate (e.g. `bitcoin`). The sketch
//! exists so OpenPay's settlement / dispute code can construct
//! Bitcoin-side artefacts without pulling in the Bitcoin Core C
//! deps as a hard requirement.
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// PSBT magic bytes: `0x70 0x73 0x62 0x74 0xff` = `"psbt\xff"`.
pub const PSBT_MAGIC: [u8; 5] = [0x70, 0x73, 0x62, 0x74, 0xff];
/// PSBT version, BIP-370 (v2).
pub const PSBT_V2: u32 = 2;
/// Key types in the global map that are required for PSBT v2
/// (BIP-370 §"Global Types").
pub mod global_key_type {
/// `PSBT_GLOBAL_TX_VERSION` — required in v2.
pub const TX_VERSION: u8 = 0x02;
/// `PSBT_GLOBAL_FALLBACK_LOCKTIME` — required in v2.
pub const FALLBACK_LOCKTIME: u8 = 0x03;
/// `PSBT_GLOBAL_INPUT_COUNT` — required in v2.
pub const INPUT_COUNT: u8 = 0x04;
/// `PSBT_GLOBAL_OUTPUT_COUNT` — required in v2.
pub const OUTPUT_COUNT: u8 = 0x05;
/// `PSBT_GLOBAL_TX_MODIFIABLE` — optional in v2.
pub const TX_MODIFIABLE: u8 = 0x06;
/// `PSBT_GLOBAL_VERSION`.
pub const VERSION: u8 = 0xfb;
}
/// Input-map key types (subset).
pub mod input_key_type {
/// `PSBT_IN_PREVIOUS_TXID`.
pub const PREVIOUS_TXID: u8 = 0x0e;
/// `PSBT_IN_OUTPUT_INDEX`.
pub const OUTPUT_INDEX: u8 = 0x0f;
/// `PSBT_IN_SEQUENCE`.
pub const SEQUENCE: u8 = 0x10;
/// `PSBT_IN_REQUIRED_TIME_LOCKTIME`.
pub const REQUIRED_TIME_LOCKTIME: u8 = 0x11;
/// `PSBT_IN_REQUIRED_HEIGHT_LOCKTIME`.
pub const REQUIRED_HEIGHT_LOCKTIME: u8 = 0x12;
}
/// Output-map key types (subset).
pub mod output_key_type {
/// `PSBT_OUT_AMOUNT`.
pub const AMOUNT: u8 = 0x03;
/// `PSBT_OUT_SCRIPT`.
pub const SCRIPT: u8 = 0x04;
}
/// PSBT v2 global map.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PsbtV2Global {
/// Transaction version (typically 2 for current Bitcoin).
pub tx_version: i32,
/// Fallback locktime for the unsigned tx (0 = "no locktime").
pub fallback_locktime: u32,
/// Number of inputs.
pub input_count: u64,
/// Number of outputs.
pub output_count: u64,
}
/// PSBT v2 input map (BIP-370 required-only subset).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PsbtV2Input {
/// Previous tx's 32-byte txid (little-endian wire order, as on
/// the wire).
pub previous_txid: [u8; 32],
/// Output index in `previous_txid`.
pub output_index: u32,
/// Sequence (default 0xffff_ffff if not RBF-signaling).
pub sequence: Option<u32>,
}
/// PSBT v2 output map.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PsbtV2Output {
/// Amount in satoshis.
pub amount: u64,
/// Locking script (scriptPubKey) bytes.
pub script: Vec<u8>,
}
/// PSBT v2 envelope.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PsbtV2 {
/// Global map.
pub global: PsbtV2Global,
/// Inputs in order.
pub inputs: Vec<PsbtV2Input>,
/// Outputs in order.
pub outputs: Vec<PsbtV2Output>,
}
impl PsbtV2 {
/// Construct.
#[must_use]
pub fn new(
global: PsbtV2Global,
inputs: Vec<PsbtV2Input>,
outputs: Vec<PsbtV2Output>,
) -> Self {
Self {
global,
inputs,
outputs,
}
}
/// Structural validation: counts in `global` match the actual
/// vectors; previous-txid + amount are consistent.
///
/// # Errors
/// Returns [`Error::Constraint`] on mismatch.
pub fn validate(&self) -> Result<()> {
if self.global.input_count != self.inputs.len() as u64 {
return Err(Error::Constraint {
field: "input_count",
reason: format!(
"global declares {} inputs, vector has {}",
self.global.input_count,
self.inputs.len()
),
});
}
if self.global.output_count != self.outputs.len() as u64 {
return Err(Error::Constraint {
field: "output_count",
reason: format!(
"global declares {} outputs, vector has {}",
self.global.output_count,
self.outputs.len()
),
});
}
Ok(())
}
/// Encode this PSBT v2 envelope to wire bytes.
///
/// # Errors
/// Returns [`Error::Constraint`] when the envelope fails
/// [`Self::validate`].
pub fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let mut out = Vec::with_capacity(64);
out.extend_from_slice(&PSBT_MAGIC);
// Global map.
write_kv_u32(&mut out, &[global_key_type::VERSION], PSBT_V2);
write_kv_i32(&mut out, &[global_key_type::TX_VERSION], self.global.tx_version);
write_kv_u32(
&mut out,
&[global_key_type::FALLBACK_LOCKTIME],
self.global.fallback_locktime,
);
write_kv_varint(
&mut out,
&[global_key_type::INPUT_COUNT],
self.global.input_count,
);
write_kv_varint(
&mut out,
&[global_key_type::OUTPUT_COUNT],
self.global.output_count,
);
out.push(0x00); // global map separator
// Input maps.
for inp in &self.inputs {
write_kv_bytes(&mut out, &[input_key_type::PREVIOUS_TXID], &inp.previous_txid);
write_kv_u32(
&mut out,
&[input_key_type::OUTPUT_INDEX],
inp.output_index,
);
if let Some(seq) = inp.sequence {
write_kv_u32(&mut out, &[input_key_type::SEQUENCE], seq);
}
out.push(0x00);
}
// Output maps.
for o in &self.outputs {
write_kv_u64(&mut out, &[output_key_type::AMOUNT], o.amount);
write_kv_bytes(&mut out, &[output_key_type::SCRIPT], &o.script);
out.push(0x00);
}
Ok(out)
}
/// Decode-magic-only: peek at the first 5 bytes and confirm
/// they're `psbt\xff`. Doesn't parse the rest of the envelope —
/// production parse should reach for a full PSBT crate.
///
/// # Errors
/// Returns [`Error::InvalidLayout`] when the magic doesn't
/// match.
pub fn check_magic(bytes: &[u8]) -> Result<()> {
if bytes.len() < PSBT_MAGIC.len() || &bytes[..PSBT_MAGIC.len()] != PSBT_MAGIC {
return Err(Error::InvalidLayout("psbt magic mismatch".into()));
}
Ok(())
}
}
fn write_varint(out: &mut Vec<u8>, n: u64) {
if n < 0xfd {
#[allow(clippy::cast_possible_truncation)]
out.push(n as u8);
} else if n <= u64::from(u16::MAX) {
out.push(0xfd);
#[allow(clippy::cast_possible_truncation)]
out.extend_from_slice(&(n as u16).to_le_bytes());
} else if n <= u64::from(u32::MAX) {
out.push(0xfe);
#[allow(clippy::cast_possible_truncation)]
out.extend_from_slice(&(n as u32).to_le_bytes());
} else {
out.push(0xff);
out.extend_from_slice(&n.to_le_bytes());
}
}
fn write_kv_bytes(out: &mut Vec<u8>, key: &[u8], value: &[u8]) {
write_varint(out, key.len() as u64);
out.extend_from_slice(key);
write_varint(out, value.len() as u64);
out.extend_from_slice(value);
}
fn write_kv_u32(out: &mut Vec<u8>, key: &[u8], v: u32) {
write_kv_bytes(out, key, &v.to_le_bytes());
}
fn write_kv_i32(out: &mut Vec<u8>, key: &[u8], v: i32) {
write_kv_bytes(out, key, &v.to_le_bytes());
}
fn write_kv_u64(out: &mut Vec<u8>, key: &[u8], v: u64) {
write_kv_bytes(out, key, &v.to_le_bytes());
}
fn write_kv_varint(out: &mut Vec<u8>, key: &[u8], v: u64) {
let mut value = Vec::with_capacity(9);
write_varint(&mut value, v);
write_kv_bytes(out, key, &value);
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> PsbtV2 {
PsbtV2 {
global: PsbtV2Global {
tx_version: 2,
fallback_locktime: 0,
input_count: 1,
output_count: 1,
},
inputs: vec![PsbtV2Input {
previous_txid: [0xab; 32],
output_index: 0,
sequence: Some(0xffff_fffd),
}],
outputs: vec![PsbtV2Output {
amount: 50_000,
script: vec![0x00, 0x14, 0xde, 0xad, 0xbe, 0xef],
}],
}
}
#[test]
fn validate_mismatched_counts() {
let mut p = sample();
p.global.input_count = 99;
assert!(p.validate().is_err());
}
#[test]
fn encode_starts_with_magic() {
let bytes = sample().encode().unwrap();
assert_eq!(&bytes[..5], &PSBT_MAGIC);
}
#[test]
fn encode_ends_after_outputs() {
let bytes = sample().encode().unwrap();
// Last byte should be a map separator 0x00 (output-map end).
assert_eq!(*bytes.last().unwrap(), 0x00);
}
#[test]
fn check_magic_accepts() {
let bytes = sample().encode().unwrap();
PsbtV2::check_magic(&bytes).unwrap();
}
#[test]
fn check_magic_rejects_other_bytes() {
let err = PsbtV2::check_magic(b"hello").unwrap_err();
assert!(matches!(err, Error::InvalidLayout(_)));
}
#[test]
fn varint_compact_sizes() {
let mut buf = Vec::new();
write_varint(&mut buf, 0xfc);
assert_eq!(buf, vec![0xfc]);
buf.clear();
write_varint(&mut buf, 0xfd);
assert_eq!(buf, vec![0xfd, 0xfd, 0x00]);
buf.clear();
write_varint(&mut buf, 0x1_0000);
assert_eq!(buf, vec![0xfe, 0x00, 0x00, 0x01, 0x00]);
}
}