Skip to content

Commit db6816b

Browse files
test(platform-wallet-ffi): cover the deliverable-amount classification
Review note on dashpay#4324: this filter-and-match is the authoritative calculation behind the amount a host quotes, and no Rust test exercised it. The Kotlin tests feed the registration blob a caller-chosen scalar, so they prove the value survives the wire but cannot catch a regression in OP_RETURN classification, output ordering, or the zero-for-ambiguous result. Extract it as `sole_deliverable_value(&[TxOut]) -> u64` and test the cases that matter: a lone destination; a destination beside a data carrier in BOTH orders (Maya puts its memo at VOUT1, and nothing here may depend on that); two spendable outputs, alone and beside a carrier; an OP_RETURN-only build and an empty output set; and a value-bearing OP_RETURN, which reports 0 because an asset lock's burn is not a payee a host would quote. 258 platform-wallet-ffi tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 19202ef commit db6816b

1 file changed

Lines changed: 100 additions & 12 deletions

File tree

‎packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs‎

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::types::{FFINetwork, Network};
66
use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return};
77
use dashcore::blockdata::transaction::special_transaction::TransactionPayload;
88
use dashcore::hashes::Hash;
9-
use dashcore::{Address as DashAddress, OutPoint, Txid};
9+
use dashcore::{Address as DashAddress, OutPoint, TxOut, Txid};
1010
use key_wallet::account::ManagedAccountCollection;
1111
use key_wallet::managed_account::ManagedCoreFundsAccount;
1212
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy;
@@ -215,6 +215,24 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize(
215215
/// `core_wallet_transaction_free`). `out_bytes_ptr`/`out_bytes_len` borrow
216216
/// `out_tx`'s buffer — copy them out before freeing `out_tx`.
217217
///
218+
/// Value of the sole non-OP_RETURN output: what a broadcast of this
219+
/// transaction actually pays out.
220+
///
221+
/// Returns 0 when there is no single such output. A multi-recipient build has
222+
/// no one deliverable amount, and an OP_RETURN-only build pays no one — hosts
223+
/// read the 0 as "not applicable" rather than "pays nothing", so the two cases
224+
/// need not be told apart here.
225+
///
226+
/// Output ORDER is deliberately irrelevant: a MAYAChain deposit carries its
227+
/// memo at VOUT1, while other layouts put the data carrier first.
228+
fn sole_deliverable_value(outputs: &[TxOut]) -> u64 {
229+
let mut carriers = outputs.iter().filter(|out| !out.script_pubkey.is_op_return());
230+
match (carriers.next(), carriers.next()) {
231+
(Some(only), None) => only.value,
232+
_ => 0,
233+
}
234+
}
235+
218236
/// Also writes `out_deliverable_duffs`: the value of the sole non-OP_RETURN
219237
/// output of the REGISTERED transaction — what a later broadcast actually
220238
/// pays out. Hosts need it for a drain (`SelectionStrategy::All`), where the
@@ -358,17 +376,7 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize(
358376
// pay. Defined only for a single-destination payment: exactly one output
359377
// that is not an OP_RETURN data carrier. Anything else reports 0, which the
360378
// host reads as "not applicable" rather than "pays nothing".
361-
let deliverable_duffs = {
362-
let mut carriers = finalized
363-
.transaction()
364-
.output
365-
.iter()
366-
.filter(|out| !out.script_pubkey.is_op_return());
367-
match (carriers.next(), carriers.next()) {
368-
(Some(only), None) => only.value,
369-
_ => 0,
370-
}
371-
};
379+
let deliverable_duffs = sole_deliverable_value(&finalized.transaction().output);
372380
unsafe { *out_deliverable_duffs = deliverable_duffs };
373381

374382
let serialized = dashcore::consensus::serialize(finalized.transaction());
@@ -852,3 +860,83 @@ pub unsafe extern "C" fn core_wallet_transaction_free(tx: *mut FFICoreTransactio
852860
tx.tx_bytes = std::ptr::null_mut();
853861
tx.tx_len = 0;
854862
}
863+
864+
865+
#[cfg(test)]
866+
mod tests {
867+
use super::sole_deliverable_value;
868+
use dashcore::blockdata::script::ScriptBuf;
869+
use dashcore::TxOut;
870+
871+
/// A spendable output. The script only has to NOT be an OP_RETURN.
872+
fn destination(value: u64) -> TxOut {
873+
TxOut {
874+
value,
875+
script_pubkey: ScriptBuf::from(vec![0x76, 0xa9, 0x14]),
876+
}
877+
}
878+
879+
fn op_return(payload: &[u8]) -> TxOut {
880+
let data = dashcore::script::PushBytesBuf::try_from(payload.to_vec())
881+
.expect("test payload is within push limits");
882+
TxOut {
883+
value: 0,
884+
script_pubkey: ScriptBuf::new_op_return(&data),
885+
}
886+
}
887+
888+
#[test]
889+
fn a_lone_destination_is_the_deliverable_amount() {
890+
assert_eq!(sole_deliverable_value(&[destination(27_442_985)]), 27_442_985);
891+
}
892+
893+
/// The MAYAChain shape: vault output plus a zero-value memo. The memo must
894+
/// not be mistaken for a second recipient, in EITHER order — Maya puts the
895+
/// memo at VOUT1, but nothing in the calculation may depend on that.
896+
#[test]
897+
fn a_data_carrier_beside_the_destination_is_ignored_in_both_orders() {
898+
let memo = op_return(b"=:MAYA.CACAO:maya1abc");
899+
assert_eq!(
900+
sole_deliverable_value(&[destination(27_442_985), memo.clone()]),
901+
27_442_985,
902+
"memo after the destination (the Maya layout)"
903+
);
904+
assert_eq!(
905+
sole_deliverable_value(&[memo, destination(27_442_985)]),
906+
27_442_985,
907+
"memo before the destination"
908+
);
909+
}
910+
911+
/// Two recipients have no single deliverable amount. Reporting either one
912+
/// would let a host quote a number the payment does not pay.
913+
#[test]
914+
fn two_spendable_outputs_report_zero() {
915+
assert_eq!(sole_deliverable_value(&[destination(1_000), destination(2_000)]), 0);
916+
}
917+
918+
#[test]
919+
fn two_spendable_outputs_report_zero_even_beside_a_data_carrier() {
920+
assert_eq!(
921+
sole_deliverable_value(&[destination(1_000), op_return(b"x"), destination(2_000)]),
922+
0
923+
);
924+
}
925+
926+
/// An OP_RETURN-only build pays no one; so does an empty output set.
927+
#[test]
928+
fn a_transaction_with_no_spendable_output_reports_zero() {
929+
assert_eq!(sole_deliverable_value(&[op_return(b"data only")]), 0);
930+
assert_eq!(sole_deliverable_value(&[]), 0);
931+
}
932+
933+
/// An asset lock's single output IS an OP_RETURN, so it reports 0 rather
934+
/// than its burn value. That is the intended reading: the credits go to an
935+
/// identity, not to a payee a host would quote.
936+
#[test]
937+
fn an_op_return_carrying_value_still_reports_zero() {
938+
let mut burn = op_return(b"credits");
939+
burn.value = 500_000;
940+
assert_eq!(sole_deliverable_value(&[burn]), 0);
941+
}
942+
}

0 commit comments

Comments
 (0)