diff --git a/.ci-config/xrpld.cfg b/.ci-config/xrpld.cfg index 47174498..ef437b4d 100644 --- a/.ci-config/xrpld.cfg +++ b/.ci-config/xrpld.cfg @@ -1,5 +1,6 @@ [server] port_rpc_admin_local +port_ws_public port_ws_admin_local # port_peer @@ -16,7 +17,7 @@ admin = 0.0.0.0 protocol = http [port_ws_public] -port = 8080 +port = 80 ip = 0.0.0.0 protocol = ws @@ -185,13 +186,23 @@ fixInvalidTxFlags # 2.5.0 Amendments PermissionedDEX TokenEscrow -SingleAssetVault -LendingProtocol PermissionDelegationV1_1 -# 3.3.0-rc2 Amendments -MPTokensV1 +# 3.1.0 Amendments +SingleAssetVault DynamicMPT +LendingProtocol +# 3.1.3 Amendments +fixCleanup3_1_3 +# 3.2.0 Amendments +fixCleanup3_2_0 +# 3.3.0 Amednments +BatchV1_1 ConfidentialTransfer +Sponsor +fixCleanup3_3_0 +# 3.4.0 Amendments +LendingProtocolV1_1 +fixCleanup3_4_0 # This section can be used to simulate various FeeSettings scenarios for rippled node in standalone mode [voting] diff --git a/CHANGELOG.md b/CHANGELOG.md index b5f9bfea..749cabeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Support for the [`LendingProtocol` amendment (XLS-66)](https://github.com/XRPLF/XRPL-Standards/pull/192): the `LoanBrokerSet`, `LoanBrokerDelete`, `LoanBrokerCoverDeposit`, `LoanBrokerCoverWithdraw`, `LoanBrokerCoverClawback`, `LoanSet`, `LoanPay`, `LoanManage` and `LoanDelete` transactions, the `Loan` and `LoanBroker` ledger objects, and `signing::sign_loan_set_by_counterparty` ([#162](https://github.com/XRPLF/xrpl-rust/pull/162)). +- Support for the `LendingProtocolV1_1` amendment ([XLS-0058](https://github.com/XRPLF/XRPL-Standards/pull/582), [XLS-0066 V1_1](https://github.com/XRPLF/XRPL-Standards/pull/587)): close-ended vaults (`VaultKind`, `SubscriptionDate` and `RedemptionDate` on `VaultCreate` and on the `Vault` ledger object), `VaultDelete.MemoData`, and `CredentialIDs` on `VaultWithdraw` and `LoanBrokerCoverWithdraw`. +- Support for the `fixCleanup3_4_0` counterparty signer prefixes: `encode_for_signing_counterparty` (`"CPT\0"`) and `encode_for_multisigning_counterparty` (`"CPM\0"`), used by `signing::sign_loan_set_by_counterparty`. A counterparty signature produced by an earlier version does not validate on a `fixCleanup3_4_0` server. +- `signing::combine_loan_set_counterparty_signers`, which merges counterparty signatures produced independently — on separate machines, say — into a single `LoanSet`. +- `signing::CounterpartySigningMode`, the third argument to `sign_loan_set_by_counterparty`: `Single`, `Multisign`, or `MultisignAs(account)` for a wallet signing with that account's RegularKey (parity with xrpl.js's `opts.multisign?: boolean | string`). +- `autofill` now covers a `LoanSet`'s counterparty signatures in the `Fee`, reading the counterparty's signer list (and resolving the counterparty from `LoanBrokerID` when the broker initiates). `Fee` is a signing field and so cannot be raised once the first party has signed, which previously made this the caller's problem. +- `ledger_entry` selectors for `Loan` and `LoanBroker` (`LoanIdentifier` and `LoanBrokerIdentifier`). - Support for [XLS-0094D DynamicMPT](https://github.com/XRPLF/XRPL-Standards/pull/583). - **XLS-0096 Confidential MPT:** support for the [XLS-0096 ConfidentialTransfer amendment](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0096-confidential-mpt). Adds the vendored `mpt-crypto` native crypto library via the internal `mpt-crypto` (safe Rust wrappers) and `mpt-crypto-sys` (FFI bindings, statically linked) crates. - **`GenericRequest`:** an untyped, catch-all request type for XRPL RPC commands that don't have a dedicated typed model yet (e.g. `ledger_accept` on standalone rippled, `server_state`). Accepts a `command` string plus a free-form `params: serde_json::Map` bag; `Serialize` flattens `params` alongside `command`/`id` and strips any collision with those reserved keys. Slots into `XRPLRequest::Generic` and the existing `Request` trait so it flows through `client.request(...)` unchanged. ### Changed +- `definitions.json` regenerated from `server_definitions` on rippled 3.4.0-rc1: adds the `LendingProtocolV1_1` fields (`VaultKind`, `SubscriptionDate`, `RedemptionDate`, `LEVersion`), the smart-contract and confidential-MPT key-epoch fields, six new transaction results, and the `SponsorshipSet` / `EscrowCreate` format updates; drops the Hooks fields, which rippled no longer supports. No existing field's code, type or signing flag changed. - **Breaking:** `XRPLSubmitAndWaitException::SubmissionFailed` changed from the tuple variant `SubmissionFailed(String)` to the struct variant `SubmissionFailed { result_code: String, message: Option }`. Callers that pattern-matched on `SubmissionFailed(msg)` must now match `SubmissionFailed { result_code, message }` — the code (`temBAD_SIGNATURE`, `tecUNFUNDED_PAYMENT`, `txnNotFound`, ...) is available without substring-parsing the `Display` string. See [#371](https://github.com/XRPLF/xrpl-rust/issues/371) for the follow-up on typing the code itself. - Both polling-timeout paths in `wait_for_final_transaction_result` now surface `XRPLSubmitAndWaitException::SubmissionTimeout` (retaining the ledger-sequence context). Previously the retry-cap branch (`c > 20`) returned `SubmissionTimeout` while the after-loop fall-through returned `SubmissionFailed { "submission_timeout" }`; `SubmissionFailed` is now reserved for definite rippled result codes. diff --git a/proptest-regressions/models/transactions/account_delete.txt b/proptest-regressions/models/transactions/account_delete.txt new file mode 100644 index 00000000..48ed11b6 --- /dev/null +++ b/proptest-regressions/models/transactions/account_delete.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 98d6525f48ecdceefc3011f86b79524f2cff7fdacb0f866cde56124f1724e5c4 # shrinks to count = 1 diff --git a/src/asynch/transaction/mod.rs b/src/asynch/transaction/mod.rs index fdd47cba..c9aeb076 100644 --- a/src/asynch/transaction/mod.rs +++ b/src/asynch/transaction/mod.rs @@ -17,8 +17,14 @@ use crate::{ }, core::binarycodec::encode, models::{ - requests::{server_state::ServerState, submit::Submit}, - results::{server_state::ServerState as ServerStateResult, submit::Submit as SubmitResult}, + requests::{ + account_info::AccountInfo, ledger_entry::LedgerEntry, server_state::ServerState, + submit::Submit, + }, + results::{ + ledger_entry::LedgerEntry as LedgerEntryResult, + server_state::ServerState as ServerStateResult, submit::Submit as SubmitResult, + }, transactions::{Transaction, TransactionType}, Model, XRPAmount, XRPLModelException, }, @@ -194,6 +200,18 @@ where }; } let mut base_fee_decimal: BigDecimal = base_fee.try_into()?; + // A `LoanSet` also pays one reference base fee per counterparty signer + // (rippled `LoanSet::calculateBaseFee`: `normalCost + signerCount * base`). + // `Fee` is a signing field, so it cannot be raised after the first party + // signs — it has to be right here, before the transaction is distributed + // for counterparty signing. + if transaction.get_transaction_type() == &TransactionType::LoanSet { + if let Some(client) = client { + let counterparty_signers = get_counterparty_signers_count(transaction, client).await?; + let net_fee_decimal: BigDecimal = net_fee.clone().try_into()?; + base_fee_decimal += &(net_fee_decimal * BigDecimal::from(counterparty_signers)); + } + } if let Some(signers_count) = signers_count { // rippled charges one extra reference base fee per signer on top of the // transaction's own base cost (Transactor::calculateBaseFee: @@ -210,6 +228,109 @@ where .into()) } +/// The number of counterparty signatures a `LoanSet` will carry, for fee +/// purposes. +/// +/// The counterparty is the `Counterparty` account when the transaction names +/// one; otherwise the loan broker initiated it and the counterparty is the +/// broker's owner, which is read from the `LoanBroker` ledger entry. An account +/// with a signer list contributes one signature per signer entry; an account +/// without one signs once. +/// +/// This is an upper bound, not the exact count. rippled charges on the +/// signatures the transaction actually carries +/// (`max(1, |CounterpartySignature.Signers|)`), but those do not exist yet at +/// autofill time — the counterparty signs later, possibly on another machine — +/// so the signer list is the only thing available to size the fee from. A +/// counterparty that owns a signer list but single-signs therefore overpays by +/// one base fee per unused signer entry; it never underpays, which would cost a +/// `telINSUF_FEE_P` and a re-signing round trip, since `Fee` is a signing field. +/// Callers who know the exact count can set `Fee` themselves before calling +/// `autofill`, which skips this calculation entirely. +/// +/// Mirrors `fetchCounterPartySignersCount` in xrpl.js's `autofill`, which sizes +/// the fee the same way. +async fn get_counterparty_signers_count<'a, T, F, C>( + transaction: &T, + client: &C, +) -> XRPLHelperResult +where + T: Transaction<'a, F>, + F: IntoEnumIterator + Serialize + Debug + PartialEq, + C: XRPLAsyncClient, +{ + fn unquote(value: String) -> String { + value.trim_matches('"').to_string() + } + + let counterparty = match transaction.get_field_value("Counterparty")? { + Some(counterparty) => unquote(counterparty), + None => { + let loan_broker_id = transaction + .get_field_value("LoanBrokerID")? + .map(unquote) + .ok_or_else(|| { + XRPLModelException::MissingField( + "LoanBrokerID (required to resolve the LoanSet counterparty)".to_string(), + ) + })?; + let response = client + .request( + LedgerEntry { + index: Some(loan_broker_id.into()), + ..Default::default() + } + .into(), + ) + .await?; + let result: LedgerEntryResult = response.try_into()?; + let node = result.node.ok_or_else(|| { + XRPLModelException::MissingField("LoanBroker ledger entry".to_string()) + })?; + node["Owner"] + .as_str() + .ok_or_else(|| XRPLModelException::MissingField("LoanBroker.Owner".to_string()))? + .to_string() + } + }; + + let response = client + .request( + AccountInfo::new( + None, + counterparty.into(), + None, + None, + None, + None, + Some(true), + ) + .into(), + ) + .await?; + let result = response + .raw_result + .ok_or_else(|| XRPLModelException::MissingField("account_info result".to_string()))?; + + // `signer_lists` sits at the root of the response under api_version 2 but + // nested inside `account_data` under api_version 1, which is what rippled + // serves when the request does not ask for a version — so accept either. + let signer_lists = result.get("signer_lists").or_else(|| { + result + .get("account_data") + .and_then(|d| d.get("signer_lists")) + }); + + // An account without a signer list signs once; otherwise it contributes one + // signature per signer entry. + Ok(signer_lists + .and_then(|lists| lists.get(0)) + .and_then(|list| list.get("SignerEntries")) + .and_then(|entries| entries.as_array()) + .map(|entries| entries.len() as u32) + .unwrap_or(1)) +} + async fn get_owner_reserve_from_response( client: &impl XRPLAsyncClient, ) -> XRPLHelperResult> { diff --git a/src/core/binarycodec/binary_wrappers.rs b/src/core/binarycodec/binary_wrappers.rs index 65a700f1..e3194469 100644 --- a/src/core/binarycodec/binary_wrappers.rs +++ b/src/core/binarycodec/binary_wrappers.rs @@ -691,6 +691,14 @@ impl Iterator for BinaryParser { pub(crate) const TRANSACTION_SIGNATURE_PREFIX: i32 = 0x53545800; pub(crate) const TRANSACTION_MULTISIG_PREFIX: [u8; 4] = (0x534D5400u32).to_be_bytes(); +/// Inner transaction to sign as the counterparty — "CPT\0" (`fixCleanup3_4_0`). +/// +/// Each signature role covers a distinct prefix so a signature cannot be +/// replayed in another role (see rippled `signingPrefix`). +pub(crate) const COUNTERPARTY_TRANSACTION_SIGNATURE_PREFIX: [u8; 4] = (0x43505400u32).to_be_bytes(); +/// Inner transaction to multi-sign as the counterparty — "CPM\0" +/// (`fixCleanup3_4_0`). +pub(crate) const COUNTERPARTY_TRANSACTION_MULTISIG_PREFIX: [u8; 4] = (0x43504D00u32).to_be_bytes(); pub(crate) const PAYMENT_CHANNEL_CLAIM_PREFIX: [u8; 4] = (0x434C4D00u32).to_be_bytes(); pub(crate) const BATCH_PREFIX: [u8; 4] = (0x42434800u32).to_be_bytes(); diff --git a/src/core/binarycodec/definitions/definitions.json b/src/core/binarycodec/definitions/definitions.json index 7f0ebd7a..3b448421 100644 --- a/src/core/binarycodec/definitions/definitions.json +++ b/src/core/binarycodec/definitions/definitions.json @@ -148,46 +148,6 @@ "type": "UInt16" } ], - [ - "HookStateChangeCount", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 17, - "type": "UInt16" - } - ], - [ - "HookEmitCount", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 18, - "type": "UInt16" - } - ], - [ - "HookExecutionIndex", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 19, - "type": "UInt16" - } - ], - [ - "HookApiVersion", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 20, - "type": "UInt16" - } - ], [ "LedgerFixType", { @@ -648,26 +608,6 @@ "type": "UInt32" } ], - [ - "HookStateCount", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 45, - "type": "UInt32" - } - ], - [ - "EmitGeneration", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 46, - "type": "UInt32" - } - ], [ "VoteWeight", { @@ -928,6 +868,116 @@ "type": "UInt32" } ], + [ + "SubscriptionDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 75, + "type": "UInt32" + } + ], + [ + "RedemptionDate", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 76, + "type": "UInt32" + } + ], + [ + "IssuerKeyEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 77, + "type": "UInt32" + } + ], + [ + "AuditorKeyEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 78, + "type": "UInt32" + } + ], + [ + "IssuerKeyMirrorEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 79, + "type": "UInt32" + } + ], + [ + "AuditorKeyMirrorEpoch", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 80, + "type": "UInt32" + } + ], + [ + "GasLimit", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 81, + "type": "UInt32" + } + ], + [ + "BytecodeSizeLimit", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 82, + "type": "UInt32" + } + ], + [ + "GasPrice", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 83, + "type": "UInt32" + } + ], + [ + "Gas", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 84, + "type": "UInt32" + } + ], + [ + "GasUsed", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 85, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1058,36 +1108,6 @@ "type": "UInt64" } ], - [ - "HookOn", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 16, - "type": "UInt64" - } - ], - [ - "HookInstructionCount", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 17, - "type": "UInt64" - } - ], - [ - "HookReturnCode", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 18, - "type": "UInt64" - } - ], [ "ReferenceCount", { @@ -1518,46 +1538,6 @@ "type": "Hash256" } ], - [ - "HookStateKey", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 30, - "type": "Hash256" - } - ], - [ - "HookHash", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 31, - "type": "Hash256" - } - ], - [ - "HookNamespace", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 32, - "type": "Hash256" - } - ], - [ - "HookSetTxnID", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 33, - "type": "Hash256" - } - ], [ "DomainID", { @@ -1948,6 +1928,16 @@ "type": "Amount" } ], + [ + "FeeAmountDelta", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 34, + "type": "Amount" + } + ], [ "PublicKey", { @@ -2148,46 +2138,6 @@ "type": "Blob" } ], - [ - "HookStateData", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": true, - "nth": 22, - "type": "Blob" - } - ], - [ - "HookReturnString", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": true, - "nth": 23, - "type": "Blob" - } - ], - [ - "HookParameterName", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": true, - "nth": 24, - "type": "Blob" - } - ], - [ - "HookParameterValue", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": true, - "nth": 25, - "type": "Blob" - } - ], [ "DIDDocument", { @@ -2398,6 +2348,16 @@ "type": "Blob" } ], + [ + "Bytecode", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 47, + "type": "Blob" + } + ], [ "Account", { @@ -2508,16 +2468,6 @@ "type": "AccountID" } ], - [ - "HookAccount", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": true, - "nth": 16, - "type": "AccountID" - } - ], [ "OtherChainSource", { @@ -2838,6 +2788,26 @@ "type": "Int32" } ], + [ + "RemainingOwnerCountDelta", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 2, + "type": "Int32" + } + ], + [ + "VMReturnCode", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 3, + "type": "Int32" + } + ], [ "TransactionMetaData", { @@ -2958,16 +2928,6 @@ "type": "STObject" } ], - [ - "Hook", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 14, - "type": "STObject" - } - ], [ "Permission", { @@ -3008,56 +2968,6 @@ "type": "STObject" } ], - [ - "EmittedTxn", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 20, - "type": "STObject" - } - ], - [ - "HookExecution", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 21, - "type": "STObject" - } - ], - [ - "HookDefinition", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 22, - "type": "STObject" - } - ], - [ - "HookParameter", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 23, - "type": "STObject" - } - ], - [ - "HookGrant", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 24, - "type": "STObject" - } - ], [ "VoteEntry", { @@ -3278,16 +3188,6 @@ "type": "STArray" } ], - [ - "Hooks", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 11, - "type": "STArray" - } - ], [ "VoteSlots", { @@ -3328,36 +3228,6 @@ "type": "STArray" } ], - [ - "HookExecutions", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 18, - "type": "STArray" - } - ], - [ - "HookParameters", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 19, - "type": "STArray" - } - ], - [ - "HookGrants", - { - "isSerialized": true, - "isSigningField": true, - "isVLEncoded": false, - "nth": 20, - "type": "STArray" - } - ], [ "XChainClaimAttestations", { @@ -3509,32 +3379,32 @@ } ], [ - "TickSize", + "LEVersion", { "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 16, + "nth": 6, "type": "UInt8" } ], [ - "UNLModifyDisabling", + "TickSize", { "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 17, + "nth": 16, "type": "UInt8" } ], [ - "HookResult", + "UNLModifyDisabling", { "isSerialized": true, "isSigningField": true, "isVLEncoded": false, - "nth": 18, + "nth": 17, "type": "UInt8" } ], @@ -3558,6 +3428,26 @@ "type": "UInt8" } ], + [ + "ContractResult", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 21, + "type": "UInt8" + } + ], + [ + "VaultKind", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 22, + "type": "UInt8" + } + ], [ "TakerPaysCurrency", { @@ -4364,6 +4254,14 @@ "name": "FinishAfter", "optionality": 1 }, + { + "name": "Bytecode", + "optionality": 1 + }, + { + "name": "Data", + "optionality": 1 + }, { "name": "SourceTag", "optionality": 1 @@ -4426,6 +4324,18 @@ "name": "ReserveIncrementDrops", "optionality": 1 }, + { + "name": "GasLimit", + "optionality": 1 + }, + { + "name": "BytecodeSizeLimit", + "optionality": 1 + }, + { + "name": "GasPrice", + "optionality": 1 + }, { "name": "PreviousTxnID", "optionality": 1 @@ -4678,6 +4588,14 @@ "name": "AuditorEncryptedBalance", "optionality": 1 }, + { + "name": "IssuerKeyMirrorEpoch", + "optionality": 1 + }, + { + "name": "AuditorKeyMirrorEpoch", + "optionality": 1 + }, { "name": "HolderEncryptionKey", "optionality": 1 @@ -4748,6 +4666,14 @@ "name": "AuditorEncryptionKey", "optionality": 1 }, + { + "name": "IssuerKeyEpoch", + "optionality": 1 + }, + { + "name": "AuditorKeyEpoch", + "optionality": 1 + }, { "name": "ConfidentialOutstandingAmount", "optionality": 2 @@ -5219,6 +5145,22 @@ { "name": "Scale", "optionality": 2 + }, + { + "name": "LEVersion", + "optionality": 2 + }, + { + "name": "VaultKind", + "optionality": 2 + }, + { + "name": "SubscriptionDate", + "optionality": 1 + }, + { + "name": "RedemptionDate", + "optionality": 1 } ], "XChainOwnedClaimID": [ @@ -5977,6 +5919,10 @@ "name": "Destination", "optionality": 0 }, + { + "name": "DestinationTag", + "optionality": 1 + }, { "name": "Amount", "optionality": 0 @@ -5994,7 +5940,11 @@ "optionality": 1 }, { - "name": "DestinationTag", + "name": "Bytecode", + "optionality": 1 + }, + { + "name": "Data", "optionality": 1 } ], @@ -6018,6 +5968,10 @@ { "name": "CredentialIDs", "optionality": 1 + }, + { + "name": "Gas", + "optionality": 1 } ], "LedgerStateFix": [ @@ -6070,6 +6024,10 @@ { "name": "DestinationTag", "optionality": 1 + }, + { + "name": "CredentialIDs", + "optionality": 1 } ], "LoanBrokerDelete": [ @@ -6584,6 +6542,18 @@ { "name": "ReserveIncrementDrops", "optionality": 1 + }, + { + "name": "GasLimit", + "optionality": 1 + }, + { + "name": "BytecodeSizeLimit", + "optionality": 1 + }, + { + "name": "GasPrice", + "optionality": 1 } ], "SetRegularKey": [ @@ -6612,7 +6582,7 @@ "optionality": 1 }, { - "name": "FeeAmount", + "name": "FeeAmountDelta", "optionality": 1 }, { @@ -6620,7 +6590,7 @@ "optionality": 1 }, { - "name": "RemainingOwnerCount", + "name": "RemainingOwnerCountDelta", "optionality": 1 } ], @@ -6710,6 +6680,18 @@ { "name": "Scale", "optionality": 1 + }, + { + "name": "VaultKind", + "optionality": 1 + }, + { + "name": "SubscriptionDate", + "optionality": 1 + }, + { + "name": "RedemptionDate", + "optionality": 1 } ], "VaultDelete": [ @@ -6766,6 +6748,10 @@ { "name": "DestinationTag", "optionality": 1 + }, + { + "name": "CredentialIDs", + "optionality": 1 } ], "XChainAccountCreateCommit": [ @@ -7050,6 +7036,7 @@ "tecARRAY_TOO_LARGE": 191, "tecBAD_CREDENTIALS": 193, "tecBAD_PROOF": 199, + "tecBYTECODE_REJECTED": 202, "tecCANT_ACCEPT_OWN_NFTOKEN_OFFER": 158, "tecCLAIM": 100, "tecCRYPTOCONDITION_ERROR": 146, @@ -7093,6 +7080,7 @@ "tecNO_SUITABLE_NFTOKEN_PAGE": 155, "tecNO_TARGET": 138, "tecOBJECT_NOT_FOUND": 160, + "tecOUT_OF_GAS": 201, "tecOVERSIZE": 145, "tecOWNERS": 132, "tecPATH_DRY": 128, @@ -7132,6 +7120,7 @@ "tefBAD_PATH_COUNT": -176, "tefBAD_QUORUM": -185, "tefBAD_SIGNATURE": -186, + "tefBYTECODE_NOT_INCLUDED": -174, "tefCREATED": -194, "tefEXCEPTION": -193, "tefFAILURE": -199, @@ -7143,6 +7132,7 @@ "tefNFTOKEN_IS_NOT_TRANSFERABLE": -179, "tefNOT_MULTI_SIGNING": -184, "tefNO_AUTH_REQUIRED": -191, + "tefNO_BYTECODE": -175, "tefNO_DST_PARTIAL": -177, "tefNO_TICKET": -180, "tefPAST_SEQ": -190, @@ -7202,6 +7192,7 @@ "temEMPTY_DID": -254, "temINVALID": -277, "temINVALID_ACCOUNT_ID": -268, + "temINVALID_BYTECODE": -247, "temINVALID_COUNT": -266, "temINVALID_FLAG": -276, "temINVALID_INNER_BATCH": -250, @@ -7209,6 +7200,7 @@ "temREDUNDANT": -275, "temRIPPLE_EMPTY": -274, "temSEQ_AND_TICKET": -263, + "temTEMP_DISABLED": -246, "temUNCERTAIN": -265, "temUNKNOWN": -264, "temXCHAIN_BAD_PROOF": -259, @@ -7354,5 +7346,5 @@ "Vector256": 19, "XChainBridge": 25 }, - "hash": "8EB5E0E1DCEC6C6AAD9BB299B58DD4FF88A6D8F6982813E943BC9931B4E7A2AA" + "hash": "649F3CE0716C45E7C7DAD2158067DE7648279BDA4021762D2FFC3BA47C9238AE" } diff --git a/src/core/binarycodec/mod.rs b/src/core/binarycodec/mod.rs index 86405fbd..26438881 100644 --- a/src/core/binarycodec/mod.rs +++ b/src/core/binarycodec/mod.rs @@ -24,6 +24,7 @@ pub use binary_wrappers::*; use self::binary_wrappers::{ decode_ledger_data_inner, decode_st_object, serialize_json, BATCH_PREFIX, + COUNTERPARTY_TRANSACTION_MULTISIG_PREFIX, COUNTERPARTY_TRANSACTION_SIGNATURE_PREFIX, PAYMENT_CHANNEL_CLAIM_PREFIX, TRANSACTION_MULTISIG_PREFIX, TRANSACTION_SIGNATURE_PREFIX, }; @@ -69,6 +70,46 @@ where ) } +/// Encode a transaction for signing as the counterparty (prepends the +/// counterparty signing prefix `"CPT\0"`). +/// +/// Under `fixCleanup3_4_0` a counterparty signature — the `CounterpartySignature` +/// inner object of a `LoanSet` — covers its own signing prefix, so it cannot be +/// replayed as the transaction's own signature. +pub fn encode_for_signing_counterparty(prepared_transaction: &T) -> XRPLCoreResult +where + T: Serialize, +{ + serialize_json( + prepared_transaction, + Some(COUNTERPARTY_TRANSACTION_SIGNATURE_PREFIX.as_ref()), + None, + true, + ) +} + +/// Encode a transaction for multi-signing as the counterparty (prepends the +/// counterparty multi-sign prefix `"CPM\0"`, appends the signing account ID). +/// +/// See [`encode_for_signing_counterparty`] for why the counterparty role has its +/// own prefix. +pub fn encode_for_multisigning_counterparty( + prepared_transaction: &T, + signing_account: Cow<'_, str>, +) -> XRPLCoreResult +where + T: Serialize, +{ + let signing_account_id = AccountId::try_from(signing_account.as_ref())?; + + serialize_json( + prepared_transaction, + Some(COUNTERPARTY_TRANSACTION_MULTISIG_PREFIX.as_ref()), + Some(signing_account_id.as_ref()), + true, + ) +} + /// Encode a payment channel claim for signing. /// /// This produces the serialized data that must be signed to authorize @@ -722,4 +763,99 @@ mod test { "MPToken re-encode does not match authoritative vector" ); } + + // ── Counterparty signing prefixes (fixCleanup3_4_0) ──────────────── + // + // xrpl.js reference: packages/ripple-binary-codec/test/signing-data-encoding.test.ts + // (XRPLF/xrpl.js#3462) + + fn counterparty_loan_set<'a>() -> crate::models::transactions::loan_set::LoanSet<'a> { + crate::models::transactions::loan_set::LoanSet { + common_fields: CommonFields { + account: ACCOUNT_GENESIS.into(), + transaction_type: TransactionType::LoanSet, + fee: Some("10".into()), + sequence: Some(1), + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: "E123F4567890ABCDE123F4567890ABCDEF1234567890ABCDEF1234567890ABCD" + .into(), + principal_requested: "1000".into(), + ..Default::default() + } + } + + /// The counterparty role swaps only the 4-byte prefix: `STX\0` -> `CPT\0`. + /// The signing payload itself is unchanged, so a counterparty signature + /// cannot be replayed as the transaction's own signature. + #[test] + fn test_encode_for_signing_counterparty_swaps_only_the_prefix() { + let txn = counterparty_loan_set(); + + let base = encode_for_signing(&txn).expect("encode_for_signing failed"); + let counterparty = + encode_for_signing_counterparty(&txn).expect("encode_for_signing_counterparty failed"); + + assert!(base.starts_with("53545800"), "expected STX prefix: {base}"); + assert!( + counterparty.starts_with("43505400"), + "expected CPT prefix, got: {}", + &counterparty[..core::cmp::min(20, counterparty.len())] + ); + assert_eq!( + &counterparty[8..], + &base[8..], + "only the prefix should differ" + ); + } + + /// The multi-sign counterparty role swaps only the prefix + /// (`SMT\0` -> `CPM\0`) and keeps the signing-account suffix. + #[test] + fn test_encode_for_multisigning_counterparty_swaps_only_the_prefix() { + let txn = counterparty_loan_set(); + + let base = encode_for_multisigning(&txn, ACCOUNT_ALT.into()) + .expect("encode_for_multisigning failed"); + let counterparty = encode_for_multisigning_counterparty(&txn, ACCOUNT_ALT.into()) + .expect("encode_for_multisigning_counterparty failed"); + + assert!(base.starts_with("534D5400"), "expected SMT prefix: {base}"); + assert!( + counterparty.starts_with("43504D00"), + "expected CPM prefix, got: {}", + &counterparty[..core::cmp::min(20, counterparty.len())] + ); + assert_eq!( + &counterparty[8..], + &base[8..], + "only the prefix should differ" + ); + } + + /// The `CounterpartySignature` inner object is not a signing field, so it + /// is excluded from every signing payload — the counterparty signs the same + /// terms the first party signed. + #[test] + fn test_counterparty_signature_excluded_from_signing_payload() { + use crate::models::transactions::loan_set::CounterpartySignature; + + let unsigned = counterparty_loan_set(); + let with_counterparty_signature = crate::models::transactions::loan_set::LoanSet { + counterparty_signature: Some(CounterpartySignature { + signing_pub_key: Some( + "ED9434799226374926EDA3B54B1B461B4ABF7237962EAE18528FEA67595397FA32".into(), + ), + txn_signature: Some("DEADBEEF".into()), + signers: None, + }), + ..counterparty_loan_set() + }; + + assert_eq!( + encode_for_signing_counterparty(&unsigned).unwrap(), + encode_for_signing_counterparty(&with_counterparty_signature).unwrap(), + ); + } } diff --git a/src/models/ledger/objects/vault.rs b/src/models/ledger/objects/vault.rs index a0ddd85f..a50dfe12 100644 --- a/src/models/ledger/objects/vault.rs +++ b/src/models/ledger/objects/vault.rs @@ -77,6 +77,21 @@ pub struct Vault<'a> { /// The index of the ledger that contains the transaction that most recently modified /// this object. pub previous_txn_lgr_seq: u32, + /// (LendingProtocolV1_1) The internal version of the ledger object's + /// structure. (SoeDefault) + #[serde(rename = "LEVersion")] + pub le_version: Option, + /// (LendingProtocolV1_1) The kind of vault: 0 for an open-ended vault or 1 + /// for a close-ended vault. (SoeDefault) + pub vault_kind: Option, + /// (LendingProtocolV1_1, close-ended vaults only) The time, in seconds + /// since the Ripple Epoch, up to which deposits into the vault are + /// accepted. (SoeOptional) + pub subscription_date: Option, + /// (LendingProtocolV1_1, close-ended vaults only) The time, in seconds + /// since the Ripple Epoch, at which shares may begin to be redeemed from + /// the vault. (SoeOptional) + pub redemption_date: Option, } impl<'a> Model for Vault<'a> {} @@ -125,6 +140,10 @@ impl<'a> Vault<'a> { owner_node, previous_txn_id, previous_txn_lgr_seq, + le_version: None, + vault_kind: None, + subscription_date: None, + redemption_date: None, } } } @@ -191,6 +210,10 @@ mod test_serde { previous_txn_id: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890" .into(), previous_txn_lgr_seq: 12345678, + le_version: None, + vault_kind: None, + subscription_date: None, + redemption_date: None, }; let serialized = serde_json::to_string(&vault).unwrap(); @@ -246,6 +269,10 @@ mod test_serde { previous_txn_id: "FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321FEDCBA0987654321" .into(), previous_txn_lgr_seq: 99999999, + le_version: None, + vault_kind: None, + subscription_date: None, + redemption_date: None, }; let serialized = serde_json::to_string(&vault).unwrap(); @@ -300,6 +327,10 @@ mod test_serde { previous_txn_id: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890" .into(), previous_txn_lgr_seq: 1, + le_version: None, + vault_kind: None, + subscription_date: None, + redemption_date: None, }; let serialized = serde_json::to_string(&vault).unwrap(); let deserialized: Vault = serde_json::from_str(&serialized).unwrap(); @@ -336,6 +367,10 @@ mod test_serde { previous_txn_id: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890" .into(), previous_txn_lgr_seq: 100, + le_version: None, + vault_kind: None, + subscription_date: None, + redemption_date: None, }; let json = serde_json::to_string(&vault).unwrap(); @@ -389,4 +424,78 @@ mod test_serde { "missing LedgerEntryType=Vault: {json}" ); } + + /// A close-ended vault as returned by a LendingProtocolV1_1 server. + /// + /// The V1_1 fields are `SoeDefault`/`SoeOptional`, so rippled omits them + /// for an open-ended vault — they have to round-trip both when present and + /// when absent. + #[test] + fn test_close_ended_vault_fields_deserialize() { + let json = r#"{ + "LedgerEntryType": "Vault", + "Flags": 0, + "index": "A0000000000000000000000000000000000000000000000000000000DEADBEEF", + "Owner": "rVaultOwner123", + "Account": "rVaultPseudo456", + "Asset": {"currency": "XRP"}, + "ShareMPTID": "00000012E1A1E1A1E1A1E1A1E1A1E1A1E1A1E1A1E1A1E1A1", + "WithdrawalPolicy": 1, + "Sequence": 8, + "OwnerNode": "0", + "PreviousTxnID": "9A8765B4321CDE987654321CDE987654321CDE987654321CDE987654321CDE98", + "PreviousTxnLgrSeq": 12345678, + "LEVersion": 1, + "VaultKind": 1, + "SubscriptionDate": 800000000, + "RedemptionDate": 810000000 + }"#; + + let vault: Vault = serde_json::from_str(json).expect("failed to deserialize"); + + assert_eq!(vault.le_version, Some(1)); + assert_eq!(vault.vault_kind, Some(1)); + assert_eq!(vault.subscription_date, Some(800_000_000)); + assert_eq!(vault.redemption_date, Some(810_000_000)); + + let round_tripped: Vault = + serde_json::from_str(&serde_json::to_string(&vault).unwrap()).unwrap(); + assert_eq!(vault, round_tripped); + } + + /// An open-ended vault carries none of the V1_1 fields, and they must not + /// be serialized back as nulls. + #[test] + fn test_open_ended_vault_omits_v1_1_fields() { + let vault = make_vault( + Some("A0000000000000000000000000000000000000000000000000000000DEADBEEF".into()), + "rVaultOwner123".into(), + "rVaultPseudo456".into(), + Currency::IssuedCurrency(IssuedCurrency::new("USD".into(), "rIssuer456".into())), + "00000012E1A1E1A1E1A1E1A1E1A1E1A1E1A1E1A1E1A1E1A1".into(), + 1, + 8, + "0".into(), + "9A8765B4321CDE987654321CDE987654321CDE987654321CDE987654321CDE98".into(), + 12_345_678, + ); + + assert!(vault.le_version.is_none()); + assert!(vault.vault_kind.is_none()); + assert!(vault.subscription_date.is_none()); + assert!(vault.redemption_date.is_none()); + + let serialized = serde_json::to_string(&vault).unwrap(); + for field in [ + "LEVersion", + "VaultKind", + "SubscriptionDate", + "RedemptionDate", + ] { + assert!( + !serialized.contains(field), + "{field} should be omitted when unset" + ); + } + } } diff --git a/src/models/requests/ledger_entry.rs b/src/models/requests/ledger_entry.rs index df90c3a7..34e23755 100644 --- a/src/models/requests/ledger_entry.rs +++ b/src/models/requests/ledger_entry.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use crate::core::addresscodec::is_valid_classic_address; -use crate::models::transactions::vault_common::validate_vault_id; +use crate::models::transactions::vault_common::{validate_hash256, validate_vault_id}; use crate::models::{ requests::RequestMethod, transactions::validate_credential_type, Model, XRPLModelException, XRPLModelResult, @@ -169,6 +169,40 @@ pub enum VaultIdentifier<'a> { OwnerSeq { owner: Cow<'a, str>, seq: u32 }, } +/// LoanBroker selector for a `ledger_entry` request (XLS-66 Lending Protocol). +/// +/// rippled accepts either a direct 256-bit hash object ID or an object +/// containing the loan broker's owner account and the sequence number of the +/// `LoanBrokerSet` transaction that created it (`parseLoanBroker` in +/// rippled's `LedgerEntry.cpp`). +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(untagged)] +pub enum LoanBrokerIdentifier<'a> { + /// Look up by ledger object ID (64 hex chars, nonzero). + Id(Cow<'a, str>), + /// Look up by owner account + `LoanBrokerSet` sequence number. + OwnerSeq { owner: Cow<'a, str>, seq: u32 }, +} + +/// Loan selector for a `ledger_entry` request (XLS-66 Lending Protocol). +/// +/// rippled accepts either a direct 256-bit hash object ID or an object +/// containing the loan broker's object ID and the loan's sequence number +/// (`parseLoan` in rippled's `LedgerEntry.cpp`). +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(untagged)] +pub enum LoanIdentifier<'a> { + /// Look up by ledger object ID (64 hex chars, nonzero). + Id(Cow<'a, str>), + /// Look up by LoanBroker object ID + loan sequence number. + BrokerSeq { + #[serde(rename = "loan_broker_id")] + loan_broker_id: Cow<'a, str>, + #[serde(rename = "loan_seq")] + loan_seq: u32, + }, +} + /// The ledger_entry method returns a single ledger object /// from the XRP Ledger in its raw format. See ledger formats /// for information on the different types of objects you can @@ -199,6 +233,12 @@ pub struct LedgerEntry<'a> { /// The unique identifier of a ledger. #[serde(flatten)] pub ledger_lookup: Option>, + /// Loan selector: either a 256-bit hash ID or a broker ID + loan sequence + /// pair (XLS-66). + pub loan: Option>, + /// LoanBroker selector: either a 256-bit hash ID or an owner + seq pair + /// (XLS-66). + pub loan_broker: Option>, pub offer: Option>, pub oracle: Option>, pub payment_channel: Option>, @@ -218,6 +258,45 @@ impl<'a: 'static> Model for LedgerEntry<'a> { if let Some(credential) = &self.credential { credential.get_errors()?; } + if let Some(loan_broker) = &self.loan_broker { + match loan_broker { + LoanBrokerIdentifier::Id(id) => validate_hash256("loan_broker", id)?, + LoanBrokerIdentifier::OwnerSeq { owner, seq } => { + if !is_valid_classic_address(owner) { + return Err(XRPLModelException::InvalidValue { + field: "loan_broker.owner".into(), + expected: "a valid classic account address".into(), + found: owner.as_ref().into(), + }); + } + if *seq == 0 { + return Err(XRPLModelException::InvalidValue { + field: "loan_broker.seq".into(), + expected: "a positive sequence number (> 0)".into(), + found: seq.to_string(), + }); + } + } + } + } + if let Some(loan) = &self.loan { + match loan { + LoanIdentifier::Id(id) => validate_hash256("loan", id)?, + LoanIdentifier::BrokerSeq { + loan_broker_id, + loan_seq, + } => { + validate_hash256("loan.loan_broker_id", loan_broker_id)?; + if *loan_seq == 0 { + return Err(XRPLModelException::InvalidValue { + field: "loan.loan_seq".into(), + expected: "a positive sequence number (> 0)".into(), + found: loan_seq.to_string(), + }); + } + } + } + } if let Some(vault) = &self.vault { match vault { VaultIdentifier::Id(id) => validate_vault_id(id)?, @@ -288,6 +367,12 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { if self.vault.is_some() { signing_methods += 1 } + if self.loan.is_some() { + signing_methods += 1 + } + if self.loan_broker.is_some() { + signing_methods += 1 + } if signing_methods != 1 { Err(XRPLModelException::ExpectedOneOf(&[ "index", @@ -304,6 +389,8 @@ impl<'a> LedgerEntryError for LedgerEntry<'a> { "permissioned_domain", "ticket", "vault", + "loan", + "loan_broker", ])) } else { Ok(()) @@ -340,6 +427,8 @@ impl<'a> Default for LedgerEntry<'a> { ledger_hash: None, ledger_index: None, }), + loan: None, + loan_broker: None, offer: None, oracle: None, payment_channel: None, @@ -380,6 +469,8 @@ impl<'a> LedgerEntry<'a> { account_root, check, credential, + loan: None, + loan_broker: None, payment_channel, permissioned_domain: None, deposit_preauth, @@ -417,6 +508,8 @@ impl<'a> LedgerEntry<'a> { deposit_preauth: None, directory: None, escrow: None, + loan: None, + loan_broker: None, offer: None, oracle: None, payment_channel: None, @@ -433,6 +526,54 @@ impl<'a> LedgerEntry<'a> { } } +impl<'a> LedgerEntry<'a> { + /// Look up a `Loan` object (XLS-66). + pub fn new_with_loan( + id: Option>, + binary: Option, + loan: LoanIdentifier<'a>, + ledger_hash: Option>, + ledger_index: Option>, + ) -> Self { + Self { + common_fields: CommonFields { + command: RequestMethod::LedgerEntry, + id, + }, + loan: Some(loan), + binary, + ledger_lookup: Some(LookupByLedgerRequest { + ledger_hash, + ledger_index, + }), + ..Default::default() + } + } + + /// Look up a `LoanBroker` object (XLS-66). + pub fn new_with_loan_broker( + id: Option>, + binary: Option, + loan_broker: LoanBrokerIdentifier<'a>, + ledger_hash: Option>, + ledger_index: Option>, + ) -> Self { + Self { + common_fields: CommonFields { + command: RequestMethod::LedgerEntry, + id, + }, + loan_broker: Some(loan_broker), + binary, + ledger_lookup: Some(LookupByLedgerRequest { + ledger_hash, + ledger_index, + }), + ..Default::default() + } + } +} + pub trait LedgerEntryError { #[allow(clippy::result_large_err)] fn _get_field_error(&self) -> XRPLModelResult<()>; @@ -460,7 +601,7 @@ mod test_ledger_entry_errors { }; assert_eq!( ledger_entry.validate().unwrap_err().to_string().as_str(), - "Expected one of: index, account_root, check, directory, offer, oracle, ripple_state, escrow, payment_channel, deposit_preauth, credential, permissioned_domain, ticket, vault" + "Expected one of: index, account_root, check, directory, offer, oracle, ripple_state, escrow, payment_channel, deposit_preauth, credential, permissioned_domain, ticket, vault, loan, loan_broker" ); } @@ -734,4 +875,129 @@ mod test_ledger_entry_errors { }; assert!(req.validate().is_err()); } + + // ── Loan / LoanBroker selectors (XLS-66) ─────────────────────────── + + const LOAN_BROKER_OBJECT_ID: &str = + "E123F4567890ABCDE123F4567890ABCDEF1234567890ABCDEF1234567890ABCD"; + + #[test] + fn test_loan_broker_selector_by_id() { + let req = LedgerEntry::new_with_loan_broker( + None, + None, + LoanBrokerIdentifier::Id(LOAN_BROKER_OBJECT_ID.into()), + None, + None, + ); + + assert!(req.validate().is_ok()); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!( + json["loan_broker"], + serde_json::json!(LOAN_BROKER_OBJECT_ID) + ); + } + + #[test] + fn test_loan_broker_selector_by_owner_seq() { + let req = LedgerEntry::new_with_loan_broker( + None, + None, + LoanBrokerIdentifier::OwnerSeq { + owner: "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into(), + seq: 42, + }, + None, + None, + ); + + assert!(req.validate().is_ok()); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!( + json["loan_broker"], + serde_json::json!({"owner": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", "seq": 42}) + ); + } + + #[test] + fn test_loan_selector_by_broker_and_seq() { + let req = LedgerEntry::new_with_loan( + None, + None, + LoanIdentifier::BrokerSeq { + loan_broker_id: LOAN_BROKER_OBJECT_ID.into(), + loan_seq: 1, + }, + None, + None, + ); + + assert!(req.validate().is_ok()); + let json = serde_json::to_value(&req).unwrap(); + assert_eq!( + json["loan"], + serde_json::json!({"loan_broker_id": LOAN_BROKER_OBJECT_ID, "loan_seq": 1}) + ); + } + + #[test] + fn test_invalid_loan_selector_bad_broker_id() { + let req = LedgerEntry::new_with_loan( + None, + None, + LoanIdentifier::BrokerSeq { + loan_broker_id: "notahash".into(), + loan_seq: 1, + }, + None, + None, + ); + + assert!(req.validate().is_err()); + } + + #[test] + fn test_invalid_loan_selector_zero_seq() { + let req = LedgerEntry::new_with_loan( + None, + None, + LoanIdentifier::BrokerSeq { + loan_broker_id: LOAN_BROKER_OBJECT_ID.into(), + loan_seq: 0, + }, + None, + None, + ); + + assert!(req.validate().is_err()); + } + + #[test] + fn test_invalid_loan_broker_selector_bad_owner() { + let req = LedgerEntry::new_with_loan_broker( + None, + None, + LoanBrokerIdentifier::OwnerSeq { + owner: "notanaddress".into(), + seq: 1, + }, + None, + None, + ); + + assert!(req.validate().is_err()); + } + + /// The selectors are mutually exclusive with every other lookup. + #[test] + fn test_loan_and_vault_selectors_are_exclusive() { + let req = LedgerEntry { + loan: Some(LoanIdentifier::Id(LOAN_BROKER_OBJECT_ID.into())), + vault: Some(VaultIdentifier::Id(LOAN_BROKER_OBJECT_ID.into())), + ..Default::default() + }; + + assert!(req.validate().is_err()); + } } diff --git a/src/models/transactions/account_delete.rs b/src/models/transactions/account_delete.rs index 073d006c..95d4c137 100644 --- a/src/models/transactions/account_delete.rs +++ b/src/models/transactions/account_delete.rs @@ -409,12 +409,35 @@ mod tests { ); } + /// An all-zero credential ID is not a real ledger object; rippled rejects + /// it with `temMALFORMED` under `fixCleanup3_4_0` + /// (`credentials::checkFields`). + #[test] + fn test_credential_ids_zero_id_error() { + let account_delete = AccountDelete { + common_fields: CommonFields { + account: "rWYkbWkCeg8dP6rXALnjgZSjjLyih5NXm".into(), + transaction_type: TransactionType::AccountDelete, + ..Default::default() + }, + destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe".into(), + destination_tag: None, + credential_ids: Some(alloc::vec![alloc::borrow::Cow::Owned("0".repeat(64))]), + }; + + assert!(matches!( + account_delete.get_errors().unwrap_err(), + XRPLModelException::InvalidValue { .. } + )); + } + proptest! { #![proptest_config(ProptestConfig::with_cases(100))] #[test] fn prop_credential_ids_valid_length(count in 1_usize..=8) { - let ids: alloc::vec::Vec> = (0..count) + // 1..=count, not 0..count: an all-zero credential ID is invalid. + let ids: alloc::vec::Vec> = (1..=count) .map(|i| alloc::borrow::Cow::Owned(alloc::format!("{:064X}", i))) .collect(); let tx = AccountDelete { @@ -433,7 +456,8 @@ mod tests { #[test] fn prop_credential_ids_too_many(extra in 1_usize..=20) { let count = 8 + extra; - let ids: alloc::vec::Vec> = (0..count) + // 1..=count, not 0..count: an all-zero credential ID is invalid. + let ids: alloc::vec::Vec> = (1..=count) .map(|i| alloc::borrow::Cow::Owned(alloc::format!("{:064X}", i))) .collect(); let tx = AccountDelete { diff --git a/src/models/transactions/loan_broker_cover_clawback.rs b/src/models/transactions/loan_broker_cover_clawback.rs index e73bca46..71a125b5 100644 --- a/src/models/transactions/loan_broker_cover_clawback.rs +++ b/src/models/transactions/loan_broker_cover_clawback.rs @@ -51,10 +51,10 @@ impl Model for LoanBrokerCoverClawback<'_> { match &self.amount { Some(Amount::MPTAmount(amount)) => { - Self::validate_non_zero_negative_amount(amount.value.as_ref())?; + Self::validate_non_negative_amount(amount.value.as_ref())?; } Some(Amount::IssuedCurrencyAmount(amount)) => { - Self::validate_non_zero_negative_amount(amount.value.as_ref())?; + Self::validate_non_negative_amount(amount.value.as_ref())?; } Some(Amount::XRPAmount(_)) => { return Err(XRPLModelException::InvalidValue { @@ -171,11 +171,18 @@ impl<'a> LoanBrokerCoverClawback<'a> { } Amount::MPTAmount(_) => Err(XRPLModelException::MissingField("loan_broker_id".into())), + // Unreachable in practice: `get_errors` rejects an XRP amount + // before this runs, since XRP has no issuer to claw back from. + // The arm is kept for exhaustiveness. Amount::XRPAmount(_) => Ok(()), } } - fn validate_non_zero_negative_amount(value: &str) -> Result<(), XRPLModelException> { + /// An `amount` of 0 — or no `amount` at all — means "claw back up to + /// `LoanBroker.DebtTotal * LoanBroker.CoverRateMinimum`", so only a + /// negative amount is invalid (rippled `LoanBrokerCoverClawback::preflight` + /// rejects `*amount < kZero` and treats `kZero` as "unspecified"). + fn validate_non_negative_amount(value: &str) -> Result<(), XRPLModelException> { let parsed = bigdecimal::BigDecimal::from_str(value).map_err(|_| { XRPLModelException::InvalidValueFormat { field: "amount".to_string(), @@ -184,10 +191,10 @@ impl<'a> LoanBrokerCoverClawback<'a> { } })?; - if parsed <= 0 { + if parsed < 0 { return Err(XRPLModelException::InvalidValue { field: "amount".to_string(), - expected: "a non-negative/non-zero amount".to_string(), + expected: "a non-negative amount".to_string(), found: value.to_string(), }); } @@ -579,13 +586,14 @@ mod tests { } #[test] - fn test_invalid_amount_zero() { + /// A zero `amount` is the documented way to ask for the maximum clawback + /// (`LoanBroker.DebtTotal * LoanBroker.CoverRateMinimum`), so it is valid: + /// rippled's preflight only rejects `*amount < kZero` and its doApply + /// treats a zero amount the same as an absent one. + fn test_valid_amount_zero_means_maximum() { let tx = base_tx(Some(LOAN_BROKER_ID), Some(iou("0"))); - assert!(matches!( - tx.get_errors().err(), - Some(XRPLModelException::InvalidValue { .. }) - )); + assert!(tx.get_errors().is_ok()); } #[test] diff --git a/src/models/transactions/loan_broker_cover_withdraw.rs b/src/models/transactions/loan_broker_cover_withdraw.rs index 5bd71e43..4bbe04f6 100644 --- a/src/models/transactions/loan_broker_cover_withdraw.rs +++ b/src/models/transactions/loan_broker_cover_withdraw.rs @@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use crate::models::{ - transactions::{vault_common::validate_hash256, CommonTransactionBuilder, Memo, Signer}, + transactions::{ + validate_credential_ids, vault_common::validate_hash256, CommonTransactionBuilder, Memo, + Signer, + }, Amount, FlagCollection, Model, NoFlags, ValidateCurrencies, XRPAmount, XRPLModelException, }; @@ -45,6 +48,9 @@ pub struct LoanBrokerCoverWithdraw<'a> { pub destination: Option>, /// Arbitrary tag identifying the reason for the transaction to the destination. pub destination_tag: Option, + /// The credentials to authorize the withdrawal when the destination is gated by a permissioned domain (XLS-70). + #[serde(rename = "CredentialIDs")] + pub credential_ids: Option>>, } impl Model for LoanBrokerCoverWithdraw<'_> { @@ -75,6 +81,8 @@ impl Model for LoanBrokerCoverWithdraw<'_> { validate_hash256("loan_broker_id", &self.loan_broker_id)?; + validate_credential_ids(&self.credential_ids)?; + Ok(()) } } @@ -140,6 +148,7 @@ impl<'a> LoanBrokerCoverWithdraw<'a> { amount, destination, destination_tag, + credential_ids: None, } } @@ -154,6 +163,12 @@ impl<'a> LoanBrokerCoverWithdraw<'a> { self.destination_tag = Some(destination_tag); self } + + /// Set the credentials authorizing this withdrawal. + pub fn with_credential_ids(mut self, credential_ids: Vec>) -> Self { + self.credential_ids = Some(credential_ids); + self + } } #[cfg(test)] @@ -213,6 +228,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; let default_json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"E123F4567890ABCDE123F4567890ABCDEF1234567890ABCDEF1234567890ABCD","Amount":"1000000","Destination":"rf7HPydP4ihkFkSRHWFq34b4SXRc7GvPCR","DestinationTag":32}"#; @@ -241,6 +257,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(tx.get_errors().is_ok()) @@ -259,6 +276,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("0")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(tx.get_errors().is_err()); @@ -281,6 +299,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(tx.get_errors().is_err()); @@ -303,6 +322,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(tx.get_errors().is_err()); @@ -322,6 +342,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(tx.get_errors().is_err()); @@ -342,6 +363,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(tx.get_errors().is_err()); @@ -437,6 +459,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some(DESTINATION.into()), destination_tag: None, + credential_ids: None, }; let issued = LoanBrokerCoverWithdraw { @@ -479,6 +502,7 @@ mod tests { }), destination: None, destination_tag: None, + credential_ids: None, }; assert!(matches!( @@ -500,6 +524,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("0")), destination: Some(DESTINATION.into()), destination_tag: Some(32), + credential_ids: None, }; assert!(matches!( @@ -521,6 +546,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: None, destination_tag: None, + credential_ids: None, }; assert!(tx.get_errors().is_ok()); @@ -530,4 +556,93 @@ mod tests { assert!(!json.contains("Destination")); assert!(!json.contains("DestinationTag")); } + + /// `CredentialIDs` validation (LendingProtocolV1_1). + /// + /// Mirrors `test/models/loanBrokerCoverWithdraw.test.ts` in xrpl.js. The + /// credentials authorize the withdrawal when the destination is gated by a + /// permissioned domain (XLS-70). + mod credential_ids { + use super::*; + use alloc::vec; + + const CREDENTIAL_ID: &str = + "0F0B70F4F4C5B27E39D62D4D69E9DF3D0BC0AC29B8FE7CD5AF1AC8C15F1D2E3B"; + + fn with_credentials( + credential_ids: Vec>, + ) -> LoanBrokerCoverWithdraw<'static> { + LoanBrokerCoverWithdraw { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::LoanBrokerCoverWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: Some(DESTINATION.into()), + destination_tag: None, + credential_ids: Some(credential_ids), + } + } + + #[test] + fn test_valid_credential_ids() { + assert!(with_credentials(vec![CREDENTIAL_ID.into()]) + .get_errors() + .is_ok()); + } + + #[test] + fn test_invalid_duplicate_credential_ids() { + assert!(matches!( + with_credentials(vec![CREDENTIAL_ID.into(), CREDENTIAL_ID.into()]) + .get_errors() + .err(), + Some(XRPLModelException::ValueEqualsValue { .. }) + )); + } + + #[test] + fn test_invalid_empty_credential_ids() { + assert!(matches!( + with_credentials(vec![]).get_errors().err(), + Some(XRPLModelException::ValueTooShort { .. }) + )); + } + + #[test] + fn test_serde_credential_ids() { + let tx = with_credentials(vec![CREDENTIAL_ID.into()]); + + let json_str = r#"{"Account":"r9LqNeG6qHxLoanBrokerCoverWithdraw5weJ9","TransactionType":"LoanBrokerCoverWithdraw","Flags":0,"SigningPubKey":"","LoanBrokerID":"E123F4567890ABCDE123F4567890ABCDEF1234567890ABCDEF1234567890ABCD","Amount":"1000000","Destination":"rf7HPydP4ihkFkSRHWFq34b4SXRc7GvPCR","CredentialIDs":["0F0B70F4F4C5B27E39D62D4D69E9DF3D0BC0AC29B8FE7CD5AF1AC8C15F1D2E3B"]}"#; + + assert_eq!( + serde_json::to_value(serde_json::to_string(&tx).unwrap()).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + let deserialized: LoanBrokerCoverWithdraw = serde_json::from_str(json_str).unwrap(); + assert_eq!(tx, deserialized); + } + + #[test] + fn test_builder_sets_credential_ids() { + let tx = LoanBrokerCoverWithdraw { + common_fields: CommonFields { + account: ACCOUNT.into(), + transaction_type: TransactionType::LoanBrokerCoverWithdraw, + ..Default::default() + }, + loan_broker_id: LOAN_BROKER_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + ..Default::default() + } + .with_credential_ids(vec![CREDENTIAL_ID.into()]); + + assert_eq!(tx.credential_ids.as_ref().unwrap().len(), 1); + assert!(tx.get_errors().is_ok()); + } + } } diff --git a/src/models/transactions/loan_broker_set.rs b/src/models/transactions/loan_broker_set.rs index 72f3d680..da8b0318 100644 --- a/src/models/transactions/loan_broker_set.rs +++ b/src/models/transactions/loan_broker_set.rs @@ -162,28 +162,21 @@ impl Model for LoanBrokerSet<'_> { } } - match (self.cover_rate_liquidation, self.cover_rate_minimum) { - (Some(crl), Some(crm)) if (crl == 0) != (crm == 0) => { - return Err(XRPLModelException::InvalidValue { - field: "cover_rate_liquidation and cover_rate_minimum".into(), - expected: "Both should be either None, Zero or Non-Zero".into(), - found: format!( - "cover_rate_liquidation: {}, cover_rate_minimum: {}", - crl, crm - ), - }); - } - (Some(_), None) | (None, Some(_)) => { - return Err(XRPLModelException::InvalidValue { - field: "cover_rate_liquidation and cover_rate_minimum".into(), - expected: "Both should be either None, Zero or Non-Zero".into(), - found: format!( - "cover_rate_liquidation: {:?}, cover_rate_minimum: {:?}", - self.cover_rate_liquidation, self.cover_rate_minimum - ), - }); - } - _ => {} + // Both cover rates must be zero or both non-zero, with an absent rate + // counting as zero — matching rippled, which compares + // `tx[~sfCoverRateMinimum].value_or(0) == 0` against the same for + // `sfCoverRateLiquidation`. + let minimum_zero = self.cover_rate_minimum.unwrap_or(0) == 0; + let liquidation_zero = self.cover_rate_liquidation.unwrap_or(0) == 0; + if minimum_zero != liquidation_zero { + return Err(XRPLModelException::InvalidValue { + field: "cover_rate_liquidation and cover_rate_minimum".into(), + expected: "both zero (or absent) or both non-zero".into(), + found: format!( + "cover_rate_liquidation: {:?}, cover_rate_minimum: {:?}", + self.cover_rate_liquidation, self.cover_rate_minimum + ), + }); } Ok(()) @@ -984,4 +977,39 @@ mod tests { assert_eq!(tx.cover_rate_liquidation, Some(0)); assert!(tx.get_errors().is_ok()); } + + /// An absent cover rate counts as zero, so pairing an explicit zero with an + /// absent rate is the "no first-loss capital" configuration and is valid. + #[test] + fn test_valid_zero_cover_rate_with_absent_counterpart() { + let with_explicit_zero_minimum = LoanBrokerSet { + cover_rate_minimum: Some(0), + cover_rate_liquidation: None, + ..base_tx() + }; + assert!(with_explicit_zero_minimum.get_errors().is_ok()); + + let with_explicit_zero_liquidation = LoanBrokerSet { + cover_rate_minimum: None, + cover_rate_liquidation: Some(0), + ..base_tx() + }; + assert!(with_explicit_zero_liquidation.get_errors().is_ok()); + } + + /// A non-zero rate paired with an absent one is still a mismatch, since the + /// absent rate counts as zero. + #[test] + fn test_invalid_nonzero_cover_rate_with_absent_counterpart() { + let tx = LoanBrokerSet { + cover_rate_minimum: Some(10_000), + cover_rate_liquidation: None, + ..base_tx() + }; + + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } } diff --git a/src/models/transactions/loan_set.rs b/src/models/transactions/loan_set.rs index 95d353cf..a647fecd 100644 --- a/src/models/transactions/loan_set.rs +++ b/src/models/transactions/loan_set.rs @@ -161,10 +161,6 @@ impl Model for LoanSet<'_> { validate_hex_blob("data", data, MAX_DATA_LENGTH)?; } - if let Some(Err(e)) = self.data.as_ref().map(|s| hex::decode(s.as_ref())) { - return Err(XRPLModelException::FromHexError(e)); - } - if let Some(lsf) = &self.loan_service_fee { let lsf_decimal = lsf .parse::() diff --git a/src/models/transactions/mod.rs b/src/models/transactions/mod.rs index eb513a7c..600789ab 100644 --- a/src/models/transactions/mod.rs +++ b/src/models/transactions/mod.rs @@ -188,6 +188,16 @@ pub fn validate_credential_ids(credential_ids: &Option>>) -> XR found: id.as_ref().into(), }); } + // A zero credential ID is not a real ledger object; rippled rejects + // it with `temMALFORMED` under `fixCleanup3_4_0` + // (`credentials::checkFields`). + if id.bytes().all(|b| b == b'0') { + return Err(XRPLModelException::InvalidValue { + field: "credential_ids".into(), + expected: "nonzero 256-bit hash".into(), + found: id.as_ref().into(), + }); + } // Hex credential IDs are compared case-insensitively: rippled normalizes // the raw bytes, so "ABCD" and "abcd" refer to the same credential. if ids[..i].iter().any(|prev| prev.eq_ignore_ascii_case(id)) { diff --git a/src/models/transactions/vault_create.rs b/src/models/transactions/vault_create.rs index 11947f8e..2ec58f4c 100644 --- a/src/models/transactions/vault_create.rs +++ b/src/models/transactions/vault_create.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use alloc::format; use alloc::string::ToString; use alloc::vec::Vec; use serde::{Deserialize, Serialize}; @@ -25,6 +26,16 @@ const MAX_VAULT_MPTOKEN_METADATA_HEX_LEN: usize = 2048; const MAX_VAULT_SCALE: u8 = 18; const FIRST_COME_FIRST_SERVE_POLICY: u8 = 1; +/// Minimum length, in seconds, of a close-ended vault's investment period +/// (`RedemptionDate - SubscriptionDate`). 180s is the smallest window that can +/// still fit a minimum-interval loan plus the 60s redemption buffer enforced by +/// `LoanSet` (rippled `kMinInvestmentPeriod`). +const MIN_INVESTMENT_PERIOD: i64 = 180; + +/// Exclusive upper bound, in seconds, on a close-ended vault's investment +/// period (30 Gregorian years). +const MAX_INVESTMENT_PERIOD: i64 = 946708560; + /// Transactions of the VaultCreate type support additional values in the /// Flags field. This enum represents those options. /// @@ -43,6 +54,29 @@ pub enum VaultCreateFlag { TfVaultShareNonTransferable = 0x00020000, } +/// The kind of vault, for the `VaultKind` field (LendingProtocolV1_1). +/// +/// A convenience for [`VaultCreate::with_vault_kind`]: the field itself is an +/// `Option` on the transaction, so unknown future kinds still round-trip, +/// and this enum never reaches the wire. +#[derive(Debug, Eq, PartialEq, Copy, Clone, Display, AsRefStr, EnumIter)] +#[repr(u8)] +pub enum VaultKind { + /// An open-ended vault: shares can be redeemed at any time. + Open = 0, + /// A close-ended vault: deposits and redemptions are restricted to the + /// subscription and redemption periods respectively. Under + /// LendingProtocolV1_1 a `LoanBroker` can only be attached to a + /// close-ended vault. + Closed = 1, +} + +impl From for u8 { + fn from(kind: VaultKind) -> Self { + kind as u8 + } +} + /// Create a new single-asset vault on the XRP Ledger (XLS-65). /// /// A vault holds a single asset type and issues share tokens (MPTokens) @@ -88,6 +122,18 @@ pub struct VaultCreate<'a> { /// when converting it into an integer-based number of shares. /// Fixed at 0 for XRP and MPT. Configurable 0-18 for IOU (default 6). pub scale: Option, + /// (LendingProtocolV1_1) The kind of vault: 0 for an open-ended vault + /// (the default) or 1 for a close-ended vault. Can only be set at vault + /// creation. See [`VaultKind`]. + pub vault_kind: Option, + /// (LendingProtocolV1_1, close-ended vaults only) The time, in seconds + /// since the Ripple Epoch, up to which deposits into the vault are + /// accepted. + pub subscription_date: Option, + /// (LendingProtocolV1_1, close-ended vaults only) The time, in seconds + /// since the Ripple Epoch, at which shares may begin to be redeemed from + /// the vault. + pub redemption_date: Option, } impl Model for VaultCreate<'_> { @@ -143,6 +189,7 @@ impl Model for VaultCreate<'_> { }); } } + self.validate_vault_kind()?; Ok(()) } } @@ -176,6 +223,57 @@ impl<'a> CommonTransactionBuilder<'a, VaultCreateFlag> for VaultCreate<'a> { } impl<'a> VaultCreate<'a> { + /// Validate the LendingProtocolV1_1 close-ended vault fields: a + /// close-ended vault (`vault_kind` = 1) requires both a subscription and a + /// redemption date bounding a valid investment period, and an open-ended + /// vault (the default) must not carry either date. + fn validate_vault_kind(&self) -> XRPLModelResult<()> { + if let Some(kind) = self.vault_kind { + if kind != u8::from(VaultKind::Open) && kind != u8::from(VaultKind::Closed) { + return Err(XRPLModelException::InvalidValue { + field: "vault_kind".into(), + expected: "0 (open-ended) or 1 (close-ended)".into(), + found: kind.to_string(), + }); + } + } + + if self.vault_kind == Some(u8::from(VaultKind::Closed)) { + let (Some(subscription_date), Some(redemption_date)) = + (self.subscription_date, self.redemption_date) + else { + return Err(XRPLModelException::MissingField( + "subscription_date and redemption_date (both required for a close-ended vault)" + .into(), + )); + }; + + // Widen to i64: a redemption date before the subscription date is a + // caller error to report, not a `u32` underflow to panic on. + let investment_period = i64::from(redemption_date) - i64::from(subscription_date); + if !(MIN_INVESTMENT_PERIOD..MAX_INVESTMENT_PERIOD).contains(&investment_period) { + return Err(XRPLModelException::InvalidValue { + field: "redemption_date and subscription_date".into(), + expected: format!( + "an investment period within [{MIN_INVESTMENT_PERIOD}, {MAX_INVESTMENT_PERIOD}) seconds" + ), + found: format!("redemption_date - subscription_date: {investment_period}"), + }); + } + } else if self.subscription_date.is_some() || self.redemption_date.is_some() { + return Err(XRPLModelException::InvalidValue { + field: "subscription_date and redemption_date".into(), + expected: "only present when vault_kind is 1 (close-ended)".into(), + found: format!( + "subscription_date: {:?}, redemption_date: {:?}", + self.subscription_date, self.redemption_date + ), + }); + } + + Ok(()) + } + #[allow(clippy::too_many_arguments)] pub fn new( account: Cow<'a, str>, @@ -220,6 +318,9 @@ impl<'a> VaultCreate<'a> { domain_id, withdrawal_policy, scale, + vault_kind: None, + subscription_date: None, + redemption_date: None, } } @@ -259,6 +360,26 @@ impl<'a> VaultCreate<'a> { self } + /// Set the vault kind (LendingProtocolV1_1). + pub fn with_vault_kind(mut self, vault_kind: VaultKind) -> Self { + self.vault_kind = Some(vault_kind.into()); + self + } + + /// Set the subscription date, in seconds since the Ripple Epoch + /// (LendingProtocolV1_1, close-ended vaults only). + pub fn with_subscription_date(mut self, subscription_date: u32) -> Self { + self.subscription_date = Some(subscription_date); + self + } + + /// Set the redemption date, in seconds since the Ripple Epoch + /// (LendingProtocolV1_1, close-ended vaults only). + pub fn with_redemption_date(mut self, redemption_date: u32) -> Self { + self.redemption_date = Some(redemption_date); + self + } + /// Append a flag to this transaction's flag set. pub fn with_flag(mut self, flag: VaultCreateFlag) -> Self { self.common_fields.flags.0.push(flag); @@ -288,6 +409,9 @@ mod tests { domain_id: None, withdrawal_policy: None, scale: None, + vault_kind: None, + subscription_date: None, + redemption_date: None, }; let json_str = r#"{"Account":"rVaultCreator123","TransactionType":"VaultCreate","Flags":0,"SigningPubKey":"","Asset":{"currency":"USD","issuer":"rIssuer456"}}"#; @@ -322,6 +446,9 @@ mod tests { ), withdrawal_policy: Some(1), scale: Some(6), + vault_kind: None, + subscription_date: None, + redemption_date: None, }; let serialized = serde_json::to_string(&vault_create).unwrap(); @@ -907,4 +1034,180 @@ mod tests { }; assert!(vault_create.validate().is_err()); } + + /// Close-ended vault validation (LendingProtocolV1_1). + /// + /// Mirrors `test/models/vaultCreate.test.ts` in xrpl.js. The `NaN` / + /// non-integer date cases from that suite have no Rust analogue: the date + /// fields are `u32`, so a non-integral or non-finite value cannot be + /// constructed. + mod close_ended { + use super::*; + + fn close_ended( + subscription_date: Option, + redemption_date: Option, + ) -> VaultCreate<'static> { + VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator123".into(), + transaction_type: TransactionType::VaultCreate, + signing_pub_key: Some("".into()), + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + vault_kind: Some(VaultKind::Closed.into()), + subscription_date, + redemption_date, + ..Default::default() + } + } + + #[test] + fn test_valid_close_ended_vault() { + assert!(close_ended(Some(800_000_000), Some(810_000_000)) + .get_errors() + .is_ok()); + } + + #[test] + fn test_valid_open_ended_vault_without_dates() { + let tx = VaultCreate { + vault_kind: Some(VaultKind::Open.into()), + subscription_date: None, + redemption_date: None, + ..close_ended(None, None) + }; + + assert!(tx.get_errors().is_ok()); + } + + #[test] + fn test_valid_minimum_investment_period() { + assert!(close_ended(Some(800_000_000), Some(800_000_180)) + .get_errors() + .is_ok()); + } + + #[test] + fn test_invalid_close_ended_vault_missing_redemption_date() { + assert!(matches!( + close_ended(Some(800_000_000), None).get_errors().err(), + Some(XRPLModelException::MissingField(..)) + )); + } + + #[test] + fn test_invalid_close_ended_vault_missing_subscription_date() { + assert!(matches!( + close_ended(None, Some(810_000_000)).get_errors().err(), + Some(XRPLModelException::MissingField(..)) + )); + } + + /// The dates describe the subscription and redemption windows of a + /// close-ended vault, so an open-ended vault must not carry them. + #[test] + fn test_invalid_dates_on_open_ended_vault() { + let tx = VaultCreate { + vault_kind: None, + ..close_ended(Some(800_000_000), Some(810_000_000)) + }; + + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_investment_period_below_minimum() { + assert!(matches!( + close_ended(Some(800_000_000), Some(800_000_179)) + .get_errors() + .err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + /// A redemption date before the subscription date is a negative + /// investment period, which must be reported rather than underflowing. + #[test] + fn test_invalid_redemption_date_before_subscription_date() { + assert!(matches!( + close_ended(Some(810_000_000), Some(800_000_000)) + .get_errors() + .err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_invalid_investment_period_at_exclusive_maximum() { + assert!(matches!( + close_ended(Some(0), Some(946_708_560)).get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_valid_investment_period_just_below_maximum() { + assert!(close_ended(Some(0), Some(946_708_559)).get_errors().is_ok()); + } + + #[test] + fn test_invalid_unsupported_vault_kind() { + let tx = VaultCreate { + vault_kind: Some(2), + ..close_ended(Some(800_000_000), Some(810_000_000)) + }; + + assert!(matches!( + tx.get_errors().err(), + Some(XRPLModelException::InvalidValue { .. }) + )); + } + + #[test] + fn test_serde_close_ended_fields() { + let tx = close_ended(Some(800_000_000), Some(810_000_000)); + + let json_str = r#"{"Account":"rVaultCreator123","TransactionType":"VaultCreate","Flags":0,"SigningPubKey":"","Asset":{"currency":"XRP"},"VaultKind":1,"SubscriptionDate":800000000,"RedemptionDate":810000000}"#; + + assert_eq!( + serde_json::to_value(serde_json::to_string(&tx).unwrap()).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + let deserialized: VaultCreate = serde_json::from_str(json_str).unwrap(); + assert_eq!(tx, deserialized); + } + + #[test] + fn test_builder_sets_close_ended_fields() { + let tx = VaultCreate { + common_fields: CommonFields { + account: "rVaultCreator123".into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + ..Default::default() + } + .with_vault_kind(VaultKind::Closed) + .with_subscription_date(800_000_000) + .with_redemption_date(810_000_000); + + assert_eq!(tx.vault_kind, Some(1)); + assert_eq!(tx.subscription_date, Some(800_000_000)); + assert_eq!(tx.redemption_date, Some(810_000_000)); + assert!(tx.get_errors().is_ok()); + } + + #[test] + fn test_vault_kind_values() { + assert_eq!(u8::from(VaultKind::Open), 0); + assert_eq!(u8::from(VaultKind::Closed), 1); + } + } } diff --git a/src/models/transactions/vault_delete.rs b/src/models/transactions/vault_delete.rs index 4a24c023..183e445b 100644 --- a/src/models/transactions/vault_delete.rs +++ b/src/models/transactions/vault_delete.rs @@ -6,9 +6,14 @@ use serde_with::skip_serializing_none; use crate::models::amount::XRPAmount; use crate::models::{FlagCollection, Model, NoFlags, XRPLModelResult}; -use super::vault_common::validate_vault_id; +use super::vault_common::{validate_hex_blob, validate_vault_id}; use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; +/// Maximum length, in hex characters, of the VaultDelete `MemoData` field. +/// Per LendingProtocolV1_1, the deletion memo is capped at 256 bytes +/// = 512 hex chars. +const MAX_VAULT_MEMO_DATA_HEX_LEN: usize = 512; + /// Delete a vault from the XRP Ledger (XLS-65). /// /// The vault must be empty (no remaining assets) before it can be deleted. @@ -29,11 +34,18 @@ pub struct VaultDelete<'a> { /// The ID of the vault to delete (256-bit hex string). #[serde(rename = "VaultID")] pub vault_id: Cow<'a, str>, + /// (LendingProtocolV1_1) Arbitrary metadata attached to the deletion, in + /// hex format, limited to 256 bytes. + pub memo_data: Option>, } impl Model for VaultDelete<'_> { fn get_errors(&self) -> XRPLModelResult<()> { - validate_vault_id(&self.vault_id) + validate_vault_id(&self.vault_id)?; + if let Some(memo_data) = self.memo_data.as_deref() { + validate_hex_blob("memo_data", memo_data, MAX_VAULT_MEMO_DATA_HEX_LEN)?; + } + Ok(()) } } @@ -92,8 +104,15 @@ impl<'a> VaultDelete<'a> { None, ), vault_id, + memo_data: None, } } + + /// Set the deletion memo (LendingProtocolV1_1). + pub fn with_memo_data(mut self, memo_data: Cow<'a, str>) -> Self { + self.memo_data = Some(memo_data); + self + } } #[cfg(test)] @@ -112,6 +131,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, }; let json_str = r#"{"Account":"rVaultOwner123","TransactionType":"VaultDelete","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF"}"#; @@ -137,6 +157,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, } .with_fee("12".into()) .with_sequence(100) @@ -168,6 +189,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, }; assert_eq!(vault_delete.common_fields.account, "rVaultOwner456"); @@ -189,6 +211,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, } .with_ticket_sequence(54321) .with_fee("12".into()); @@ -206,6 +229,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, } .with_memo(Memo { memo_data: Some("first memo".into()), @@ -270,6 +294,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, } .with_fee("12".into()) .with_sequence(300); @@ -286,6 +311,7 @@ mod tests { ..Default::default() }, vault_id: VAULT_ID.into(), + memo_data: None, } .with_account_txn_id("F1E2D3C4B5A69788".into()) .with_fee("12".into()) @@ -296,4 +322,91 @@ mod tests { Some("F1E2D3C4B5A69788".into()) ); } + + /// `MemoData` validation (LendingProtocolV1_1). + /// + /// Mirrors `test/models/vaultDelete.test.ts` in xrpl.js. + mod memo_data { + use super::*; + use crate::models::XRPLModelException; + + fn with_memo(memo_data: &str) -> VaultDelete<'_> { + VaultDelete { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultDelete, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + memo_data: Some(memo_data.into()), + } + } + + #[test] + fn test_valid_memo_data_at_maximum() { + // 256 bytes, the documented cap. + let memo_data = "41".repeat(256); + assert!(with_memo(&memo_data).get_errors().is_ok()); + } + + #[test] + fn test_invalid_memo_data_non_hex() { + assert!(matches!( + with_memo("zznothex").get_errors().err(), + Some(XRPLModelException::InvalidValueFormat { .. }) + )); + } + + /// An odd number of hex characters is not a whole number of bytes and + /// fails to serialize. + #[test] + fn test_invalid_memo_data_odd_length() { + assert!(matches!( + with_memo("ABC").get_errors().err(), + Some(XRPLModelException::InvalidValueFormat { .. }) + )); + } + + #[test] + fn test_invalid_memo_data_too_long() { + let memo_data = "41".repeat(257); + assert!(matches!( + with_memo(&memo_data).get_errors().err(), + Some(XRPLModelException::ValueTooLong { .. }) + )); + } + + #[test] + fn test_serde_memo_data() { + let tx = with_memo("48656C6C6F"); + + let json_str = r#"{"Account":"rVaultOwner123","TransactionType":"VaultDelete","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","MemoData":"48656C6C6F"}"#; + + assert_eq!( + serde_json::to_value(serde_json::to_string(&tx).unwrap()).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + let deserialized: VaultDelete = serde_json::from_str(json_str).unwrap(); + assert_eq!(tx, deserialized); + } + + #[test] + fn test_builder_sets_memo_data() { + let tx = VaultDelete { + common_fields: CommonFields { + account: "rVaultOwner123".into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + ..Default::default() + } + .with_memo_data("48656C6C6F".into()); + + assert_eq!(tx.memo_data, Some("48656C6C6F".into())); + assert!(tx.get_errors().is_ok()); + } + } } diff --git a/src/models/transactions/vault_withdraw.rs b/src/models/transactions/vault_withdraw.rs index 2b39cb9f..8662e57d 100644 --- a/src/models/transactions/vault_withdraw.rs +++ b/src/models/transactions/vault_withdraw.rs @@ -10,7 +10,10 @@ use crate::models::{ }; use super::vault_common::{validate_positive_amount, validate_vault_id}; -use super::{CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, TransactionType}; +use super::{ + validate_credential_ids, CommonFields, CommonTransactionBuilder, Memo, Signer, Transaction, + TransactionType, +}; /// Withdraw assets from a vault on the XRP Ledger (XLS-65). /// @@ -47,6 +50,9 @@ pub struct VaultWithdraw<'a> { pub destination: Option>, /// Arbitrary tag identifying the reason for the withdrawal to the destination. pub destination_tag: Option, + /// The credentials to authorize the withdrawal when the vault is gated by a permissioned domain (XLS-70). + #[serde(rename = "CredentialIDs")] + pub credential_ids: Option>>, } impl Model for VaultWithdraw<'_> { @@ -63,6 +69,7 @@ impl Model for VaultWithdraw<'_> { }); } } + validate_credential_ids(&self.credential_ids)?; Ok(()) } } @@ -128,6 +135,7 @@ impl<'a> VaultWithdraw<'a> { amount, destination, destination_tag, + credential_ids: None, } } @@ -142,6 +150,12 @@ impl<'a> VaultWithdraw<'a> { self.destination_tag = Some(destination_tag); self } + + /// Set the credentials authorizing this withdrawal. + pub fn with_credential_ids(mut self, credential_ids: Vec>) -> Self { + self.credential_ids = Some(credential_ids); + self + } } #[cfg(test)] @@ -164,6 +178,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: None, destination_tag: None, + credential_ids: None, }; let json_str = r#"{"Account":"rWithdrawer123","TransactionType":"VaultWithdraw","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Amount":"1000000"}"#; @@ -197,6 +212,7 @@ mod tests { )), destination: None, destination_tag: None, + credential_ids: None, }; let serialized = serde_json::to_string(&vault_withdraw).unwrap(); @@ -216,6 +232,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: None, destination_tag: None, + credential_ids: None, } .with_fee("12".into()) .with_sequence(100) @@ -253,6 +270,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("5000000")), destination: None, destination_tag: None, + credential_ids: None, }; assert_eq!(vault_withdraw.common_fields.account, "rWithdrawer789"); @@ -277,6 +295,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("2000000")), destination: None, destination_tag: None, + credential_ids: None, } .with_ticket_sequence(54321) .with_fee("12".into()); @@ -301,6 +320,7 @@ mod tests { )), destination: None, destination_tag: None, + credential_ids: None, } .with_memo(Memo { memo_data: Some("partial withdrawal".into()), @@ -342,6 +362,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("10000000")), destination: None, destination_tag: None, + credential_ids: None, }; assert_eq!(vault_withdraw.common_fields.account, "rNewWithdrawer444"); @@ -365,6 +386,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: None, destination_tag: None, + credential_ids: None, } .with_destination("rDestAccount789".into()) .with_destination_tag(42); @@ -385,6 +407,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some("notanaddress".into()), destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_err()); } @@ -401,6 +424,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: Some("rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn".into()), destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_ok()); } @@ -418,6 +442,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: None, destination_tag: None, + credential_ids: None, }; assert_eq!( *vault_withdraw.get_transaction_type(), @@ -437,6 +462,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("1000000")), destination: None, destination_tag: None, + credential_ids: None, } .with_fee("12".into()) .with_sequence(300); @@ -456,6 +482,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("0")), destination: None, destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_err()); } @@ -476,6 +503,7 @@ mod tests { )), destination: None, destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_err()); } @@ -492,6 +520,7 @@ mod tests { amount: Amount::XRPAmount(XRPAmount::from("bad")), destination: None, destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_err()); } @@ -512,6 +541,7 @@ mod tests { )), destination: None, destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_err()); } @@ -532,6 +562,7 @@ mod tests { }), destination: None, destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_ok()); } @@ -552,7 +583,96 @@ mod tests { }), destination: None, destination_tag: None, + credential_ids: None, }; assert!(vault_withdraw.validate().is_err()); } + + /// `CredentialIDs` validation (LendingProtocolV1_1). + /// + /// Mirrors `test/models/vaultWithdraw.test.ts` in xrpl.js. A withdrawal + /// from a domain-gated vault carries the credentials that prove the + /// withdrawer's domain membership (XLS-70). + mod credential_ids { + use super::*; + use crate::models::XRPLModelException; + use alloc::vec; + + const CREDENTIAL_ID: &str = + "0F0B70F4F4C5B27E39D62D4D69E9DF3D0BC0AC29B8FE7CD5AF1AC8C15F1D2E3B"; + + fn with_credentials(credential_ids: Vec>) -> VaultWithdraw<'static> { + VaultWithdraw { + common_fields: CommonFields { + account: "rVaultWithdrawer123".into(), + transaction_type: TransactionType::VaultWithdraw, + signing_pub_key: Some("".into()), + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + destination: None, + destination_tag: None, + credential_ids: Some(credential_ids), + } + } + + #[test] + fn test_valid_credential_ids() { + assert!(with_credentials(vec![CREDENTIAL_ID.into()]) + .get_errors() + .is_ok()); + } + + #[test] + fn test_invalid_duplicate_credential_ids() { + assert!(matches!( + with_credentials(vec![CREDENTIAL_ID.into(), CREDENTIAL_ID.into()]) + .get_errors() + .err(), + Some(XRPLModelException::ValueEqualsValue { .. }) + )); + } + + #[test] + fn test_invalid_empty_credential_ids() { + assert!(matches!( + with_credentials(vec![]).get_errors().err(), + Some(XRPLModelException::ValueTooShort { .. }) + )); + } + + #[test] + fn test_serde_credential_ids() { + let tx = with_credentials(vec![CREDENTIAL_ID.into()]); + + let json_str = r#"{"Account":"rVaultWithdrawer123","TransactionType":"VaultWithdraw","Flags":0,"SigningPubKey":"","VaultID":"A0000000000000000000000000000000000000000000000000000000DEADBEEF","Amount":"1000000","CredentialIDs":["0F0B70F4F4C5B27E39D62D4D69E9DF3D0BC0AC29B8FE7CD5AF1AC8C15F1D2E3B"]}"#; + + assert_eq!( + serde_json::to_value(serde_json::to_string(&tx).unwrap()).unwrap(), + serde_json::to_value(json_str).unwrap() + ); + + let deserialized: VaultWithdraw = serde_json::from_str(json_str).unwrap(); + assert_eq!(tx, deserialized); + } + + #[test] + fn test_builder_sets_credential_ids() { + let tx = VaultWithdraw { + common_fields: CommonFields { + account: "rVaultWithdrawer123".into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: VAULT_ID.into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + ..Default::default() + } + .with_credential_ids(vec![CREDENTIAL_ID.into()]); + + assert_eq!(tx.credential_ids.as_ref().unwrap().len(), 1); + assert!(tx.get_errors().is_ok()); + } + } } diff --git a/src/signing/exceptions.rs b/src/signing/exceptions.rs index f3818a76..32d04599 100644 --- a/src/signing/exceptions.rs +++ b/src/signing/exceptions.rs @@ -19,4 +19,6 @@ pub enum XRPLSignTransactionException { WalletRequired, #[error("transaction signed: {0}")] TransactionSigned(String), + #[error("cannot combine counterparty signatures: {0}")] + CombineCounterpartySigners(String), } diff --git a/src/signing/mod.rs b/src/signing/mod.rs index be6e2e91..6acdcfbe 100644 --- a/src/signing/mod.rs +++ b/src/signing/mod.rs @@ -12,6 +12,7 @@ pub mod exceptions; use core::fmt::Debug; +use alloc::borrow::Cow; use alloc::string::String; use alloc::string::ToString; use alloc::vec; @@ -23,7 +24,10 @@ use strum::IntoEnumIterator; use crate::asynch::exceptions::XRPLHelperResult; use crate::core::{ addresscodec::{decode_classic_address, is_valid_xaddress, xaddress_to_classic_address}, - binarycodec::{encode_for_multisigning, encode_for_signing}, + binarycodec::{ + encode_for_multisigning, encode_for_multisigning_counterparty, encode_for_signing, + encode_for_signing_counterparty, + }, keypairs::sign as keypairs_sign, }; use crate::models::transactions::loan_set::CounterpartySignature; @@ -110,19 +114,57 @@ where Ok(()) } +/// How a counterparty signature is produced. +/// +/// Mirrors xrpl.js's `opts.multisign?: boolean | string` on +/// `signLoanSetByCounterparty`: the string form exists so a wallet holding an +/// account's RegularKey can sign on that account's behalf. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CounterpartySigningMode<'a> { + /// Single-sign: set `SigningPubKey` and `TxnSignature` on the + /// `CounterpartySignature`. + Single, + /// Multisign as the wallet's own account. + Multisign, + /// Multisign on behalf of `account`, whose RegularKey this wallet holds. + /// The signature and `Signer.Account` use `account`, not the wallet's own + /// address (rippled `Transactor::checkMultiSign` accepts a signature from + /// the signer's RegularKey). + MultisignAs(Cow<'a, str>), +} + +impl CounterpartySigningMode<'_> { + fn is_multisign(&self) -> bool { + !matches!(self, CounterpartySigningMode::Single) + } +} + +/// rippled's cap on a `Signers` array (`STTx::kMaxMultiSigners`). +const MAX_MULTI_SIGNERS: usize = 32; + /// Signs a LoanSet transaction as the counterparty. /// This function adds a counterparty signature to a LoanSet transaction that has /// already been signed by the first party. The counterparty uses their wallet to /// sign the transaction, which is required for multi-party loan agreements on the /// XRP Ledger. +/// +/// # Verify the terms first +/// +/// The signature commits the counterparty to every signing field of the +/// transaction — the principal, all fees and rates, the payment schedule and the +/// broker — so the loan terms cannot change afterwards without invalidating it. +/// This function only checks that a first-party signature is *present*; it does +/// not verify it, and it cannot know whether the terms are the ones your user +/// agreed to. A caller that receives a `LoanSet` from the other party is +/// responsible for showing the terms to its user before calling this. pub fn sign_loan_set_by_counterparty<'a>( transaction: &mut LoanSet<'a>, wallet: &Wallet, - multisign: bool, + mode: CounterpartySigningMode<'_>, ) -> XRPLHelperResult<()> { transaction.validate()?; - reject_if_already_signed(transaction, wallet, multisign)?; + reject_if_already_signed(transaction, wallet, &mode)?; let cf = transaction.get_common_fields(); @@ -136,15 +178,148 @@ pub fn sign_loan_set_by_counterparty<'a>( .into()); } - if multisign { - sign_multisign(transaction, wallet)?; - } else { - sign_single(transaction, wallet)?; + match &mode { + CounterpartySigningMode::Single => sign_single(transaction, wallet)?, + CounterpartySigningMode::Multisign => { + sign_multisign(transaction, wallet, wallet.classic_address.as_str().into())? + } + CounterpartySigningMode::MultisignAs(account) => { + sign_multisign(transaction, wallet, account.clone())? + } } Ok(()) } +/// Combines the counterparty signatures of several copies of the same +/// `LoanSet` into one transaction. +/// +/// Each input must be the same transaction, signed by the first party, and +/// carrying exactly the counterparty `Signers` contributed by one counterparty +/// signer — which is what [`sign_loan_set_by_counterparty`] with +/// `multisign = true` produces. Use this when the counterparty signers sign +/// independently (on separate machines, say) and the results have to be merged +/// before submission; when every signer's wallet is available in one place, +/// calling [`sign_loan_set_by_counterparty`] once per signer on the same +/// transaction accumulates them directly. +/// +/// The combined `Signers` list is sorted by decoded account ID, as rippled +/// requires. +pub fn combine_loan_set_counterparty_signers<'a>( + transaction: &mut LoanSet<'a>, + tx_list: &[LoanSet<'a>], +) -> XRPLHelperResult<()> { + if tx_list.is_empty() { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "there are 0 transactions to combine".into(), + ) + .into()); + } + + let mut combined_signers: Vec = Vec::new(); + for tx in tx_list { + tx.validate()?; + + // The first party may have single-signed or multisigned, the same two + // modes `sign_loan_set_by_counterparty` accepts. + let cf = tx.get_common_fields(); + let has_single_sig = cf.txn_signature.is_some() && cf.signing_pub_key.is_some(); + let has_multi_sig = cf.signers.as_ref().is_some_and(|s| !s.is_empty()); + if !has_single_sig && !has_multi_sig { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "every transaction must first be signed by the first party".into(), + ) + .into()); + } + + let signers = tx + .counterparty_signature + .as_ref() + .and_then(|cs| cs.signers.as_ref()) + .filter(|signers| !signers.is_empty()) + .ok_or_else(|| { + XRPLSignTransactionException::CombineCounterpartySigners( + "every transaction must carry counterparty Signers".into(), + ) + })?; + + for signer in signers { + if combined_signers + .iter() + .any(|existing| existing.account == signer.account) + { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "the same counterparty account signed more than once".into(), + ) + .into()); + } + combined_signers.push(signer.clone()); + } + } + + // The transactions have to agree on everything the signatures cover, so + // compare the signing payload itself rather than the Rust structs: it is + // exactly the bytes every signature is over, and the `isSigningField` + // filter drops `CounterpartySignature`, `TxnSignature` and `Signers` for + // us. Comparing structs would instead miss signing fields the model does + // not carry and wrongly reject copies that differ only in a non-signing + // field. The prefix is constant across inputs, so any signing encoding + // works as the comparison key. + let reference = encode_for_signing(&tx_list[0])?; + for tx in &tx_list[1..] { + if encode_for_signing(tx)? != reference { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "the transactions to combine are not the same transaction".into(), + ) + .into()); + } + } + + // The destination has to be that same transaction too: attaching these + // signatures to a different `LoanSet` would produce a transaction whose + // signatures do not cover its own terms. + if encode_for_signing(&transaction)? != reference { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "the transaction being combined into is not the transaction that was signed".into(), + ) + .into()); + } + + // The combined signers replace whatever the destination carries, so refuse + // to overwrite an existing counterparty signature rather than silently + // dropping it — `sign_loan_set_by_counterparty` rejects the same case. + if transaction.counterparty_signature.is_some() { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "the transaction being combined into already carries a counterparty signature".into(), + ) + .into()); + } + + // rippled caps a Signers array at 32 entries + // (`STTx::kMaxMultiSigners`); more would be rejected by the ledger. + if combined_signers.len() > MAX_MULTI_SIGNERS { + return Err(XRPLSignTransactionException::CombineCounterpartySigners( + "more than 32 counterparty signers".into(), + ) + .into()); + } + + // Fallible sort instead of unwrap()-in-key-fn. + let mut keyed = combined_signers + .drain(..) + .map(|s| decode_classic_address(&s.account).map(|k| (k, s))) + .collect::, _>>()?; + keyed.sort_by(|a, b| a.0.cmp(&b.0)); + + transaction.counterparty_signature = Some(CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: Some(keyed.into_iter().map(|(_, s)| s).collect()), + }); + + Ok(()) +} + pub(crate) fn prepare_transaction<'a, T, F>( transaction: &mut T, wallet: &Wallet, @@ -232,8 +407,10 @@ where } fn sign_single<'a>(transaction: &mut LoanSet<'a>, wallet: &Wallet) -> XRPLHelperResult<()> { + // The counterparty role signs under its own `fixCleanup3_4_0` prefix, not the + // transaction prefix. let txn_signature = crate::core::keypairs::sign( - &hex::decode(encode_for_signing(&transaction)?)?, + &hex::decode(encode_for_signing_counterparty(&transaction)?)?, &wallet.private_key, )?; transaction.counterparty_signature = Some(CounterpartySignature { @@ -244,16 +421,23 @@ fn sign_single<'a>(transaction: &mut LoanSet<'a>, wallet: &Wallet) -> XRPLHelper Ok(()) } -fn sign_multisign<'a>(transaction: &mut LoanSet<'a>, wallet: &Wallet) -> XRPLHelperResult<()> { +fn sign_multisign<'a>( + transaction: &mut LoanSet<'a>, + wallet: &Wallet, + signer_account: Cow<'_, str>, +) -> XRPLHelperResult<()> { + // Both the payload and `Signer.Account` carry the *declared* account, which + // is the wallet's own address unless it is signing with that account's + // RegularKey. let txn_signature = crate::core::keypairs::sign( - &hex::decode(encode_for_multisigning( + &hex::decode(encode_for_multisigning_counterparty( &transaction, - wallet.classic_address.as_str().into(), + signer_account.clone(), )?)?, &wallet.private_key, )?; let signer = Signer { - account: wallet.classic_address.clone(), + account: signer_account.into_owned(), signing_pub_key: wallet.public_key.clone(), txn_signature, }; @@ -286,20 +470,29 @@ fn sign_multisign<'a>(transaction: &mut LoanSet<'a>, wallet: &Wallet) -> XRPLHel fn reject_if_already_signed<'a>( transaction: &LoanSet<'a>, wallet: &Wallet, - multisign: bool, + mode: &CounterpartySigningMode<'_>, ) -> XRPLHelperResult<()> { let Some(cs) = transaction.counterparty_signature.as_ref() else { return Ok(()); }; + // Compare against the account this signature will be recorded under, which + // is the declared account under `MultisignAs` and the wallet's own address + // otherwise — the same account `sign_multisign` writes to `Signer.Account`. + // rippled rejects a `Signers` array holding the same account twice. + let signer_account: &str = match mode { + CounterpartySigningMode::MultisignAs(account) => account.as_ref(), + _ => wallet.classic_address.as_str(), + }; + match &cs.signers { - Some(_) if !multisign => Err(XRPLSignTransactionException::TransactionSigned( + Some(_) if !mode.is_multisign() => Err(XRPLSignTransactionException::TransactionSigned( "Transaction already has multisign counterparty signatures; \ cannot apply a single-sign counterparty signature." .into(), ) .into()), - Some(signers) if signers.iter().any(|s| s.account == wallet.classic_address) => { + Some(signers) if signers.iter().any(|s| s.account == signer_account) => { Err(XRPLSignTransactionException::TransactionSigned( "This counterparty account has already signed.".into(), ) @@ -318,8 +511,12 @@ mod test_sign_loan_set_by_counterparty { use alloc::borrow::Cow; use alloc::string::ToString; - use super::{sign, sign_loan_set_by_counterparty}; + use super::{ + combine_loan_set_counterparty_signers, sign, sign_loan_set_by_counterparty, + CounterpartySigningMode, + }; use crate::models::transactions::loan_set::{CounterpartySignature, LoanSet}; + use crate::models::transactions::Signer; use crate::models::transactions::Transaction; use crate::wallet::Wallet; @@ -358,7 +555,7 @@ mod test_sign_loan_set_by_counterparty { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single).unwrap(); let cs = tx.counterparty_signature.as_ref().unwrap(); assert_eq!( @@ -379,8 +576,10 @@ mod test_sign_loan_set_by_counterparty { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &second, true).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); + sign_loan_set_by_counterparty(&mut tx, &second, CounterpartySigningMode::Multisign) + .unwrap(); let cs = tx.counterparty_signature.as_ref().unwrap(); // The single-sign fields are cleared: the two modes are exclusive. @@ -404,7 +603,9 @@ mod test_sign_loan_set_by_counterparty { let borrower = borrower_wallet(); let mut tx = loan_set(&broker, &borrower); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .unwrap_err(); assert!( err.to_string().contains("first-party signature"), "unexpected error: {}", @@ -419,9 +620,11 @@ mod test_sign_loan_set_by_counterparty { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single).unwrap(); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .unwrap_err(); assert!( err.to_string() .contains("already signed by the counterparty"), @@ -439,9 +642,12 @@ mod test_sign_loan_set_by_counterparty { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .unwrap_err(); assert!( err.to_string() .contains("multisign counterparty signatures"), @@ -457,9 +663,12 @@ mod test_sign_loan_set_by_counterparty { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap_err(); assert!( err.to_string() .contains("This counterparty account has already signed"), @@ -479,7 +688,7 @@ mod test_sign_loan_set_by_counterparty { sign(&mut tx, &broker, true).unwrap(); assert!(tx.get_common_fields().signers.is_some()); - sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single).unwrap(); assert!(tx .counterparty_signature .as_ref() @@ -503,6 +712,498 @@ mod test_sign_loan_set_by_counterparty { signers: Some(alloc::vec::Vec::new()), }); - assert!(sign_loan_set_by_counterparty(&mut tx, &borrower, false).is_err()); + assert!( + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .is_err() + ); + } + + /// Counterparty signers that signed independently can be merged into one + /// transaction. + /// + /// xrpl.js reference: `combineLoanSetCounterpartySigners` in + /// `packages/xrpl/src/Wallet/counterpartySigner.ts`. + #[test] + fn test_combine_counterparty_signers() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + // Each counterparty signs its own copy, as it would on its own machine. + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut combined = base.clone(); + combine_loan_set_counterparty_signers(&mut combined, &[first_copy, second_copy]).unwrap(); + + let cs = combined.counterparty_signature.as_ref().unwrap(); + assert!(cs.signing_pub_key.is_none()); + assert!(cs.txn_signature.is_none()); + + let signers = cs.signers.as_ref().unwrap(); + assert_eq!(signers.len(), 2); + let mut sorted = signers.clone(); + sorted.sort_by_key(|s| { + crate::core::addresscodec::decode_classic_address(&s.account).unwrap() + }); + assert_eq!( + signers, &sorted, + "Signers must be sorted by decoded account id" + ); + } + + #[test] + fn test_combine_rejects_empty_list() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let mut combined = loan_set(&broker, &borrower); + + let err = combine_loan_set_counterparty_signers(&mut combined, &[]).unwrap_err(); + assert!( + err.to_string().contains("0 transactions to combine"), + "unexpected error: {}", + err + ); + } + + /// A single-signed counterparty signature carries no `Signers`, so it + /// cannot take part in a combine. + #[test] + fn test_combine_rejects_single_signed_counterparty() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + + let mut single = loan_set(&broker, &borrower); + sign(&mut single, &broker, false).unwrap(); + sign_loan_set_by_counterparty(&mut single, &borrower, CounterpartySigningMode::Single) + .unwrap(); + + let mut combined = loan_set(&broker, &borrower); + let err = combine_loan_set_counterparty_signers(&mut combined, &[single]).unwrap_err(); + assert!( + err.to_string().contains("must carry counterparty Signers"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_combine_rejects_unsigned_first_party() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + + // Counterparty Signers present, but the first party never signed. + let mut unsigned = loan_set(&broker, &borrower); + unsigned.counterparty_signature = Some(CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: Some(alloc::vec![Signer { + account: borrower.classic_address.clone(), + signing_pub_key: borrower.public_key.clone(), + txn_signature: "DEADBEEF".into(), + }]), + }); + + let mut combined = loan_set(&broker, &borrower); + let err = combine_loan_set_counterparty_signers(&mut combined, &[unsigned]).unwrap_err(); + assert!( + err.to_string().contains("signed by the first party"), + "unexpected error: {}", + err + ); + } + + /// The signatures cover the loan terms, so combining copies that disagree + /// on those terms would produce a transaction nobody signed. + #[test] + fn test_combine_rejects_mismatched_transactions() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + + let mut first_copy = loan_set(&broker, &borrower); + sign(&mut first_copy, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut tampered = loan_set(&broker, &borrower); + tampered.principal_requested = "999999".into(); + sign(&mut tampered, &broker, false).unwrap(); + sign_loan_set_by_counterparty(&mut tampered, &second, CounterpartySigningMode::Multisign) + .unwrap(); + + let mut combined = loan_set(&broker, &borrower); + let err = combine_loan_set_counterparty_signers(&mut combined, &[first_copy, tampered]) + .unwrap_err(); + assert!( + err.to_string().contains("not the same transaction"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_combine_rejects_duplicate_signer() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let duplicate = first_copy.clone(); + + let mut combined = base; + let err = combine_loan_set_counterparty_signers(&mut combined, &[first_copy, duplicate]) + .unwrap_err(); + assert!( + err.to_string().contains("signed more than once"), + "unexpected error: {}", + err + ); + } + + /// A first party that multisigned is a valid starting point for a combine, + /// matching what [`sign_loan_set_by_counterparty`] accepts. + #[test] + fn test_combine_accepts_multisigned_first_party() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, true).unwrap(); + assert!(base.get_common_fields().signers.is_some()); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut combined = base.clone(); + combine_loan_set_counterparty_signers(&mut combined, &[first_copy, second_copy]).unwrap(); + + let signers = combined + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap(); + assert_eq!(signers.len(), 2); + } + + /// Combining into a `LoanSet` with different terms would attach signatures + /// that do not cover that transaction. + #[test] + fn test_combine_rejects_mismatched_destination() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut other_terms = loan_set(&broker, &borrower); + other_terms.principal_requested = "999999".into(); + sign(&mut other_terms, &broker, false).unwrap(); + + let err = + combine_loan_set_counterparty_signers(&mut other_terms, &[first_copy, second_copy]) + .unwrap_err(); + assert!( + err.to_string() + .contains("not the transaction that was signed"), + "unexpected error: {}", + err + ); + } + + /// A wallet holding an account's RegularKey can sign on that account's + /// behalf: the signature and `Signer.Account` both use the declared + /// account, not the wallet's own address. + #[test] + fn test_multisign_as_another_account() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let regular_key = second_borrower_wallet(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap(); + + let signers = tx + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap(); + assert_eq!(signers.len(), 1); + assert_eq!( + signers[0].account, borrower.classic_address, + "Signer.Account must be the declared account, not the signing wallet" + ); + assert_eq!( + signers[0].signing_pub_key, regular_key.public_key, + "the public key must be the regular key that produced the signature" + ); + // The signature covers the declared account, so it differs from one made + // as the wallet's own account. + let mut own_account = loan_set(&broker, &borrower); + sign(&mut own_account, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut own_account, + ®ular_key, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + assert_ne!( + signers[0].txn_signature, + own_account + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap()[0] + .txn_signature + ); + } + + /// The destination must be free of a counterparty signature — overwriting + /// one would silently discard it. + #[test] + fn test_combine_rejects_already_signed_destination() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + // The destination already carries a single-sign counterparty signature. + let mut destination = base.clone(); + sign_loan_set_by_counterparty(&mut destination, &borrower, CounterpartySigningMode::Single) + .unwrap(); + + let err = + combine_loan_set_counterparty_signers(&mut destination, &[first_copy, second_copy]) + .unwrap_err(); + assert!( + err.to_string() + .contains("already carries a counterparty signature"), + "unexpected error: {}", + err + ); + } + + /// Copies that differ only in a non-signing field (the first party's own + /// `TxnSignature`) still combine: no signature covers those bytes. + #[test] + fn test_combine_ignores_non_signing_field_differences() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + // Same terms, but this copy's first-party signature bytes differ. + second_copy.common_fields.txn_signature = Some("DEADBEEF".into()); + + let mut combined = base.clone(); + combine_loan_set_counterparty_signers(&mut combined, &[first_copy, second_copy]).unwrap(); + assert_eq!( + combined + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap() + .len(), + 2 + ); + } + + /// The duplicate guard has to compare against the account the signature is + /// recorded under, which is the declared account under `MultisignAs` — not + /// the signing wallet's own address. rippled rejects a `Signers` array + /// holding the same account twice. + #[test] + fn test_rejects_duplicate_multisign_as_same_declared_account() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let regular_key = second_borrower_wallet(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap(); + + // The same declared account signing again, with any key. + let err = sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("This counterparty account has already signed"), + "unexpected error: {}", + err + ); + } + + /// The mirror case: the account already multisigned for itself, so a + /// regular-key signature on its behalf is also a duplicate. + #[test] + fn test_rejects_multisign_as_account_that_already_signed_itself() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let regular_key = second_borrower_wallet(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); + + let err = sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("This counterparty account has already signed"), + "unexpected error: {}", + err + ); + } + + /// A different declared account is still allowed to sign. + #[test] + fn test_multisign_as_distinct_accounts_accumulate() { + let broker = broker_wallet(); + let borrower = borrower_wallet(); + let second = second_borrower_wallet(); + let regular_key = broker_wallet(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap(); + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(second.classic_address.clone().into()), + ) + .unwrap(); + + let signers = tx + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap(); + assert_eq!(signers.len(), 2); + assert_ne!(signers[0].account, signers[1].account); } } diff --git a/tests/common/lending_protocol.rs b/tests/common/lending_protocol.rs index 9f1f96c7..b8fc4672 100644 --- a/tests/common/lending_protocol.rs +++ b/tests/common/lending_protocol.rs @@ -131,6 +131,10 @@ pub async fn get_loan_metadata(owner: &str, ao: AccountObjectType) -> LoanMetada let principal = optional_amount_str(object, "PrincipalOutstanding"); let payment_remaining = optional_u32(object, "PaymentRemaining"); + let next_payment_due_date = object + .get("NextPaymentDueDate") + .and_then(|v| v.as_u64()) + .map(|v| v as u32); let is_repaid = object.get("PrincipalOutstanding").is_none(); let flags_num = object["Flags"] @@ -149,6 +153,7 @@ pub async fn get_loan_metadata(owner: &str, ao: AccountObjectType) -> LoanMetada loan_broker_id, borrower_address, payment_remaining, + next_payment_due_date, flags, is_repaid, } @@ -161,6 +166,9 @@ pub struct LoanMetadata { pub loan_broker_id: String, pub borrower_address: String, pub payment_remaining: u32, + /// When the next payment falls due, in seconds since the Ripple Epoch. + /// Absent once the loan is fully repaid. + pub next_payment_due_date: Option, pub flags: FlagCollection, /// true once the loan has been fully repaid (PrincipalOutstanding / /// PaymentRemaining omitted from the ledger object) @@ -262,3 +270,17 @@ fn optional_amount_str(object: &serde_json::Value, field: &str) -> String { fn optional_u32(object: &serde_json::Value, field: &str) -> u32 { object.get(field).and_then(|v| v.as_u64()).unwrap_or(0) as u32 } + +/// The subscription / redemption window of a close-ended vault +/// (LendingProtocolV1_1), as `(subscription_date, redemption_date)`. +/// +/// Under LendingProtocolV1_1 a `LoanBroker` can only be attached to a +/// close-ended vault, so every lending-protocol test needs a window. The dates +/// are ledger close times, so they are derived from the latest validated +/// ledger: the standalone node's clock is not in sync with the local system +/// clock. +#[cfg(feature = "std")] +pub async fn investment_window() -> (u32, u32) { + let subscription_date = get_ledger_close_time().await as u32 + 10; + (subscription_date, subscription_date + 86_400) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f4866779..7655a1ff 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -184,13 +184,26 @@ pub async fn get_ledger_close_time() -> u64 { /// stops advancing (e.g. frozen or unresponsive `ledger_accept` requests). #[cfg(feature = "std")] pub async fn wait_for_ledger_close_time(target: u64) { - for _ in 0..60 { + wait_for_ledger_close_time_with_retries(target, 60).await +} + +/// [`wait_for_ledger_close_time`] with an explicit retry budget. +/// +/// Each `ledger_accept` advances `close_time` by one close-time resolution +/// interval, so jumping a long way forward — past a loan's payment due date, +/// say — needs a larger budget than the default. +#[cfg(feature = "std")] +pub async fn wait_for_ledger_close_time_with_retries(target: u64, retries: u32) { + if get_ledger_close_time().await >= target { + return; + } + for _ in 0..retries { + ledger_accept().await; if get_ledger_close_time().await >= target { return; } - ledger_accept().await; } - panic!("ledger close_time did not advance to {target} after 60 ledger_accept calls"); + panic!("ledger close_time did not advance to {target} after {retries} ledger_accept calls"); } /// Serialize all blockchain-mutating tests to prevent sequence number conflicts. diff --git a/tests/common/vault.rs b/tests/common/vault.rs index f6e188f3..6356a22f 100644 --- a/tests/common/vault.rs +++ b/tests/common/vault.rs @@ -43,6 +43,24 @@ pub async fn account_objects_json(owner: &str) -> Value { ) } +/// Return the first `Vault` ledger object owned by `owner`. +/// +/// `account_objects` gives no ordering guarantee, so even though the request +/// filters `type=vault` server-side this selects by `LedgerEntryType` rather +/// than trusting index 0. +#[cfg(feature = "std")] +pub async fn first_vault_object(owner: &str) -> Value { + let resp = account_objects_json(owner).await; + let objects = resp["account_objects"] + .as_array() + .expect("account_objects array missing") + .clone(); + objects + .into_iter() + .find(|object| object["LedgerEntryType"] == "Vault") + .unwrap_or_else(|| panic!("no Vault ledger object found for {owner}")) +} + /// Return the ledger object ID (`index`) of the first vault owned by `owner`. /// /// Panics if no vault is found. diff --git a/tests/requests/ledger_entry.rs b/tests/requests/ledger_entry.rs index 0390660e..69f3c98d 100644 --- a/tests/requests/ledger_entry.rs +++ b/tests/requests/ledger_entry.rs @@ -235,3 +235,100 @@ async fn test_ledger_entry_vault_by_owner_seq() { }) .await; } + +/// Fetch a `LoanBroker` via `ledger_entry` by object ID and by owner + sequence, +/// and a `Loan` by broker ID + loan sequence (XLS-66). +/// +/// rippled reference: `parseLoanBroker` / `parseLoan` in `LedgerEntry.cpp`. +#[cfg(feature = "integration")] +#[tokio::test] +async fn test_ledger_entry_loan_and_loan_broker_selectors() { + use crate::transactions::lending_protocol::create_loan_for_ledger_entry_tests; + use xrpl::models::requests::ledger_entry::{LoanBrokerIdentifier, LoanIdentifier}; + + with_blockchain_lock(|| async { + let client = crate::common::get_client().await; + let loan = create_loan_for_ledger_entry_tests().await; + + // --- LoanBroker by object ID --- + let result: LedgerEntryResult = client + .request( + LedgerEntry { + loan_broker: Some(LoanBrokerIdentifier::Id( + loan.loan_broker_id.as_str().into(), + )), + ..Default::default() + } + .into(), + ) + .await + .expect("ledger_entry loan_broker by ID failed") + .try_into() + .expect("failed to parse loan_broker result"); + assert_eq!( + result.node.as_ref().unwrap()["LedgerEntryType"].as_str(), + Some("LoanBroker") + ); + assert_eq!(result.index.as_ref(), loan.loan_broker_id.as_str()); + + // --- The same LoanBroker by owner + creating-transaction sequence --- + let result: LedgerEntryResult = client + .request( + LedgerEntry { + loan_broker: Some(LoanBrokerIdentifier::OwnerSeq { + owner: loan.owner.as_str().into(), + seq: loan.broker_sequence, + }), + ..Default::default() + } + .into(), + ) + .await + .expect("ledger_entry loan_broker by owner+seq failed") + .try_into() + .expect("failed to parse loan_broker result"); + assert_eq!( + result.index.as_ref(), + loan.loan_broker_id.as_str(), + "both selectors must resolve to the same object" + ); + + // --- Loan by broker ID + loan sequence --- + let result: LedgerEntryResult = client + .request( + LedgerEntry { + loan: Some(LoanIdentifier::BrokerSeq { + loan_broker_id: loan.loan_broker_id.as_str().into(), + loan_seq: loan.loan_sequence, + }), + ..Default::default() + } + .into(), + ) + .await + .expect("ledger_entry loan by broker+seq failed") + .try_into() + .expect("failed to parse loan result"); + assert_eq!( + result.node.as_ref().unwrap()["LedgerEntryType"].as_str(), + Some("Loan") + ); + assert_eq!(result.index.as_ref(), loan.loan_id.as_str()); + + // --- The same Loan by object ID --- + let result: LedgerEntryResult = client + .request( + LedgerEntry { + loan: Some(LoanIdentifier::Id(loan.loan_id.as_str().into())), + ..Default::default() + } + .into(), + ) + .await + .expect("ledger_entry loan by ID failed") + .try_into() + .expect("failed to parse loan result"); + assert_eq!(result.index.as_ref(), loan.loan_id.as_str()); + }) + .await; +} diff --git a/tests/transactions/lending_protocol.rs b/tests/transactions/lending_protocol.rs index 9be1bd3b..4456d685 100644 --- a/tests/transactions/lending_protocol.rs +++ b/tests/transactions/lending_protocol.rs @@ -5,11 +5,11 @@ use crate::common::{ create_transferable_clawbackable_mptoken_issuance, generate_funded_wallet, get_client, lending_protocol::{ account_objects_json, get_loan_broker_cover_available, get_loan_metadata, get_object_id, - test_lending_transaction, + investment_window, test_lending_transaction, }, test_transaction, vault::get_vault_id, - with_blockchain_lock, + wait_for_ledger_close_time, wait_for_ledger_close_time_with_retries, with_blockchain_lock, }; use xrpl::{ asynch::transaction::{autofill, sign, sign_and_submit}, @@ -24,20 +24,23 @@ use xrpl::{ loan_broker_set::LoanBrokerSet, loan_delete::LoanDelete, loan_manage::{LoanManage, LoanManageFlag}, - loan_pay::LoanPay, + loan_pay::{LoanPay, LoanPayFlag}, loan_set::LoanSet, mptoken_authorize::MPTokenAuthorize, payment::Payment, signer_list_set::{SignerEntry, SignerListSet}, trust_set::TrustSet, - vault_create::VaultCreate, + vault_create::{VaultCreate, VaultKind}, vault_deposit::VaultDeposit, CommonFields, Transaction, TransactionType, }, Amount, Currency, FlagCollection, IssuedCurrency, IssuedCurrencyAmount, MPTAmount, MPTCurrency, XRPAmount, XRP, }, - signing::sign_loan_set_by_counterparty, + signing::{ + combine_loan_set_counterparty_signers, sign_loan_set_by_counterparty, + CounterpartySigningMode, + }, wallet::Wallet, }; @@ -49,11 +52,14 @@ async fn test_lending_protocol_lifecycle() { let depositor_wallet = generate_funded_wallet().await; let borrower_wallet = generate_funded_wallet().await; + let (subscription_date, redemption_date) = investment_window().await; let vault_id = create_vault( &loan_issuer, Currency::XRP(XRP::new()), Some("1000"), Some(1), + subscription_date, + redemption_date, ) .await; @@ -72,6 +78,10 @@ async fn test_lending_protocol_lifecycle() { // The Loan Broker and Borrower create a Loan object with a LoanSet // transaction and the requested principal (excluding fees) is transferred to // the Borrower. + // LendingProtocolV1_1 only accepts a LoanSet while the close-ended vault is + // in its investment phase (subscription_date < close_time < redemption_date). + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + let mut loan_set_tx = LoanSet::new( loan_issuer.classic_address.clone().into(), None, @@ -104,13 +114,18 @@ async fn test_lending_protocol_lifecycle() { let client = get_client().await; - autofill(&mut loan_set_tx, client, Some(1)) + autofill(&mut loan_set_tx, client, None) .await .expect("Failed to auto-fill loan set transaction"); sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &borrower_wallet, false).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &borrower_wallet, + CounterpartySigningMode::Single, + ) + .unwrap(); test_lending_transaction(&mut loan_set_tx, "tesSUCCESS").await; @@ -123,12 +138,29 @@ async fn test_lending_protocol_lifecycle() { "tecHAS_OBLIGATIONS" ); + // Advance the ledger past the loan's first payment due date so the loan + // becomes overdue. Under fixCleanup3_4_0 a loan can only be impaired once a + // payment is late (parentCloseTime > NextPaymentDueDate), and the + // late-payment LoanPay below requires the same; otherwise LoanManage and + // LoanPay return tecTOO_SOON. The due date is overshot by a comfortable + // margin so the close-time-resolution-rounded parentCloseTime is safely + // past it. + wait_for_ledger_close_time_with_retries( + loan_metadata + .next_payment_due_date + .expect("loan should have a next payment due date") as u64 + + 200, + 400, + ) + .await; + impair_loan(&loan_issuer, &loan_metadata.loan_id).await; pay_loan( &borrower_wallet, &loan_metadata.loan_id, Amount::XRPAmount(XRPAmount("100".into())), + Some(FlagCollection::new(vec![LoanPayFlag::TfLoanLatePayment])), ) .await; }) @@ -150,7 +182,14 @@ async fn test_lending_protocol_with_mpt_and_multisigning() { setup_multisigning(&borrower_wallet, &signer1, &signer2).await; // Create Vault - let vault_object = create_single_asset_vault(&loan_issuer, &mpt_issuer_wallet).await; + let (subscription_date, redemption_date) = investment_window().await; + let vault_object = create_single_asset_vault( + &loan_issuer, + &mpt_issuer_wallet, + subscription_date, + redemption_date, + ) + .await; // Depositor authorizes to hold MPT, then is funded by the issuer authorize_mpt(&depositor_wallet, &vault_object.mpt_issuance_id).await; @@ -192,6 +231,10 @@ async fn test_lending_protocol_with_mpt_and_multisigning() { // Create a Loan object. // The Loan Issuer creates the transaction from their account setting the // pre-agreed terms. + // LendingProtocolV1_1 only accepts a LoanSet while the close-ended vault is + // in its investment phase (subscription_date < close_time < redemption_date). + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + let mut loan_set_tx = LoanSet::new( loan_issuer.classic_address.clone().into(), None, @@ -224,7 +267,7 @@ async fn test_lending_protocol_with_mpt_and_multisigning() { let client = get_client().await; - autofill(&mut loan_set_tx, client, Some(2)).await.unwrap(); + autofill(&mut loan_set_tx, client, None).await.unwrap(); // Loan broker signs the transaction and sends it to the borrower // The Loan Issuer signs the transaction setting the SigningPubKey, TxnSignature, Signers, Account, Fee, Sequence fields. @@ -245,9 +288,19 @@ async fn test_lending_protocol_with_mpt_and_multisigning() { // Borrower signs the transaction and fills in the CounterpartySignature to confirm the // loan terms. - sign_loan_set_by_counterparty(&mut loan_set_tx, &signer1, true).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &signer1, + CounterpartySigningMode::Multisign, + ) + .unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &signer2, true).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &signer2, + CounterpartySigningMode::Multisign, + ) + .unwrap(); test_lending_transaction(&mut loan_set_tx, "tesSUCCESS").await; @@ -307,6 +360,22 @@ async fn test_lending_protocol_with_mpt_and_multisigning() { assert_eq!(withdrawcover_available, diff); + // Advance the ledger past the loan's first payment due date so the loan + // becomes overdue. Under fixCleanup3_4_0 a loan can only be impaired once a + // payment is late (parentCloseTime > NextPaymentDueDate), and the + // late-payment LoanPay below requires the same; otherwise LoanManage and + // LoanPay return tecTOO_SOON. The due date is overshot by a comfortable + // margin so the close-time-resolution-rounded parentCloseTime is safely + // past it. + wait_for_ledger_close_time_with_retries( + loan_metadata + .next_payment_due_date + .expect("loan should have a next payment due date") as u64 + + 200, + 400, + ) + .await; + impair_loan(&loan_issuer, &loan_metadata.loan_id).await; // Assert Loan object is impaired @@ -326,6 +395,7 @@ async fn test_lending_protocol_with_mpt_and_multisigning() { value: "100000".into(), mpt_issuance_id: vault_object.mpt_issuance_id.clone().into(), }), + Some(FlagCollection::new(vec![LoanPayFlag::TfLoanLatePayment])), ) .await; @@ -414,11 +484,14 @@ async fn test_loan_set_txn_counterparty_is_loan_broker_owner() { let loan_issuer = generate_funded_wallet().await; let depositor_wallet = generate_funded_wallet().await; + let (subscription_date, redemption_date) = investment_window().await; let vault_id = create_vault( &loan_issuer, Currency::XRP(XRP::new()), Some("1000"), Some(1), + subscription_date, + redemption_date, ) .await; @@ -436,6 +509,10 @@ async fn test_loan_set_txn_counterparty_is_loan_broker_owner() { // LoanBroker, i.e. loan_issuer account) create a Loan object with a LoanSet // transaction and the requested principal (excluding fees) is transferred to // the Borrower. + // LendingProtocolV1_1 only accepts a LoanSet while the close-ended vault is + // in its investment phase (subscription_date < close_time < redemption_date). + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + let mut loan_set_tx = LoanSet::new( loan_issuer.classic_address.clone().into(), None, @@ -468,13 +545,18 @@ async fn test_loan_set_txn_counterparty_is_loan_broker_owner() { let client = get_client().await; - autofill(&mut loan_set_tx, client, Some(1)) + autofill(&mut loan_set_tx, client, None) .await .expect("Failed to auto-fill loan set transaction"); sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &loan_issuer, false).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &loan_issuer, + CounterpartySigningMode::Single, + ) + .unwrap(); test_lending_transaction(&mut loan_set_tx, "tesSUCCESS").await; @@ -589,6 +671,7 @@ async fn test_lending_protocol_lifecycle_with_iou_asset() { test_transaction(&mut payment_tx, &loan_issuer).await; + let (subscription_date, redemption_date) = investment_window().await; let vault_id = create_vault( &loan_issuer, Currency::IssuedCurrency(IssuedCurrency::new( @@ -597,6 +680,8 @@ async fn test_lending_protocol_lifecycle_with_iou_asset() { )), Some("10000"), Some(1), + subscription_date, + redemption_date, ) .await; @@ -617,6 +702,10 @@ async fn test_lending_protocol_lifecycle_with_iou_asset() { // The Loan Broker and Borrower create a Loan object with a LoanSet // transaction and the requested principal (excluding fees) is transferred to // the Borrower. + // LendingProtocolV1_1 only accepts a LoanSet while the close-ended vault is + // in its investment phase (subscription_date < close_time < redemption_date). + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + let mut loan_set_tx = LoanSet::new( loan_issuer.classic_address.clone().into(), None, @@ -649,13 +738,18 @@ async fn test_lending_protocol_lifecycle_with_iou_asset() { let client = get_client().await; - autofill(&mut loan_set_tx, client, Some(1)) + autofill(&mut loan_set_tx, client, None) .await .expect("Failed to auto-fill loan set transaction"); sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &borrower_wallet, false).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &borrower_wallet, + CounterpartySigningMode::Single, + ) + .unwrap(); test_lending_transaction(&mut loan_set_tx, "tesSUCCESS").await; @@ -670,6 +764,22 @@ async fn test_lending_protocol_lifecycle_with_iou_asset() { "tecHAS_OBLIGATIONS" ); + // Advance the ledger past the loan's first payment due date so the loan + // becomes overdue. Under fixCleanup3_4_0 a loan can only be impaired once a + // payment is late (parentCloseTime > NextPaymentDueDate), and the + // late-payment LoanPay below requires the same; otherwise LoanManage and + // LoanPay return tecTOO_SOON. The due date is overshot by a comfortable + // margin so the close-time-resolution-rounded parentCloseTime is safely + // past it. + wait_for_ledger_close_time_with_retries( + loan_metadata + .next_payment_due_date + .expect("loan should have a next payment due date") as u64 + + 200, + 400, + ) + .await; + impair_loan(&loan_issuer, &loan_metadata.loan_id).await; pay_loan( @@ -680,6 +790,7 @@ async fn test_lending_protocol_lifecycle_with_iou_asset() { issuer: loan_issuer.classic_address.clone().into(), value: "100".into(), }), + Some(FlagCollection::new(vec![LoanPayFlag::TfLoanLatePayment])), ) .await; }) @@ -693,11 +804,14 @@ async fn test_loan_set_with_sign_loan_set_by_counterparty() { let depositor_wallet = generate_funded_wallet().await; let borrower_wallet = generate_funded_wallet().await; + let (subscription_date, redemption_date) = investment_window().await; let vault_id = create_vault( &loan_issuer, Currency::XRP(XRP::new()), Some("1000"), Some(1), + subscription_date, + redemption_date, ) .await; let loan_broker_id = create_loan_broker(&loan_issuer, &vault_id, Some("10000")).await; @@ -710,6 +824,10 @@ async fn test_loan_set_with_sign_loan_set_by_counterparty() { ) .await; + // LendingProtocolV1_1 only accepts a LoanSet while the close-ended vault is + // in its investment phase (subscription_date < close_time < redemption_date). + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + let mut loan_set_tx = LoanSet::new( loan_issuer.classic_address.clone().into(), None, @@ -742,13 +860,18 @@ async fn test_loan_set_with_sign_loan_set_by_counterparty() { let client = get_client().await; - autofill(&mut loan_set_tx, client, Some(1)) + autofill(&mut loan_set_tx, client, None) .await .expect("Failed to auto-fill loan set transaction"); sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &borrower_wallet, false).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &borrower_wallet, + CounterpartySigningMode::Single, + ) + .unwrap(); assert!(loan_set_tx.counterparty_signature.is_some()); assert!(loan_set_tx @@ -775,7 +898,7 @@ async fn test_loan_set_with_sign_loan_set_by_counterparty() { } #[tokio::test] -async fn test_loan_set_with_combine_loanset_counterparty_signers() { +async fn test_loan_set_with_accumulated_counterparty_multisign() { with_blockchain_lock(|| async { // The Vault Owner and Loan Broker must be on the same account let loan_issuer = generate_funded_wallet().await; @@ -787,11 +910,14 @@ async fn test_loan_set_with_combine_loanset_counterparty_signers() { // Setup Multi-Signing setup_multisigning(&borrower_wallet, &signer1, &signer2).await; + let (subscription_date, redemption_date) = investment_window().await; let vault_id = create_vault( &loan_issuer, Currency::XRP(XRP::new()), Some("1000"), Some(1), + subscription_date, + redemption_date, ) .await; let loan_broker_id = create_loan_broker(&loan_issuer, &vault_id, Some("10000")).await; @@ -804,6 +930,10 @@ async fn test_loan_set_with_combine_loanset_counterparty_signers() { ) .await; + // LendingProtocolV1_1 only accepts a LoanSet while the close-ended vault is + // in its investment phase (subscription_date < close_time < redemption_date). + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + let mut loan_set_tx = LoanSet::new( loan_issuer.classic_address.clone().into(), None, @@ -836,15 +966,25 @@ async fn test_loan_set_with_combine_loanset_counterparty_signers() { let client = get_client().await; - autofill(&mut loan_set_tx, client, Some(2)) + autofill(&mut loan_set_tx, client, None) .await .expect("Failed to auto-fill loan set transaction"); sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &signer1, true).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &signer1, + CounterpartySigningMode::Multisign, + ) + .unwrap(); - sign_loan_set_by_counterparty(&mut loan_set_tx, &signer2, true).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &signer2, + CounterpartySigningMode::Multisign, + ) + .unwrap(); assert!(loan_set_tx.counterparty_signature.is_some()); assert!(loan_set_tx @@ -876,11 +1016,19 @@ async fn test_loan_set_with_combine_loanset_counterparty_signers() { } /// Creates a Vault with the given `currency` and returns its vault id. +/// Creates the vault a LoanBroker will lend from. +/// +/// Under LendingProtocolV1_1 a LoanBroker can only be attached to a +/// close-ended vault, so the vault is always created with a `VaultKind` of +/// `Closed` plus the subscription / redemption window bounding its investment +/// period. async fn create_vault<'a>( loan_issuer: &Wallet, currency: Currency<'a>, assets_maximum: Option<&str>, withdrawal_policy: Option, + subscription_date: u32, + redemption_date: u32, ) -> String { let mut vault_create_tx = VaultCreate::new( loan_issuer.classic_address.as_str().into(), @@ -900,7 +1048,10 @@ async fn create_vault<'a>( None, withdrawal_policy, None, - ); + ) + .with_vault_kind(VaultKind::Closed) + .with_subscription_date(subscription_date) + .with_redemption_date(redemption_date); let client = get_client().await; @@ -916,6 +1067,8 @@ async fn create_vault<'a>( async fn create_single_asset_vault( loan_issuer: &Wallet, mpt_issuer_wallet: &Wallet, + subscription_date: u32, + redemption_date: u32, ) -> VaultObject { let mpt_issuance_id = create_transferable_clawbackable_mptoken_issuance(mpt_issuer_wallet).await; @@ -925,6 +1078,8 @@ async fn create_single_asset_vault( Currency::MPTCurrency(MPTCurrency::new(mpt_issuance_id.clone().into())), None, None, + subscription_date, + redemption_date, ) .await; @@ -1003,12 +1158,21 @@ async fn impair_loan(loan_issuer: &Wallet, loan_id: &str) { } /// Makes a payment of `amount` towards the loan identified by `loan_id`. -async fn pay_loan<'a>(borrower: &Wallet, loan_id: &str, amount: Amount<'a>) { +/// +/// `flags` carries the payment type: an overdue loan has to be paid with +/// `TfLoanLatePayment`, otherwise `fixCleanup3_4_0` rejects the payment with +/// `tecEXPIRED`. +async fn pay_loan<'a>( + borrower: &Wallet, + loan_id: &str, + amount: Amount<'a>, + flags: Option>, +) { let mut loan_pay_tx = LoanPay::new( borrower.classic_address.clone().into(), None, None, - None, + flags, None, None, None, @@ -1230,11 +1394,141 @@ pub async fn setup_multisigning(wallet: &Wallet, signer1: &Wallet, signer2: &Wal // decided entirely from the transaction's existing signature fields. They live // here, next to the live-rippled counterparty-signing tests, so the whole // counterparty signing surface is covered by one suite. +/// The counterparty signers sign independently, their copies are merged with +/// `combine_loan_set_counterparty_signers`, and the combined transaction is +/// submitted to the ledger. +/// +/// This is the distributed-signing flow the combine API exists for, and the +/// only test that puts its output on a ledger. xrpl.js reference: +/// `combineLoanSetCounterpartySigners` in `lendingProtocol.test.ts`. +#[tokio::test] +async fn test_loan_set_with_combined_counterparty_signers() { + with_blockchain_lock(|| async { + let loan_issuer = generate_funded_wallet().await; + let depositor_wallet = generate_funded_wallet().await; + let borrower_wallet = generate_funded_wallet().await; + let signer1 = generate_funded_wallet().await; + let signer2 = generate_funded_wallet().await; + + setup_multisigning(&borrower_wallet, &signer1, &signer2).await; + + let (subscription_date, redemption_date) = investment_window().await; + let vault_id = create_vault( + &loan_issuer, + Currency::XRP(XRP::new()), + Some("1000"), + Some(1), + subscription_date, + redemption_date, + ) + .await; + let loan_broker_id = create_loan_broker(&loan_issuer, &vault_id, Some("10000")).await; + + deposit_into_vault( + &depositor_wallet, + &vault_id, + Amount::XRPAmount(XRPAmount("100".into())), + ) + .await; + + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + + let mut loan_set_tx = LoanSet::new( + loan_issuer.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + None, + loan_broker_id.clone().into(), + None, + Some(borrower_wallet.classic_address.as_str().into()), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "100".into(), + None, + None, + None, + ); + + let client = get_client().await; + + // autofill accounts for the counterparty's signer list, so the Fee + // already covers both signatures — it cannot be raised later, since + // `Fee` is a signing field. + autofill(&mut loan_set_tx, client, None) + .await + .expect("Failed to auto-fill loan set transaction"); + + sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); + + // Each signer signs its own copy, as it would on its own machine. + let mut signer1_copy = loan_set_tx.clone(); + sign_loan_set_by_counterparty( + &mut signer1_copy, + &signer1, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut signer2_copy = loan_set_tx.clone(); + sign_loan_set_by_counterparty( + &mut signer2_copy, + &signer2, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + combine_loan_set_counterparty_signers(&mut loan_set_tx, &[signer1_copy, signer2_copy]) + .unwrap(); + + assert_eq!( + loan_set_tx + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap() + .len(), + 2 + ); + + test_lending_transaction(&mut loan_set_tx, "tesSUCCESS").await; + + // The loan exists, so the ledger accepted the combined signatures. + let loan_metadata = + get_loan_metadata(&borrower_wallet.classic_address, AccountObjectType::Loan).await; + assert_eq!(loan_metadata.loan_broker_id, loan_broker_id); + assert_eq!( + loan_metadata.borrower_address, + borrower_wallet.classic_address + ); + }) + .await +} + mod counterparty_signing_rejections { use xrpl::core::addresscodec::decode_classic_address; use xrpl::models::transactions::loan_set::{CounterpartySignature, LoanSet}; + use xrpl::models::transactions::Signer; use xrpl::models::transactions::{Transaction, TransactionType}; - use xrpl::signing::{sign, sign_loan_set_by_counterparty}; + use xrpl::signing::{ + combine_loan_set_counterparty_signers, sign, sign_loan_set_by_counterparty, + CounterpartySigningMode, + }; use xrpl::wallet::Wallet; const LOAN_BROKER_ID: &str = "E123F4567890ABCDE123F4567890ABCDEF1234567890ABCDEF1234567890ABCD"; @@ -1266,7 +1560,9 @@ mod counterparty_signing_rejections { let (broker, borrower, _) = wallets(); let mut tx = loan_set(&broker, &borrower); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .unwrap_err(); assert!( err.to_string().contains("first-party signature"), "unexpected error: {}", @@ -1284,7 +1580,7 @@ mod counterparty_signing_rejections { sign(&mut tx, &broker, true).unwrap(); assert!(tx.get_common_fields().signers.is_some()); - sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single).unwrap(); assert!(tx .counterparty_signature .as_ref() @@ -1299,9 +1595,11 @@ mod counterparty_signing_rejections { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single).unwrap(); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .unwrap_err(); assert!( err.to_string() .contains("already signed by the counterparty"), @@ -1318,9 +1616,12 @@ mod counterparty_signing_rejections { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, false).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .unwrap_err(); assert!( err.to_string() .contains("multisign counterparty signatures"), @@ -1335,9 +1636,12 @@ mod counterparty_signing_rejections { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); - let err = sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap_err(); + let err = + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap_err(); assert!( err.to_string() .contains("This counterparty account has already signed"), @@ -1354,8 +1658,10 @@ mod counterparty_signing_rejections { let mut tx = loan_set(&broker, &borrower); sign(&mut tx, &broker, false).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &borrower, true).unwrap(); - sign_loan_set_by_counterparty(&mut tx, &second, true).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); + sign_loan_set_by_counterparty(&mut tx, &second, CounterpartySigningMode::Multisign) + .unwrap(); let cs = tx.counterparty_signature.as_ref().unwrap(); assert!(cs.signing_pub_key.is_none()); @@ -1387,6 +1693,598 @@ mod counterparty_signing_rejections { signers: Some(Vec::new()), }); - assert!(sign_loan_set_by_counterparty(&mut tx, &borrower, false).is_err()); + assert!( + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Single) + .is_err() + ); + } + + // ── combine_loan_set_counterparty_signers ────────────────────────── + // + // Merging independently-produced counterparty signatures is decided + // entirely from the transactions' own fields, so these need no ledger + // either. xrpl.js reference: `combineLoanSetCounterpartySigners`. + + /// Two counterparties sign their own copy; the copies are then merged. + #[test] + fn test_combine_merges_independently_signed_copies() { + let (broker, borrower, second) = wallets(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut combined = base.clone(); + combine_loan_set_counterparty_signers(&mut combined, &[first_copy, second_copy]).unwrap(); + + let cs = combined.counterparty_signature.as_ref().unwrap(); + assert!(cs.signing_pub_key.is_none()); + assert!(cs.txn_signature.is_none()); + + let signers = cs.signers.as_ref().unwrap(); + assert_eq!(signers.len(), 2); + let submitted: Vec> = signers + .iter() + .map(|s| decode_classic_address(&s.account).unwrap()) + .collect(); + let mut sorted = submitted.clone(); + sorted.sort(); + assert_eq!( + submitted, sorted, + "Signers must be sorted by decoded account id" + ); + } + + /// A first party that multisigned is a valid starting point, so its copies + /// can be combined too. + #[test] + fn test_combine_accepts_multisigned_first_party() { + let (broker, borrower, second) = wallets(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, true).unwrap(); + assert!(base.get_common_fields().signers.is_some()); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut combined = base.clone(); + combine_loan_set_counterparty_signers(&mut combined, &[first_copy, second_copy]).unwrap(); + + assert_eq!( + combined + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn test_combine_rejects_empty_list() { + let (broker, borrower, _) = wallets(); + let mut combined = loan_set(&broker, &borrower); + + let err = combine_loan_set_counterparty_signers(&mut combined, &[]).unwrap_err(); + assert!( + err.to_string().contains("0 transactions to combine"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_combine_rejects_unsigned_first_party() { + let (broker, borrower, _) = wallets(); + + let mut unsigned = loan_set(&broker, &borrower); + unsigned.counterparty_signature = Some(CounterpartySignature { + signing_pub_key: None, + txn_signature: None, + signers: Some(vec![Signer { + account: borrower.classic_address.clone(), + signing_pub_key: borrower.public_key.clone(), + txn_signature: "DEADBEEF".into(), + }]), + }); + + let mut combined = loan_set(&broker, &borrower); + let err = combine_loan_set_counterparty_signers(&mut combined, &[unsigned]).unwrap_err(); + assert!( + err.to_string().contains("signed by the first party"), + "unexpected error: {}", + err + ); + } + + /// A single-signed counterparty signature carries no `Signers`, so it + /// cannot take part in a combine. + #[test] + fn test_combine_rejects_single_signed_counterparty() { + let (broker, borrower, _) = wallets(); + + let mut single = loan_set(&broker, &borrower); + sign(&mut single, &broker, false).unwrap(); + sign_loan_set_by_counterparty(&mut single, &borrower, CounterpartySigningMode::Single) + .unwrap(); + + let mut combined = loan_set(&broker, &borrower); + sign(&mut combined, &broker, false).unwrap(); + + let err = combine_loan_set_counterparty_signers(&mut combined, &[single]).unwrap_err(); + assert!( + err.to_string().contains("must carry counterparty Signers"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_combine_rejects_duplicate_signer() { + let (broker, borrower, _) = wallets(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let duplicate = first_copy.clone(); + + let mut combined = base; + let err = combine_loan_set_counterparty_signers(&mut combined, &[first_copy, duplicate]) + .unwrap_err(); + assert!( + err.to_string().contains("signed more than once"), + "unexpected error: {}", + err + ); + } + + /// The signatures cover the loan terms, so copies that disagree on those + /// terms cannot be merged. + #[test] + fn test_combine_rejects_mismatched_copies() { + let (broker, borrower, second) = wallets(); + + let mut first_copy = loan_set(&broker, &borrower); + sign(&mut first_copy, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + let mut tampered = loan_set(&broker, &borrower); + tampered.principal_requested = "999999".into(); + sign(&mut tampered, &broker, false).unwrap(); + sign_loan_set_by_counterparty(&mut tampered, &second, CounterpartySigningMode::Multisign) + .unwrap(); + + let mut combined = loan_set(&broker, &borrower); + sign(&mut combined, &broker, false).unwrap(); + + let err = combine_loan_set_counterparty_signers(&mut combined, &[first_copy, tampered]) + .unwrap_err(); + assert!( + err.to_string().contains("not the same transaction"), + "unexpected error: {}", + err + ); + } + + /// Combining into a different `LoanSet` would attach signatures that do not + /// cover that transaction's terms. + #[test] + fn test_combine_rejects_mismatched_destination() { + let (broker, borrower, second) = wallets(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + // Same signers, but the destination asks for a different principal. + let mut other_terms = loan_set(&broker, &borrower); + other_terms.principal_requested = "999999".into(); + sign(&mut other_terms, &broker, false).unwrap(); + + let err = + combine_loan_set_counterparty_signers(&mut other_terms, &[first_copy, second_copy]) + .unwrap_err(); + assert!( + err.to_string() + .contains("not the transaction that was signed"), + "unexpected error: {}", + err + ); + } + /// A wallet holding an account's RegularKey can sign on that account's + /// behalf: the signature and `Signer.Account` both use the declared + /// account, not the wallet's own address. + #[test] + fn test_multisign_as_another_account() { + let (broker, borrower, regular_key) = wallets(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap(); + + let signers = tx + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap(); + assert_eq!(signers.len(), 1); + assert_eq!( + signers[0].account, borrower.classic_address, + "Signer.Account must be the declared account, not the signing wallet" + ); + assert_eq!( + signers[0].signing_pub_key, regular_key.public_key, + "the public key must be the regular key that produced the signature" + ); + // The signature covers the declared account, so it differs from one made + // as the wallet's own account. + let mut own_account = loan_set(&broker, &borrower); + sign(&mut own_account, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut own_account, + ®ular_key, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + assert_ne!( + signers[0].txn_signature, + own_account + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap()[0] + .txn_signature + ); + } + + /// The destination must be free of a counterparty signature — overwriting + /// one would silently discard it. + #[test] + fn test_combine_rejects_already_signed_destination() { + let (broker, borrower, second) = wallets(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + + // The destination already carries a single-sign counterparty signature. + let mut destination = base.clone(); + sign_loan_set_by_counterparty(&mut destination, &borrower, CounterpartySigningMode::Single) + .unwrap(); + + let err = + combine_loan_set_counterparty_signers(&mut destination, &[first_copy, second_copy]) + .unwrap_err(); + assert!( + err.to_string() + .contains("already carries a counterparty signature"), + "unexpected error: {}", + err + ); + } + + /// Copies that differ only in a non-signing field (the first party's own + /// `TxnSignature`) still combine: no signature covers those bytes. + #[test] + fn test_combine_ignores_non_signing_field_differences() { + let (broker, borrower, second) = wallets(); + + let mut base = loan_set(&broker, &borrower); + sign(&mut base, &broker, false).unwrap(); + + let mut first_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut first_copy, + &borrower, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + let mut second_copy = base.clone(); + sign_loan_set_by_counterparty( + &mut second_copy, + &second, + CounterpartySigningMode::Multisign, + ) + .unwrap(); + // Same terms, but this copy's first-party signature bytes differ. + second_copy.common_fields.txn_signature = Some("DEADBEEF".into()); + + let mut combined = base.clone(); + combine_loan_set_counterparty_signers(&mut combined, &[first_copy, second_copy]).unwrap(); + assert_eq!( + combined + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap() + .len(), + 2 + ); + } + + /// The duplicate guard has to compare against the account the signature is + /// recorded under, which is the declared account under `MultisignAs` — not + /// the signing wallet's own address. rippled rejects a `Signers` array + /// holding the same account twice. + #[test] + fn test_rejects_duplicate_multisign_as_same_declared_account() { + let (broker, borrower, regular_key) = wallets(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap(); + + // The same declared account signing again, with any key. + let err = sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("This counterparty account has already signed"), + "unexpected error: {}", + err + ); + } + + /// The mirror case: the account already multisigned for itself, so a + /// regular-key signature on its behalf is also a duplicate. + #[test] + fn test_rejects_multisign_as_account_that_already_signed_itself() { + let (broker, borrower, regular_key) = wallets(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + sign_loan_set_by_counterparty(&mut tx, &borrower, CounterpartySigningMode::Multisign) + .unwrap(); + + let err = sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("This counterparty account has already signed"), + "unexpected error: {}", + err + ); + } + + /// A different declared account is still allowed to sign. + #[test] + fn test_multisign_as_distinct_accounts_accumulate() { + let (broker, borrower, second) = wallets(); + let (regular_key, _, _) = wallets(); + + let mut tx = loan_set(&broker, &borrower); + sign(&mut tx, &broker, false).unwrap(); + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(borrower.classic_address.clone().into()), + ) + .unwrap(); + sign_loan_set_by_counterparty( + &mut tx, + ®ular_key, + CounterpartySigningMode::MultisignAs(second.classic_address.clone().into()), + ) + .unwrap(); + + let signers = tx + .counterparty_signature + .as_ref() + .unwrap() + .signers + .as_ref() + .unwrap(); + assert_eq!(signers.len(), 2); + assert_ne!(signers[0].account, signers[1].account); + } +} + +/// A loan created for the `ledger_entry` selector tests, with the identifiers +/// and sequences those selectors need. +#[cfg(feature = "integration")] +pub struct LedgerEntryLoanFixture { + pub owner: String, + pub loan_broker_id: String, + pub loan_id: String, + /// Sequence of the `LoanBrokerSet` transaction that created the broker — + /// what `keylet::loanBroker(owner, seq)` is derived from. + pub broker_sequence: u32, + /// The loan's `LoanSequence`, which `keylet::loan(brokerID, seq)` uses. + pub loan_sequence: u32, +} + +/// Runs the full happy path — close-ended vault, loan broker, funded vault, +/// dual-signed `LoanSet` — and returns the identifiers needed to look the +/// resulting objects up with `ledger_entry`. +#[cfg(feature = "integration")] +pub async fn create_loan_for_ledger_entry_tests() -> LedgerEntryLoanFixture { + let loan_issuer = generate_funded_wallet().await; + let depositor_wallet = generate_funded_wallet().await; + let borrower_wallet = generate_funded_wallet().await; + + let (subscription_date, redemption_date) = investment_window().await; + let vault_id = create_vault( + &loan_issuer, + Currency::XRP(XRP::new()), + Some("1000"), + Some(1), + subscription_date, + redemption_date, + ) + .await; + + let loan_broker_id = create_loan_broker(&loan_issuer, &vault_id, None).await; + + deposit_into_vault( + &depositor_wallet, + &vault_id, + Amount::XRPAmount(XRPAmount("100".into())), + ) + .await; + + wait_for_ledger_close_time((subscription_date + 1) as u64).await; + + let mut loan_set_tx = LoanSet::new( + loan_issuer.classic_address.clone().into(), + None, + None, + None, + None, + None, + None, + None, + None, + None, + loan_broker_id.clone().into(), + None, + Some(borrower_wallet.classic_address.as_str().into()), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "100".into(), + None, + None, + None, + ); + + let client = get_client().await; + autofill(&mut loan_set_tx, client, None) + .await + .expect("Failed to auto-fill loan set transaction"); + sign(&mut loan_set_tx, &loan_issuer, false).unwrap(); + sign_loan_set_by_counterparty( + &mut loan_set_tx, + &borrower_wallet, + CounterpartySigningMode::Single, + ) + .unwrap(); + test_lending_transaction(&mut loan_set_tx, "tesSUCCESS").await; + + // Read the sequences straight off the created objects rather than tracking + // transaction sequences. + let broker_objects = + account_objects_json(&loan_issuer.classic_address, AccountObjectType::LoanBroker).await; + let broker_sequence = broker_objects["account_objects"][0]["Sequence"] + .as_u64() + .expect("LoanBroker.Sequence missing") as u32; + + let loan_objects = + account_objects_json(&borrower_wallet.classic_address, AccountObjectType::Loan).await; + let loan_id = loan_objects["account_objects"][0]["index"] + .as_str() + .expect("Loan index missing") + .to_string(); + let loan_sequence = loan_objects["account_objects"][0]["LoanSequence"] + .as_u64() + .expect("Loan.LoanSequence missing") as u32; + + LedgerEntryLoanFixture { + owner: loan_issuer.classic_address.clone(), + loan_broker_id, + loan_id, + broker_sequence, + loan_sequence, } } diff --git a/tests/transactions/lending_protocol_v1_1.rs b/tests/transactions/lending_protocol_v1_1.rs new file mode 100644 index 00000000..ac9d24ca --- /dev/null +++ b/tests/transactions/lending_protocol_v1_1.rs @@ -0,0 +1,244 @@ +// xrpl.js reference: packages/xrpl/test/integration/transactions/lendingProtocolV1_1.test.ts +// (XRPLF/xrpl.js#3456) +// Specs: XLS-0058 (https://github.com/XRPLF/XRPL-Standards/pull/582), +// XLS-0066 V1_1 (https://github.com/XRPLF/XRPL-Standards/pull/587) +// +// Scenarios: +// - close_ended_vault: VaultCreate with VaultKind=1 + SubscriptionDate / +// RedemptionDate, verify the fields land on the Vault ledger object +// - vault_delete_with_memo_data: VaultDelete carrying MemoData, verify the +// vault is gone afterwards +// - domain_gated_withdraw: withdraw from a private, domain-gated vault by +// presenting CredentialIDs (XLS-70) + +use crate::common::{ + generate_funded_wallet, get_client, get_ledger_close_time, provision_credential, + test_transaction, + vault::{account_objects_json, first_vault_object, get_vault_id}, + with_blockchain_lock, +}; +use xrpl::asynch::clients::XRPLAsyncClient; +use xrpl::models::ledger::objects::vault::Vault; +use xrpl::models::requests::account_objects::{AccountObjectType, AccountObjects}; +use xrpl::models::transactions::{ + permissioned_domain_set::PermissionedDomainSet, + vault_create::{VaultCreate, VaultCreateFlag, VaultKind}, + vault_delete::VaultDelete, + vault_deposit::VaultDeposit, + vault_withdraw::VaultWithdraw, + CommonFields, Credential, TransactionType, +}; +use xrpl::models::{Amount, Currency, FlagCollection, XRPAmount, XRP}; +use xrpl::wallet::Wallet; + +/// `SubscriptionDate` / `RedemptionDate` are ledger close times, so they are +/// derived from the latest validated ledger: the standalone node's clock is not +/// in sync with the local system clock. +async fn investment_window() -> (u32, u32) { + let close_time = get_ledger_close_time().await as u32; + let subscription_date = close_time + 300; + (subscription_date, subscription_date + 3600) +} + +fn xrp_vault<'a>(owner: &Wallet) -> VaultCreate<'a> { + VaultCreate { + common_fields: CommonFields { + account: owner.classic_address.clone().into(), + transaction_type: TransactionType::VaultCreate, + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + withdrawal_policy: Some(1), + ..Default::default() + } +} + +#[tokio::test] +async fn test_create_close_ended_vault() { + with_blockchain_lock(|| async { + let vault_owner = generate_funded_wallet().await; + let (subscription_date, redemption_date) = investment_window().await; + + let mut tx = xrp_vault(&vault_owner) + .with_assets_maximum("1000".into()) + .with_vault_kind(VaultKind::Closed) + .with_subscription_date(subscription_date) + .with_redemption_date(redemption_date); + + test_transaction(&mut tx, &vault_owner).await; + + let vault = first_vault_object(&vault_owner.classic_address).await; + + assert_eq!( + vault["Owner"].as_str(), + Some(vault_owner.classic_address.as_str()) + ); + assert_eq!(vault["VaultKind"].as_u64(), Some(1)); + assert_eq!( + vault["SubscriptionDate"].as_u64(), + Some(subscription_date as u64) + ); + assert_eq!( + vault["RedemptionDate"].as_u64(), + Some(redemption_date as u64) + ); + + // The same object has to round-trip through the typed `Vault` ledger + // model, which is how library users read it. + let typed: Vault = + serde_json::from_value(vault.clone()).expect("failed to deserialize Vault"); + assert_eq!(typed.vault_kind, Some(1)); + assert_eq!(typed.subscription_date, Some(subscription_date)); + assert_eq!(typed.redemption_date, Some(redemption_date)); + assert!( + typed.le_version.is_some(), + "a LendingProtocolV1_1 server sets LEVersion" + ); + }) + .await +} + +#[tokio::test] +async fn test_delete_vault_with_memo_data() { + with_blockchain_lock(|| async { + let vault_owner = generate_funded_wallet().await; + + let mut create = xrp_vault(&vault_owner); + test_transaction(&mut create, &vault_owner).await; + + let vault_id = get_vault_id(&vault_owner.classic_address).await; + + let mut delete = VaultDelete { + common_fields: CommonFields { + account: vault_owner.classic_address.clone().into(), + transaction_type: TransactionType::VaultDelete, + ..Default::default() + }, + vault_id: vault_id.clone().into(), + ..Default::default() + } + // hex("closing vault") + .with_memo_data("636C6F73696E67207661756C74".into()); + + test_transaction(&mut delete, &vault_owner).await; + + let objects = account_objects_json(&vault_owner.classic_address).await; + assert!( + objects["account_objects"] + .as_array() + .expect("account_objects array missing") + .is_empty(), + "vault should be deleted from account objects" + ); + }) + .await +} + +#[tokio::test] +async fn test_withdraw_from_domain_gated_vault_with_credential_ids() { + with_blockchain_lock(|| async { + let vault_owner = generate_funded_wallet().await; + let depositor = generate_funded_wallet().await; + // hex("lp-kyc") + let credential_type = "6C702D6B7963"; + + // The owner issues a credential to the depositor, who accepts it. + let credential_id = provision_credential(&vault_owner, &depositor, credential_type).await; + + // The owner opens a permissioned domain that accepts that credential. + let mut pd_set = PermissionedDomainSet { + common_fields: CommonFields { + account: vault_owner.classic_address.clone().into(), + transaction_type: TransactionType::PermissionedDomainSet, + ..Default::default() + }, + accepted_credentials: vec![Credential { + issuer: vault_owner.classic_address.clone(), + credential_type: credential_type.to_string(), + }], + domain_id: None, + }; + test_transaction(&mut pd_set, &vault_owner).await; + + let client = get_client().await; + let pd_resp = client + .request( + AccountObjects::new( + None, + vault_owner.classic_address.clone().into(), + None, + None, + Some(AccountObjectType::PermissionedDomain), + None, + None, + None, + ) + .into(), + ) + .await + .expect("permissioned_domain account_objects request failed"); + let domain_id = pd_resp + .raw_result + .expect("account_objects response contained no raw_result")["account_objects"] + .as_array() + .expect("account_objects array missing") + .iter() + .find(|object| object["LedgerEntryType"] == "PermissionedDomain") + .expect("no PermissionedDomain ledger object found")["index"] + .as_str() + .expect("permissioned domain index missing") + .to_string(); + + // A private vault gated on that domain. + let mut vault_create = VaultCreate { + common_fields: CommonFields { + account: vault_owner.classic_address.clone().into(), + transaction_type: TransactionType::VaultCreate, + flags: FlagCollection::new(vec![VaultCreateFlag::TfVaultPrivate]), + ..Default::default() + }, + asset: Currency::XRP(XRP::new()), + withdrawal_policy: Some(1), + domain_id: Some(domain_id.clone().into()), + ..Default::default() + }; + test_transaction(&mut vault_create, &vault_owner).await; + + let vault_id = get_vault_id(&vault_owner.classic_address).await; + + // The depositor is a domain member, so the deposit needs no credentials. + let mut deposit = VaultDeposit { + common_fields: CommonFields { + account: depositor.classic_address.clone().into(), + transaction_type: TransactionType::VaultDeposit, + ..Default::default() + }, + vault_id: vault_id.clone().into(), + amount: Amount::XRPAmount(XRPAmount::from("1000000")), + ..Default::default() + }; + test_transaction(&mut deposit, &depositor).await; + + // The withdrawal presents the credential that proves membership. + let mut withdraw = VaultWithdraw { + common_fields: CommonFields { + account: depositor.classic_address.clone().into(), + transaction_type: TransactionType::VaultWithdraw, + ..Default::default() + }, + vault_id: vault_id.clone().into(), + amount: Amount::XRPAmount(XRPAmount::from("500000")), + ..Default::default() + } + .with_credential_ids(vec![credential_id.clone().into()]); + test_transaction(&mut withdraw, &depositor).await; + + let vault = first_vault_object(&vault_owner.classic_address).await; + assert_eq!( + vault["AssetsTotal"].as_str().unwrap_or("0"), + "500000", + "half of the deposit should remain in the vault" + ); + }) + .await +} diff --git a/tests/transactions/mod.rs b/tests/transactions/mod.rs index 0d8ef8b6..5c98f420 100644 --- a/tests/transactions/mod.rs +++ b/tests/transactions/mod.rs @@ -20,6 +20,7 @@ pub mod escrow_cancel; pub mod escrow_create; pub mod escrow_finish; pub mod lending_protocol; +pub mod lending_protocol_v1_1; pub mod mptoken_authorize; pub mod mptoken_issuance_create; pub mod mptoken_issuance_destroy; diff --git a/tests/transactions/vault_create.rs b/tests/transactions/vault_create.rs index f66f7bd1..7d80fd90 100644 --- a/tests/transactions/vault_create.rs +++ b/tests/transactions/vault_create.rs @@ -169,6 +169,7 @@ mod tests { )), destination: Some(holder.classic_address.clone().into()), destination_tag: Some(10), + credential_ids: None, }; test_transaction(&mut vault_withdraw, &holder).await; @@ -202,6 +203,7 @@ mod tests { ..Default::default() }, vault_id: vault_id.into(), + memo_data: None, }; test_transaction(&mut vault_delete, &vault_owner).await; @@ -326,6 +328,7 @@ mod tests { }), destination: Some(holder.classic_address.clone().into()), destination_tag: None, + credential_ids: None, }; test_transaction(&mut vault_withdraw, &holder).await; assert_eq!( @@ -363,6 +366,7 @@ mod tests { ..Default::default() }, vault_id: vault_id.into(), + memo_data: None, }; test_transaction(&mut vault_delete, &vault_owner).await; assert_eq!(