diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 5624ee8014..b98388d893 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -4,7 +4,7 @@ name: Node.js CI env: - RIPPLED_DOCKER_IMAGE: rippleci/rippled:develop + RIPPLED_DOCKER_IMAGE: legleux/xrpld:sponsor GIT_REF: ${{ inputs.git_ref || github.ref }} on: diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index ccd7b7990f..471c3a0a14 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -60,6 +60,26 @@ "type": "Amount" } ], + [ + "FeeAmount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 32, + "type": "Amount" + } + ], + [ + "MaxFee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 33, + "type": "Amount" + } + ], [ "LedgerEntryType", { @@ -850,6 +870,56 @@ "type": "UInt32" } ], + [ + "SponsorFlags", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 73, + "type": "UInt32" + } + ], + [ + "SponsoredOwnerCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 69, + "type": "UInt32" + } + ], + [ + "SponsoringOwnerCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 70, + "type": "UInt32" + } + ], + [ + "SponsoringAccountCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 71, + "type": "UInt32" + } + ], + [ + "ReserveCount", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 72, + "type": "UInt32" + } + ], [ "IndexNext", { @@ -1140,6 +1210,16 @@ "type": "UInt64" } ], + [ + "SponseeNode", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 32, + "type": "UInt64" + } + ], [ "EmailHash", { @@ -1510,6 +1590,16 @@ "type": "Hash256" } ], + [ + "ObjectID", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 39, + "type": "Hash256" + } + ], [ "hash", { @@ -2310,6 +2400,56 @@ "type": "AccountID" } ], + [ + "Sponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 27, + "type": "AccountID" + } + ], + [ + "HighSponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 28, + "type": "AccountID" + } + ], + [ + "LowSponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 29, + "type": "AccountID" + } + ], + [ + "CounterpartySponsor", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 30, + "type": "AccountID" + } + ], + [ + "Sponsee", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": true, + "nth": 31, + "type": "AccountID" + } + ], [ "Number", { @@ -2840,6 +2980,16 @@ "type": "STObject" } ], + [ + "SponsorSignature", + { + "isSerialized": true, + "isSigningField": false, + "isVLEncoded": false, + "nth": 38, + "type": "STObject" + } + ], [ "Signers", { @@ -3459,6 +3609,7 @@ "PermissionedDomain": 130, "RippleState": 114, "SignerList": 83, + "Sponsorship": 144, "Ticket": 84, "Vault": 132, "XChainOwnedClaimID": 113, @@ -3720,6 +3871,8 @@ "SetFee": 101, "SetRegularKey": 5, "SignerListSet": 12, + "SponsorshipSet": 86, + "SponsorshipTransfer": 85, "TicketCreate": 10, "TrustSet": 20, "UNLModify": 102, diff --git a/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts b/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts index f3f90bf189..d2662c7c6d 100644 --- a/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts +++ b/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts @@ -48,10 +48,11 @@ const secp256k1: SigningScheme = { lowS: true, // Would fail tests if signatures aren't deterministic extraEntropy: undefined, - format: 'der', // We pass a pre-hashed message (Sha512Half), so disable secp256k1's // default SHA-256 prehashing (added as default in @noble/curves 2.0.0) prehash: false, + // Return DER-encoded signature bytes + format: 'der', }), ).toUpperCase() }, diff --git a/packages/xrpl/src/Wallet/index.ts b/packages/xrpl/src/Wallet/index.ts index 49a31684a4..ebd2d1d787 100644 --- a/packages/xrpl/src/Wallet/index.ts +++ b/packages/xrpl/src/Wallet/index.ts @@ -486,3 +486,9 @@ export { signLoanSetByCounterparty, combineLoanSetCounterpartySigners, } from './counterpartySigner' + +export { + signAsSponsor, + combineSponsorSigners, + addPreFundedSponsor, +} from './sponsorSigner' diff --git a/packages/xrpl/src/Wallet/sponsorSigner.ts b/packages/xrpl/src/Wallet/sponsorSigner.ts new file mode 100644 index 0000000000..03f51aa52d --- /dev/null +++ b/packages/xrpl/src/Wallet/sponsorSigner.ts @@ -0,0 +1,312 @@ +import stringify from 'fast-json-stable-stringify' +import { encode } from 'ripple-binary-codec' + +import { ValidationError } from '../errors' +import { Signer, Transaction, validate } from '../models' +import { areAddressesEqual, SponsorFlags } from '../models/transactions/common' +import { hashSignedTx } from '../utils/hashes' + +import { + compareSigners, + computeSignature, + getDecodedTransaction, +} from './utils' + +import type { Wallet } from '.' + +/** + * Signs a transaction as the sponsor. + * + * This function adds a sponsor signature to a transaction that has already been + * signed by the account. The sponsor uses their wallet to sign the transaction, + * which allows them to pay the transaction fees and/or reserves on behalf of the + * sponsee (the Account field). + * + * @param wallet - The sponsor's wallet used for signing the transaction. + * @param transaction - The transaction to sign as sponsor. Can be either: + * - A transaction object that has been signed by the account + * - A serialized transaction blob (string) in hex format + * @param opts - (Optional) Options for signing the transaction. + * @param opts.multisign - Specify true/false to use multisign or actual address (classic/x-address) to make multisign tx request. + * The actual address is only needed in the case of regular key usage. + * @returns An object containing: + * - `tx`: The signed transaction object with SponsorSignature + * - `tx_blob`: The serialized transaction blob (hex string) ready to submit to the ledger + * - `hash`: The transaction hash (useful for tracking the transaction) + * + * @throws {ValidationError} If: + * - The transaction is already signed by the sponsor + * - The transaction has not been signed by the account yet + * - The transaction fails validation + */ +// eslint-disable-next-line max-lines-per-function, complexity -- for extensive validations +export function signAsSponsor( + wallet: Wallet, + transaction: Transaction | string, + opts: { multisign?: boolean | string } = {}, +): { + tx: Transaction + tx_blob: string + hash: string +} { + const tx = getDecodedTransaction(transaction) + + if (tx.SponsorSignature) { + throw new ValidationError('Transaction is already signed by the sponsor.') + } + if (tx.TxnSignature == null || tx.SigningPubKey == null) { + throw new ValidationError( + 'Transaction must be first signed by the account.', + ) + } + + // Validate that SponsorFlags is present on the transaction + if (tx.SponsorFlags === undefined) { + throw new ValidationError( + 'Transaction must have SponsorFlags field set before sponsor can sign.', + ) + } + + // Validate that the Sponsor field is present + if (tx.Sponsor === undefined) { + throw new ValidationError( + 'Transaction must have Sponsor field set before sponsor can sign.', + ) + } + + let multisignAddress: boolean | string = false + if (typeof opts.multisign === 'string') { + multisignAddress = opts.multisign + } else if (opts.multisign) { + multisignAddress = wallet.classicAddress + } + + // For single-signing, validate that the Sponsor field matches the wallet + if (!multisignAddress && tx.Sponsor !== wallet.classicAddress) { + throw new ValidationError( + `Transaction Sponsor field (${tx.Sponsor}) does not match the signing wallet address (${wallet.classicAddress}).`, + ) + } + + // Prevent self-sponsorship - the sponsor cannot be the same as the account. + // Use tx.Sponsor (not wallet.classicAddress) and areAddressesEqual to handle + // X-address vs classic address equivalence. + if (areAddressesEqual(tx.Account, tx.Sponsor)) { + throw new ValidationError( + 'signAsSponsor: Sponsor cannot be the same as the transaction Account (self-sponsorship not allowed).', + ) + } + + if (multisignAddress) { + tx.SponsorSignature = { + Signers: [ + { + Signer: { + Account: multisignAddress, + SigningPubKey: wallet.publicKey, + TxnSignature: computeSignature( + tx, + wallet.privateKey, + multisignAddress, + ), + }, + }, + ], + } + } else { + tx.SponsorSignature = { + SigningPubKey: wallet.publicKey, + TxnSignature: computeSignature(tx, wallet.privateKey), + } + } + + // Validate the final signed transaction (after SponsorSignature is attached) + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type + validate(tx as unknown as Record) + + const serialized = encode(tx) + return { + tx, + tx_blob: serialized, + hash: hashSignedTx(serialized), + } +} + +/** + * Combines multiple transactions signed by the sponsor into a single transaction. + * + * @param transactions - An array of signed transactions (in object or blob form) to combine. + * @returns An object containing: + * - `tx`: The combined transaction object + * - `tx_blob`: The serialized transaction blob (hex string) ready to submit to the ledger + * @throws {ValidationError} If: + * - There are no transactions to combine + * - Any of the transactions do not have Signers in SponsorSignature + * - Any of the transactions do not have an account signature + */ +export function combineSponsorSigners( + transactions: Array, +): { + tx: Transaction + tx_blob: string +} { + if (transactions.length === 0) { + throw new ValidationError('There are 0 transactions to combine.') + } + + const decodedTransactions: Transaction[] = transactions.map( + (txOrBlob: string | Transaction) => { + return getDecodedTransaction(txOrBlob) + }, + ) + + decodedTransactions.forEach((tx) => { + /* + * This will throw a more clear error for JS users if any of the supplied transactions has incorrect formatting + */ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type + validate(tx as unknown as Record) + + if ( + tx.SponsorSignature?.Signers == null || + tx.SponsorSignature.Signers.length === 0 + ) { + throw new ValidationError('SponsorSignature must have Signers.') + } + + if (tx.TxnSignature == null || tx.SigningPubKey == null) { + throw new ValidationError( + 'Transaction must be first signed by the account.', + ) + } + }) + + validateSponsorTransactionEquivalence(decodedTransactions) + + const tx = getTransactionWithAllSponsorSigners(decodedTransactions) + + return { + tx, + tx_blob: encode(tx), + } +} + +function validateSponsorTransactionEquivalence( + transactions: Transaction[], +): void { + const exampleTransaction = stringify({ + ...transactions[0], + SponsorSignature: { + ...transactions[0].SponsorSignature, + Signers: null, + }, + }) + + if ( + transactions.slice(1).some( + (tx) => + stringify({ + ...tx, + SponsorSignature: { + ...tx.SponsorSignature, + Signers: null, + }, + }) !== exampleTransaction, + ) + ) { + throw new ValidationError('Sponsor transactions are not the same.') + } +} + +function getTransactionWithAllSponsorSigners( + transactions: Transaction[], +): Transaction { + // Signers must be sorted in the combined transaction - See compareSigners' documentation for more details + const sortedSigners: Signer[] = transactions + .flatMap((tx) => tx.SponsorSignature?.Signers ?? []) + .sort((signer1, signer2) => compareSigners(signer1.Signer, signer2.Signer)) + + // Deduplicate signers by Account (keeping the first occurrence after sorting). + // Duplicate Signer.Account entries are not allowed by rippled and will be rejected. + const seenAccounts = new Set() + const uniqueSigners: Signer[] = [] + for (const signer of sortedSigners) { + if (!seenAccounts.has(signer.Signer.Account)) { + seenAccounts.add(signer.Signer.Account) + uniqueSigners.push(signer) + } + } + + return { + ...transactions[0], + SponsorSignature: { Signers: uniqueSigners }, + } +} + +/** + * Adds sponsor fields to a transaction for use with pre-funded sponsorships. + * + * This function is used when a Sponsorship ledger object already exists on-ledger + * with sufficient balance to cover the transaction. In this case, no sponsor + * signature is required - only the Sponsor and SponsorFlags fields are needed. + * + * @param transaction - The transaction to add sponsor fields to. + * @param sponsorAddress - The address of the sponsor account (must match an + * existing Sponsorship object on the ledger). + * @param sponsorFlags - Flags indicating what the sponsor is paying for + * (tfSponsorFee = 0x00000001, tfSponsorReserve = 0x00000002). + * @returns A new transaction object with Sponsor and SponsorFlags fields added. + * + * @throws {ValidationError} If: + * - Sponsor and Account are the same (self-sponsorship not allowed) + * - SponsorFlags is missing or invalid + * + * @example + * ```typescript + * import { SponsorFlags } from 'xrpl' + * + * const sponsoredTx = addPreFundedSponsor( + * payment, + * 'rSponsorAddress123...', + * SponsorFlags.tfSponsorFee + * ) + * ``` + */ +export function addPreFundedSponsor( + transaction: Transaction, + sponsorAddress: string, + sponsorFlags: number, +): Transaction { + if (transaction.Account === sponsorAddress) { + throw new ValidationError( + 'addPreFundedSponsor: Sponsor and Account cannot be the same (self-sponsorship not allowed)', + ) + } + + if (!Number.isInteger(sponsorFlags)) { + throw new ValidationError( + 'addPreFundedSponsor: SponsorFlags must be a valid integer', + ) + } + + /* eslint-disable no-bitwise -- bitwise operations required for flag validation */ + const validFlags = SponsorFlags.tfSponsorFee | SponsorFlags.tfSponsorReserve + if ((sponsorFlags & ~validFlags) !== 0) { + throw new ValidationError( + 'addPreFundedSponsor: SponsorFlags contains invalid flags', + ) + } + + if (sponsorFlags === 0) { + throw new ValidationError( + 'addPreFundedSponsor: SponsorFlags must have at least one flag set', + ) + } + /* eslint-enable no-bitwise */ + + return { + ...transaction, + Sponsor: sponsorAddress, + SponsorFlags: sponsorFlags, + } +} diff --git a/packages/xrpl/src/Wallet/utils.ts b/packages/xrpl/src/Wallet/utils.ts index 42ac308d65..44d5f0fcf1 100644 --- a/packages/xrpl/src/Wallet/utils.ts +++ b/packages/xrpl/src/Wallet/utils.ts @@ -33,15 +33,12 @@ export function compareSigners( if (!left.Account || !right.Account) { throw new Error('compareSigners: Account cannot be null or undefined') } - const result = addressToBigNumber(left.Account).comparedTo( - addressToBigNumber(right.Account), + // comparedTo returns null when comparing with NaN; treat as equal (0) + return ( + addressToBigNumber(left.Account).comparedTo( + addressToBigNumber(right.Account), + ) ?? 0 ) - if (result === null) { - throw new Error( - 'compareSigners: Invalid account address comparison resulted in NaN', - ) - } - return result } export const NUM_BITS_IN_HEX = 16 diff --git a/packages/xrpl/src/index.ts b/packages/xrpl/src/index.ts index 83cb825a7a..fcc247ed8e 100644 --- a/packages/xrpl/src/index.ts +++ b/packages/xrpl/src/index.ts @@ -4,6 +4,8 @@ export * from './models' export * from './utils' +export * from './sugar' + export { default as ECDSA } from './ECDSA' export * from './errors' diff --git a/packages/xrpl/src/models/common/index.ts b/packages/xrpl/src/models/common/index.ts index 4297f47252..036e117a92 100644 --- a/packages/xrpl/src/models/common/index.ts +++ b/packages/xrpl/src/models/common/index.ts @@ -48,6 +48,27 @@ export interface Signer { } } +/** + * SponsorSignature object containing sponsor's signing information. + * Used in transactions to provide sponsor authorization for fee and/or reserve sponsorship. + * + * Must contain either single-sign fields (SigningPubKey + TxnSignature) OR multi-sign field (Signers). + */ +export type SponsorSignature = + | { + /** The sponsor's public key (for single-signing) */ + SigningPubKey: string + /** The sponsor's signature (for single-signing) */ + TxnSignature: string + Signers?: never + } + | { + SigningPubKey?: never + TxnSignature?: never + /** Array of sponsor signatures (for multi-signing) */ + Signers: Signer[] + } + export interface Memo { Memo: { MemoData?: string diff --git a/packages/xrpl/src/models/ledger/AccountRoot.ts b/packages/xrpl/src/models/ledger/AccountRoot.ts index fdcc5e0f86..9861a467cd 100644 --- a/packages/xrpl/src/models/ledger/AccountRoot.ts +++ b/packages/xrpl/src/models/ledger/AccountRoot.ts @@ -21,6 +21,21 @@ export default interface AccountRoot extends BaseLedgerEntry, HasPreviousTxnID { OwnerCount: number /** The sequence number of the next valid transaction for this account. */ Sequence: number + /** + * (Optional) The number of ledger objects owned by this account that are + * sponsored by other accounts. Used in reserve calculations for XLS-68. + */ + SponsoredOwnerCount?: number + /** + * (Optional) The number of ledger objects this account is sponsoring for + * other accounts. Used in reserve calculations for XLS-68. + */ + SponsoringOwnerCount?: number + /** + * (Optional) The number of accounts for which this account is providing + * account-level sponsorship. Used in reserve calculations for XLS-68. + */ + SponsoringAccountCount?: number /** * The identifying hash of the transaction most recently sent by this * account. This field must be enabled to use the AccountTxnID transaction @@ -78,6 +93,12 @@ export default interface AccountRoot extends BaseLedgerEntry, HasPreviousTxnID { MintedNFTokens?: number /** Another account that can mint NFTokens on behalf of this account. */ NFTokenMinter?: string + /** + * The account that is sponsoring the reserves for this account. + * If present, indicates that another account is paying the base reserve + * for this account's existence in the ledger. + */ + Sponsor?: string } /** diff --git a/packages/xrpl/src/models/ledger/Check.ts b/packages/xrpl/src/models/ledger/Check.ts index 653b6b8822..0d6f55af9d 100644 --- a/packages/xrpl/src/models/ledger/Check.ts +++ b/packages/xrpl/src/models/ledger/Check.ts @@ -67,4 +67,10 @@ export default interface Check extends BaseLedgerEntry, HasPreviousTxnID { * hosted recipient at the sender's address. */ SourceTag?: number + /** + * The account sponsoring the reserve for this Check. If present, the sponsor + * is responsible for the reserve requirement of this object instead of the + * owner. + */ + Sponsor?: string } diff --git a/packages/xrpl/src/models/ledger/DepositPreauth.ts b/packages/xrpl/src/models/ledger/DepositPreauth.ts index f93db62076..71f041340c 100644 --- a/packages/xrpl/src/models/ledger/DepositPreauth.ts +++ b/packages/xrpl/src/models/ledger/DepositPreauth.ts @@ -27,4 +27,10 @@ export default interface DepositPreauth Authorize?: string /** The credential(s) that received the preauthorization. */ AuthorizeCredentials?: AuthorizeCredential[] + /** + * The account sponsoring the reserve for this DepositPreauth. If present, + * the sponsor is responsible for the reserve requirement of this object + * instead of the owner. + */ + Sponsor?: string } diff --git a/packages/xrpl/src/models/ledger/Escrow.ts b/packages/xrpl/src/models/ledger/Escrow.ts index f51ccea9f9..64d4e0eaed 100644 --- a/packages/xrpl/src/models/ledger/Escrow.ts +++ b/packages/xrpl/src/models/ledger/Escrow.ts @@ -74,4 +74,11 @@ export default interface Escrow extends BaseLedgerEntry, HasPreviousTxnID { * Used when the issuer is neither the source nor destination account. */ IssuerNode?: number + + /** + * The account sponsoring the reserve for this Escrow. If present, the + * sponsor is responsible for the reserve requirement of this object instead + * of the owner. + */ + Sponsor?: string } diff --git a/packages/xrpl/src/models/ledger/LedgerEntry.ts b/packages/xrpl/src/models/ledger/LedgerEntry.ts index abaa50ad07..5f72babf3f 100644 --- a/packages/xrpl/src/models/ledger/LedgerEntry.ts +++ b/packages/xrpl/src/models/ledger/LedgerEntry.ts @@ -19,6 +19,7 @@ import PayChannel from './PayChannel' import PermissionedDomain from './PermissionedDomain' import RippleState from './RippleState' import SignerList from './SignerList' +import Sponsorship from './Sponsorship' import Ticket from './Ticket' import Vault from './Vault' import XChainOwnedClaimID from './XChainOwnedClaimID' @@ -46,6 +47,7 @@ type LedgerEntry = | PermissionedDomain | RippleState | SignerList + | Sponsorship | Ticket | Vault | XChainOwnedClaimID @@ -76,6 +78,7 @@ type LedgerEntryFilter = | 'payment_channel' | 'permissioned_domain' | 'signer_list' + | 'sponsorship' | 'state' | 'ticket' | 'vault' diff --git a/packages/xrpl/src/models/ledger/NFTokenOffer.ts b/packages/xrpl/src/models/ledger/NFTokenOffer.ts index 9dcca5d832..973e3b5395 100644 --- a/packages/xrpl/src/models/ledger/NFTokenOffer.ts +++ b/packages/xrpl/src/models/ledger/NFTokenOffer.ts @@ -11,4 +11,10 @@ export interface NFTokenOffer extends BaseLedgerEntry, HasPreviousTxnID { NFTokenOfferNode?: string Owner: string OwnerNode?: string + /** + * The account sponsoring the reserve for this NFTokenOffer. If present, the + * sponsor is responsible for the reserve requirement of this object instead + * of the owner. + */ + Sponsor?: string } diff --git a/packages/xrpl/src/models/ledger/Offer.ts b/packages/xrpl/src/models/ledger/Offer.ts index 700d2f2572..37f6f4b0f8 100644 --- a/packages/xrpl/src/models/ledger/Offer.ts +++ b/packages/xrpl/src/models/ledger/Offer.ts @@ -54,6 +54,12 @@ export default interface Offer extends BaseLedgerEntry, HasPreviousTxnID { * Currently this field only applicable to hybrid offers. */ AdditionalBooks?: Book[] + /** + * The account sponsoring the reserve for this Offer. If present, the sponsor + * is responsible for the reserve requirement of this object instead of the + * owner. + */ + Sponsor?: string } export enum OfferFlags { diff --git a/packages/xrpl/src/models/ledger/PayChannel.ts b/packages/xrpl/src/models/ledger/PayChannel.ts index db2b4fe1fc..2ab9152b30 100644 --- a/packages/xrpl/src/models/ledger/PayChannel.ts +++ b/packages/xrpl/src/models/ledger/PayChannel.ts @@ -94,4 +94,10 @@ export default interface PayChannel extends BaseLedgerEntry, HasPreviousTxnID { * this object, in case the directory consists of multiple pages. */ DestinationNode?: string + /** + * The account sponsoring the reserve for this PayChannel. If present, the + * sponsor is responsible for the reserve requirement of this object instead + * of the owner. + */ + Sponsor?: string } diff --git a/packages/xrpl/src/models/ledger/RippleState.ts b/packages/xrpl/src/models/ledger/RippleState.ts index 0250e63c13..cf14adf444 100644 --- a/packages/xrpl/src/models/ledger/RippleState.ts +++ b/packages/xrpl/src/models/ledger/RippleState.ts @@ -61,6 +61,18 @@ export default interface RippleState extends BaseLedgerEntry, HasPreviousTxnID { * equivalent to 1 billion, or face value. */ HighQualityOut?: number + /** + * The account sponsoring the reserve for the low account's side of this + * trust line. If present, indicates that another account is paying the + * reserve for the low account's participation in this trust line. + */ + LowSponsor?: string + /** + * The account sponsoring the reserve for the high account's side of this + * trust line. If present, indicates that another account is paying the + * reserve for the high account's participation in this trust line. + */ + HighSponsor?: string } export enum RippleStateFlags { diff --git a/packages/xrpl/src/models/ledger/Sponsorship.ts b/packages/xrpl/src/models/ledger/Sponsorship.ts new file mode 100644 index 0000000000..47209a2fa5 --- /dev/null +++ b/packages/xrpl/src/models/ledger/Sponsorship.ts @@ -0,0 +1,74 @@ +import { BaseLedgerEntry, HasPreviousTxnID } from './BaseLedgerEntry' + +/** + * Enum for Sponsorship ledger entry flags. + * + * @category Ledger Entry Flags + */ +export enum SponsorshipFlags { + /** + * If set, requires the sponsee to sign (approve) any transaction + * where the sponsor pays the transaction fee. + */ + lsfSponsorshipRequireSignForFee = 0x00010000, + /** + * If set, requires the sponsee to sign (approve) any transaction + * where the sponsor pays for reserves (e.g., creating new ledger objects). + */ + lsfSponsorshipRequireSignForReserve = 0x00020000, +} + +/** + * The Sponsorship object type represents a sponsorship relationship between + * two accounts, where the sponsor (Owner) pays fees and/or reserves on behalf + * of the sponsee. + * + * @category Ledger Entries + */ +export default interface Sponsorship extends BaseLedgerEntry, HasPreviousTxnID { + LedgerEntryType: 'Sponsorship' + /** + * The account that is sponsoring (paying fees/reserves). + * This is the owner of the Sponsorship object. + */ + Owner: string + /** + * The account being sponsored (receiving fee/reserve coverage). + */ + Sponsee: string + /** + * A bit-map of boolean flags. Possible flags include: + * - lsfSponsorshipRequireSignForFee (0x00010000): Requires sponsee signature for fee sponsorship + * - lsfSponsorshipRequireSignForReserve (0x00020000): Requires sponsee signature for reserve sponsorship + */ + Flags: number + /** + * A hint indicating which page of the sponsor's owner directory links to + * this object, in case the directory consists of multiple pages. + */ + OwnerNode: string + /** + * A hint indicating which page of the sponsee's owner directory links to + * this object, in case the directory consists of multiple pages. + */ + SponseeNode: string + /** + * The amount of XRP (in drops) available for paying transaction fees on + * behalf of the sponsee. This is a pre-funded balance that gets decremented + * when the sponsor pays fees for the sponsee's transactions. + */ + FeeAmount?: string + /** + * The maximum fee (in drops) that the sponsor is willing to pay per + * transaction on behalf of the sponsee. If not specified, there is no + * per-transaction limit. + */ + MaxFee?: string + /** + * The number of ledger objects for which the sponsor is paying reserves + * on behalf of the sponsee. This count is incremented when the sponsor + * pays for object creation and decremented when sponsored objects are + * deleted. Default value is 0. + */ + ReserveCount?: number +} diff --git a/packages/xrpl/src/models/ledger/Ticket.ts b/packages/xrpl/src/models/ledger/Ticket.ts index e45d860718..1a93d820ca 100644 --- a/packages/xrpl/src/models/ledger/Ticket.ts +++ b/packages/xrpl/src/models/ledger/Ticket.ts @@ -23,4 +23,10 @@ export default interface Ticket extends BaseLedgerEntry, HasPreviousTxnID { OwnerNode: string /** The Sequence Number this Ticket sets aside. */ TicketSequence: number + /** + * The account sponsoring the reserve for this Ticket. If present, the + * sponsor is responsible for the reserve requirement of this object instead + * of the owner. + */ + Sponsor?: string } diff --git a/packages/xrpl/src/models/ledger/index.ts b/packages/xrpl/src/models/ledger/index.ts index 3988698576..d3d11ab3af 100644 --- a/packages/xrpl/src/models/ledger/index.ts +++ b/packages/xrpl/src/models/ledger/index.ts @@ -32,6 +32,7 @@ import Oracle from './Oracle' import PayChannel from './PayChannel' import RippleState, { RippleStateFlags } from './RippleState' import SignerList, { SignerListFlags } from './SignerList' +import Sponsorship, { SponsorshipFlags } from './Sponsorship' import Ticket from './Ticket' import Vault, { VaultFlags } from './Vault' import XChainOwnedClaimID from './XChainOwnedClaimID' @@ -80,6 +81,8 @@ export { RippleStateFlags, SignerList, SignerListFlags, + Sponsorship, + SponsorshipFlags, Ticket, Vault, VaultFlags, diff --git a/packages/xrpl/src/models/methods/accountObjects.ts b/packages/xrpl/src/models/methods/accountObjects.ts index a339793db5..5cf3d36ed3 100644 --- a/packages/xrpl/src/models/methods/accountObjects.ts +++ b/packages/xrpl/src/models/methods/accountObjects.ts @@ -29,6 +29,12 @@ export interface AccountObjectsRequest * from being deleted. The default is false. */ deletion_blockers_only?: boolean + /** + * (Optional) Filter results based on sponsorship status. If true, returns only + * sponsored objects. If false, returns only non-sponsored objects. If omitted, + * returns all objects regardless of sponsorship status. + */ + sponsored?: boolean /** * The maximum number of objects to include in the results. Must be within * the inclusive range 10 to 400 on non-admin connections. The default is 200. diff --git a/packages/xrpl/src/models/methods/accountSponsoring.ts b/packages/xrpl/src/models/methods/accountSponsoring.ts new file mode 100644 index 0000000000..62aed9b88f --- /dev/null +++ b/packages/xrpl/src/models/methods/accountSponsoring.ts @@ -0,0 +1,107 @@ +import { AccountObjectType } from './accountObjects' +import { BaseRequest, BaseResponse, LookupByLedgerRequest } from './baseMethod' + +/** + * Represents an account that is being sponsored by the requested account. + * + * @category Responses + */ +export interface SponsoredAccount { + /** The address of the sponsored account. */ + account: string + /** + * The number of ledger objects owned by this account that are sponsored + * by the requested account. + */ + sponsored_owner_count?: number + /** + * Indicates whether the account's base reserve is being sponsored by the + * requested account. + */ + account_reserve_sponsored?: boolean +} + +/** + * The account_sponsoring command returns information about accounts and ledger + * objects that are sponsored by the specified account. This is a Clio-only + * method that provides details about sponsorship relationships for XLS-68. + * Expects a response in the form of an {@link AccountSponsoringResponse}. + * + * @category Requests + */ +export interface AccountSponsoringRequest + extends BaseRequest, LookupByLedgerRequest { + command: 'account_sponsoring' + /** + * A unique identifier for the account, most commonly the account's address. + * This is the sponsor account whose sponsorships will be returned. + */ + account: string + /** + * If true, the response only includes sponsored objects that would block the + * sponsored account from being deleted. The default is false. + */ + deletion_blockers_only?: boolean + /** + * If included, filter results to include only sponsored objects of this + * ledger entry type. + */ + type?: AccountObjectType + /** + * The maximum number of sponsored accounts to include in the results. Must be + * within the inclusive range 10 to 400 on non-admin connections. The default + * is 200. + */ + limit?: number + /** + * Value from a previous paginated response. Resume retrieving data where + * that response left off. + */ + marker?: unknown +} + +/** + * Response expected from an {@link AccountSponsoringRequest}. + * + * @category Responses + */ +export interface AccountSponsoringResponse extends BaseResponse { + result: { + /** The address of the sponsor account from the request. */ + account: string + /** + * Array of accounts and objects sponsored by this account. Each entry + * contains information about the sponsorship relationship. + */ + sponsored_accounts: SponsoredAccount[] + /** + * The identifying hash of the ledger that was used to generate this + * response. + */ + ledger_hash?: string + /** + * The ledger index of the ledger version that was used to generate this + * response. + */ + ledger_index?: number + /** + * The ledger index of the current in-progress ledger version, which was + * used to generate this response. + */ + ledger_current_index?: number + /** The limit that was used in this request, if any. */ + limit?: number + /** + * Server-defined value indicating the response is paginated. Pass this to + * the next call to resume where this call left off. Omitted when there are + * no additional pages after this one. + */ + marker?: unknown + /** + * If included and set to true, the information in this response comes from + * a validated ledger version. Otherwise, the information is subject to + * change. + */ + validated?: boolean + } +} diff --git a/packages/xrpl/src/models/methods/index.ts b/packages/xrpl/src/models/methods/index.ts index 5332c8c52a..258bc78d28 100644 --- a/packages/xrpl/src/models/methods/index.ts +++ b/packages/xrpl/src/models/methods/index.ts @@ -42,6 +42,11 @@ import { AccountOffersRequest, AccountOffersResponse, } from './accountOffers' +import { + AccountSponsoringRequest, + AccountSponsoringResponse, + SponsoredAccount, +} from './accountSponsoring' import { AccountTxRequest, AccountTxResponse, @@ -203,6 +208,7 @@ type Request = | AccountNFTsRequest | AccountObjectsRequest | AccountOffersRequest + | AccountSponsoringRequest | AccountTxRequest | GatewayBalancesRequest | NoRippleCheckRequest @@ -264,6 +270,7 @@ type Response = | AccountNFTsResponse | AccountObjectsResponse | AccountOffersResponse + | AccountSponsoringResponse | AccountTxVersionResponseMap | GatewayBalancesResponse | NoRippleCheckResponse @@ -330,6 +337,8 @@ export type RequestResponseMap< ? AccountObjectsResponse : T extends AccountOffersRequest ? AccountOffersResponse + : T extends AccountSponsoringRequest + ? AccountSponsoringResponse : T extends AccountTxRequest ? AccountTxVersionResponseMap : T extends AMMInfoRequest @@ -538,6 +547,9 @@ export { AccountOffer, AccountOffersRequest, AccountOffersResponse, + AccountSponsoringRequest, + AccountSponsoringResponse, + SponsoredAccount, AccountTxRequest, AccountTxResponse, AccountTxV1Response, diff --git a/packages/xrpl/src/models/methods/ledger.ts b/packages/xrpl/src/models/methods/ledger.ts index c0dab28a71..d2180130a8 100644 --- a/packages/xrpl/src/models/methods/ledger.ts +++ b/packages/xrpl/src/models/methods/ledger.ts @@ -144,7 +144,7 @@ export interface LedgerRequestExpandedAccountsOnly extends LedgerRequest { * * @category Requests */ -// eslint-disable-next-line max-len -- Disable for interface declaration. +// eslint-disable-next-line max-len -- interface name is descriptive export interface LedgerRequestExpandedAccountsAndTransactions extends LedgerRequest { expand: true accounts: true diff --git a/packages/xrpl/src/models/methods/ledgerEntry.ts b/packages/xrpl/src/models/methods/ledgerEntry.ts index cabdba90a5..974ffebe36 100644 --- a/packages/xrpl/src/models/methods/ledgerEntry.ts +++ b/packages/xrpl/src/models/methods/ledgerEntry.ts @@ -228,6 +228,20 @@ export interface LedgerEntryRequest extends BaseRequest, LookupByLedgerRequest { account: string authorize: string } + + /** + * Retrieve a Sponsorship object from the ledger. + * If a string, must be the object ID of the Sponsorship, as hexadecimal. + * If an object, requires sponsor and sponsee sub-fields. + */ + sponsorship?: + | { + /** The account that is the sponsor (Owner of the Sponsorship object). */ + sponsor: string + /** The account that is being sponsored (Sponsee). */ + sponsee: string + } + | string } /** diff --git a/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts b/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts index de31645350..b158851739 100644 --- a/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts +++ b/packages/xrpl/src/models/transactions/NFTokenCreateOffer.ts @@ -11,6 +11,8 @@ import { isAccount, validateOptionalField, Account, + areAddressesEqual, + isString, } from './common' import type { TransactionMetadataBase } from './metadata' @@ -123,13 +125,21 @@ function validateNFTokenBuyOfferCases(tx: Record): void { export function validateNFTokenCreateOffer(tx: Record): void { validateBaseTransaction(tx) - if (tx.Account === tx.Owner) { + if ( + isString(tx.Account) && + isString(tx.Owner) && + areAddressesEqual(tx.Account, tx.Owner) + ) { throw new ValidationError( 'NFTokenCreateOffer: Owner and Account must not be equal', ) } - if (tx.Account === tx.Destination) { + if ( + isString(tx.Account) && + isString(tx.Destination) && + areAddressesEqual(tx.Account, tx.Destination) + ) { throw new ValidationError( 'NFTokenCreateOffer: Destination and Account must not be equal', ) diff --git a/packages/xrpl/src/models/transactions/common.ts b/packages/xrpl/src/models/transactions/common.ts index 42dfe47cbf..db67d6795c 100644 --- a/packages/xrpl/src/models/transactions/common.ts +++ b/packages/xrpl/src/models/transactions/common.ts @@ -1,6 +1,10 @@ /* eslint-disable max-lines -- common utility file */ import { HEX_REGEX } from '@xrplf/isomorphic/utils' -import { isValidClassicAddress, isValidXAddress } from 'ripple-address-codec' +import { + isValidClassicAddress, + isValidXAddress, + xAddressToClassicAddress, +} from 'ripple-address-codec' import { TRANSACTION_TYPES } from 'ripple-binary-codec' import { ValidationError } from '../../errors' @@ -14,6 +18,7 @@ import { MPTAmount, Memo, Signer, + SponsorSignature, XChainBridge, } from '../common' import { isHex, onlyHasFields } from '../utils' @@ -75,6 +80,54 @@ function isSigner(obj: unknown): obj is Signer { ) } +/** + * Verify the form and type of a SponsorSignature at runtime. + * + * @param obj - The object to check the form and type of. + * @returns Whether the SponsorSignature is properly formed. + */ +function isSponsorSignature(obj: unknown): obj is SponsorSignature { + if (!isRecord(obj)) { + return false + } + + const hasSigningPubKey = obj.SigningPubKey !== undefined + const hasTxnSignature = obj.TxnSignature !== undefined + const hasSigners = obj.Signers !== undefined + + /* + * Must have either (SigningPubKey + TxnSignature) OR Signers, but not both + */ + const hasSingleSig = hasSigningPubKey && hasTxnSignature + const hasMultiSig = hasSigners + + if (hasSingleSig && hasMultiSig) { + /* Cannot have both single-sig and multi-sig */ + return false + } + + if (!hasSingleSig && !hasMultiSig) { + /* Must have at least one signing method */ + return false + } + + // Validate single-sig fields + if (hasSingleSig) { + if (!isString(obj.SigningPubKey) || !isString(obj.TxnSignature)) { + return false + } + } + + // Validate multi-sig fields + if (hasMultiSig) { + if (!isArray(obj.Signers) || !obj.Signers.every(isSigner)) { + return false + } + } + + return true +} + // Currency object sizes const XRP_CURRENCY_SIZE = 1 const MPT_CURRENCY_SIZE = 1 @@ -288,6 +341,39 @@ export function isAccount(account: unknown): account is Account { ) } +/** + * Normalizes an address to its classic format. + * If the address is an X-address, converts it to a classic address. + * If the address is already a classic address, returns it as-is. + * + * @param address - The address to normalize (classic or X-address format). + * @returns The classic address format. + */ +function toClassicAddress(address: string): string { + if (isValidXAddress(address)) { + return xAddressToClassicAddress(address).classicAddress + } + return address +} + +/** + * Compares two addresses for equality, normalizing both to classic address format. + * This handles the case where one address might be an X-address and the other + * a classic address, but they refer to the same account. + * + * @param address1 - The first address to compare. + * @param address2 - The second address to compare. + * @returns True if the addresses refer to the same account, false otherwise. + */ +export function areAddressesEqual(address1: string, address2: string): boolean { + try { + return toClassicAddress(address1) === toClassicAddress(address2) + } catch { + // If conversion fails for any reason, fall back to direct comparison + return address1 === address2 + } +} + /** * Verify the form and type of an Amount at runtime. * @@ -456,6 +542,17 @@ export interface GlobalFlagsInterface { tfInnerBatchTxn?: boolean } +/** + * Sponsor flags for transaction common fields. + * These flags indicate what type of sponsorship is being used in a transaction. + */ +export enum SponsorFlags { + /** Sponsor is paying the transaction fee */ + tfSponsorFee = 0x00000001, + /** Sponsor is paying reserves for objects created in the transaction */ + tfSponsorReserve = 0x00000002, +} + /** * Every transaction has the same set of common fields. */ @@ -534,6 +631,156 @@ export interface BaseTransaction extends Record { * The delegate account that is sending the transaction. */ Delegate?: Account + /** + * The account sponsoring this transaction (paying fees and/or reserves). + */ + Sponsor?: string + /** + * Flags indicating sponsorship type (fee and/or reserve). + * Must be included if Sponsor field is present. + */ + SponsorFlags?: number + /** + * Sponsor's signature information. + * Required for co-signed sponsorship (when no pre-funded Sponsorship object exists). + */ + SponsorSignature?: SponsorSignature +} + +/** + * Transaction types that can create ledger objects and thus support reserve sponsorship. + * These transactions can use the tfSponsorReserve flag. + */ +const RESERVE_SPONSORABLE_TRANSACTIONS = new Set([ + 'CheckCreate', + 'DepositPreauth', + 'EscrowCreate', + 'NFTokenCreateOffer', + 'OfferCreate', + 'PaymentChannelCreate', + 'SignerListSet', + 'TicketCreate', + 'TrustSet', + 'AMMCreate', + 'CredentialCreate', + 'DIDSet', + 'MPTokenIssuanceCreate', + 'OracleSet', + 'VaultCreate', + 'LoanBrokerSet', + 'PermissionedDomainSet', +]) + +/** + * Validate that SponsorFlags contains only valid flag values. + * + * @param sponsorFlags - The SponsorFlags value to validate. + * @param transactionType - The transaction type to validate flags against. + * @throws ValidationError if flags are invalid. + */ +function validateSponsorFlagsValue( + sponsorFlags: number, + transactionType?: string, +): void { + /* eslint-disable no-bitwise -- bitwise operations required for flag validation */ + const validFlags = SponsorFlags.tfSponsorFee | SponsorFlags.tfSponsorReserve + if ((sponsorFlags & ~validFlags) !== 0) { + throw new ValidationError( + 'Transaction: SponsorFlags contains invalid flags', + ) + } + + if (sponsorFlags === 0) { + throw new ValidationError( + 'Transaction: SponsorFlags must have at least one flag set', + ) + } + + // Validate that reserve sponsorship is only used for transactions that create objects + const hasReserveFlag = (sponsorFlags & SponsorFlags.tfSponsorReserve) !== 0 + if ( + hasReserveFlag && + transactionType && + !RESERVE_SPONSORABLE_TRANSACTIONS.has(transactionType) + ) { + throw new ValidationError( + `Transaction: ${transactionType} cannot use tfSponsorReserve flag (does not create ledger objects)`, + ) + } + /* eslint-enable no-bitwise */ +} + +/** + * Validate sponsor-related fields in a transaction. + * This is a helper function for validateBaseTransaction. + * + * @param tx - The transaction to validate sponsor fields for. + * @throws ValidationError if sponsor fields are invalid. + */ +// eslint-disable-next-line max-lines-per-function -- necessary for validation +export function validateSponsorFields(tx: Record): void { + // Skip sponsor field validation for SponsorshipTransfer which uses the Sponsor field + // for a different purpose (the new reserve-payer, not fee sponsorship). + // SponsorshipSet does NOT use the Sponsor field for its own purposes, so it should + // still be validated for fee sponsorship fields. + const transactionType = String(tx.TransactionType) + if (transactionType === 'SponsorshipTransfer') { + return + } + + const sponsor = tx.Sponsor + const sponsorFlags = tx.SponsorFlags + const sponsorSignature = tx.SponsorSignature + + const hasSponsor = sponsor !== undefined + const hasSponsorFlags = sponsorFlags !== undefined + const hasSponsorSignature = sponsorSignature !== undefined + + /* If any sponsor field is present, Sponsor and SponsorFlags must be present */ + if (hasSponsor || hasSponsorFlags || hasSponsorSignature) { + if (!hasSponsor || !hasSponsorFlags) { + throw new ValidationError( + 'Transaction: Sponsor and SponsorFlags must both be present for sponsored transactions', + ) + } + } + + /* Validate Sponsor field */ + if (hasSponsor) { + if (!isString(sponsor)) { + throw new ValidationError('Transaction: Sponsor must be a string') + } + if (!isAccount(sponsor)) { + throw new ValidationError( + 'Transaction: Sponsor must be a valid account address', + ) + } + } + + /* Validate SponsorFlags field */ + if (hasSponsorFlags) { + if (!isNumber(sponsorFlags)) { + throw new ValidationError('Transaction: SponsorFlags must be a number') + } + validateSponsorFlagsValue(sponsorFlags, transactionType) + } + + /* Validate SponsorSignature field */ + if (hasSponsorSignature && !isSponsorSignature(sponsorSignature)) { + throw new ValidationError('Transaction: invalid SponsorSignature') + } + + /* Validate no self-sponsorship */ + if ( + hasSponsor && + isString(sponsor) && + isString(tx.Account) && + areAddressesEqual(sponsor, tx.Account) + ) { + throw new ValidationError( + 'Transaction: Sponsor and Account cannot be the same (self-sponsorship not allowed)', + ) + } } /** @@ -605,11 +852,19 @@ export function validateBaseTransaction( validateOptionalField(common, 'Delegate', isAccount) const delegate = common.Delegate - if (delegate != null && delegate === common.Account) { + if ( + delegate != null && + isString(delegate) && + isString(common.Account) && + areAddressesEqual(delegate, common.Account) + ) { throw new ValidationError( 'BaseTransaction: Account and Delegate addresses cannot be the same', ) } + + // Validate sponsor fields using helper function + validateSponsorFields(common) } /** diff --git a/packages/xrpl/src/models/transactions/depositPreauth.ts b/packages/xrpl/src/models/transactions/depositPreauth.ts index 2c4a56a46e..06e3c75992 100644 --- a/packages/xrpl/src/models/transactions/depositPreauth.ts +++ b/packages/xrpl/src/models/transactions/depositPreauth.ts @@ -6,6 +6,8 @@ import { validateBaseTransaction, validateCredentialsList, MAX_AUTHORIZED_CREDENTIALS, + areAddressesEqual, + isString, } from './common' /** @@ -36,6 +38,22 @@ export interface DepositPreauth extends BaseTransaction { UnauthorizeCredentials?: AuthorizeCredential[] } +function validateAuthorizationField( + tx: Record, + field: 'Authorize' | 'Unauthorize', +): void { + const value = tx[field] + if (!isString(value)) { + throw new ValidationError(`DepositPreauth: ${field} must be a string`) + } + if (isString(tx.Account) && areAddressesEqual(tx.Account, value)) { + const action = field === 'Authorize' ? 'preauthorize' : 'unauthorize' + throw new ValidationError( + `DepositPreauth: Account can't ${action} its own address`, + ) + } +} + /** * Verify the form and type of a DepositPreauth at runtime. * @@ -44,29 +62,12 @@ export interface DepositPreauth extends BaseTransaction { */ export function validateDepositPreauth(tx: Record): void { validateBaseTransaction(tx) - validateSingleAuthorizationFieldProvided(tx) if (tx.Authorize !== undefined) { - if (typeof tx.Authorize !== 'string') { - throw new ValidationError('DepositPreauth: Authorize must be a string') - } - - if (tx.Account === tx.Authorize) { - throw new ValidationError( - "DepositPreauth: Account can't preauthorize its own address", - ) - } + validateAuthorizationField(tx, 'Authorize') } else if (tx.Unauthorize !== undefined) { - if (typeof tx.Unauthorize !== 'string') { - throw new ValidationError('DepositPreauth: Unauthorize must be a string') - } - - if (tx.Account === tx.Unauthorize) { - throw new ValidationError( - "DepositPreauth: Account can't unauthorize its own address", - ) - } + validateAuthorizationField(tx, 'Unauthorize') } else if (tx.AuthorizeCredentials !== undefined) { validateCredentialsList( tx.AuthorizeCredentials, diff --git a/packages/xrpl/src/models/transactions/index.ts b/packages/xrpl/src/models/transactions/index.ts index d7afb63012..7a30cd211e 100644 --- a/packages/xrpl/src/models/transactions/index.ts +++ b/packages/xrpl/src/models/transactions/index.ts @@ -3,6 +3,7 @@ export { GlobalFlags, GlobalFlagsInterface, isMPTAmount, + SponsorFlags, } from './common' export { validate, @@ -118,6 +119,16 @@ export { PermissionedDomainDelete } from './permissionedDomainDelete' export { SetFee, SetFeePreAmendment, SetFeePostAmendment } from './setFee' export { SetRegularKey } from './setRegularKey' export { SignerListSet } from './signerListSet' +export { + SponsorshipSet, + SponsorshipSetFlags, + SponsorshipSetFlagsInterface, +} from './sponsorshipSet' +export { + SponsorshipTransfer, + SponsorshipTransferFlags, + SponsorshipTransferFlagsInterface, +} from './sponsorshipTransfer' export { TicketCreate } from './ticketCreate' export { TrustSetFlagsInterface, TrustSetFlags, TrustSet } from './trustSet' export { UNLModify } from './UNLModify' diff --git a/packages/xrpl/src/models/transactions/payment.ts b/packages/xrpl/src/models/transactions/payment.ts index cf5ee4f884..e0c589579f 100644 --- a/packages/xrpl/src/models/transactions/payment.ts +++ b/packages/xrpl/src/models/transactions/payment.ts @@ -43,6 +43,11 @@ export enum PaymentFlags { * details. */ tfLimitQuality = 0x00040000, + /** + * Indicates that the payment is creating a new account and the account's + * reserve is being sponsored. Used in conjunction with XLS-68 sponsorship. + */ + tfSponsorCreatedAccount = 0x00080000, } /** @@ -105,6 +110,11 @@ export interface PaymentFlagsInterface extends GlobalFlagsInterface { * details. */ tfLimitQuality?: boolean + /** + * Indicates that the payment is creating a new account and the account's + * reserve is being sponsored. Used in conjunction with XLS-68 sponsorship. + */ + tfSponsorCreatedAccount?: boolean } /** diff --git a/packages/xrpl/src/models/transactions/sponsorshipSet.ts b/packages/xrpl/src/models/transactions/sponsorshipSet.ts new file mode 100644 index 0000000000..63cb28c314 --- /dev/null +++ b/packages/xrpl/src/models/transactions/sponsorshipSet.ts @@ -0,0 +1,234 @@ +import { ValidationError } from '../../errors' +import { INTEGER_SANITY_CHECK } from '../utils' + +import { + BaseTransaction, + GlobalFlagsInterface, + isAccount, + isString, + validateBaseTransaction, + areAddressesEqual, +} from './common' + +/** + * Flags for the SponsorshipSet transaction. + * + * @category Transaction Flags + */ +export enum SponsorshipSetFlags { + /** + * Set the lsfSponsorshipRequireSignForFee flag on the Sponsorship object. + * When set, requires the sponsee to sign any transaction where the sponsor pays the fee. + */ + tfSponsorshipSetRequireSignForFee = 0x00010000, + /** + * Clear the lsfSponsorshipRequireSignForFee flag on the Sponsorship object. + */ + tfSponsorshipClearRequireSignForFee = 0x00020000, + /** + * Set the lsfSponsorshipRequireSignForReserve flag on the Sponsorship object. + * When set, requires the sponsee to sign any transaction where the sponsor pays for reserves. + */ + tfSponsorshipSetRequireSignForReserve = 0x00040000, + /** + * Clear the lsfSponsorshipRequireSignForReserve flag on the Sponsorship object. + */ + tfSponsorshipClearRequireSignForReserve = 0x00080000, + /** + * Delete the Sponsorship object instead of creating or modifying it. + */ + tfDeleteObject = 0x00100000, +} + +/** + * Map of flags to boolean values representing the SponsorshipSet transaction + * flags. + * + * @category Transaction Flags + */ +export interface SponsorshipSetFlagsInterface extends GlobalFlagsInterface { + /** + * Set the lsfSponsorshipRequireSignForFee flag on the Sponsorship object. + */ + tfSponsorshipSetRequireSignForFee?: boolean + /** + * Clear the lsfSponsorshipRequireSignForFee flag on the Sponsorship object. + */ + tfSponsorshipClearRequireSignForFee?: boolean + /** + * Set the lsfSponsorshipRequireSignForReserve flag on the Sponsorship object. + */ + tfSponsorshipSetRequireSignForReserve?: boolean + /** + * Clear the lsfSponsorshipRequireSignForReserve flag on the Sponsorship object. + */ + tfSponsorshipClearRequireSignForReserve?: boolean + /** + * Delete the Sponsorship object instead of creating or modifying it. + */ + tfDeleteObject?: boolean +} + +/** + * A SponsorshipSet transaction creates, modifies, or deletes a Sponsorship + * object that defines a sponsorship relationship between two accounts. + * + * The sponsor (Account) or sponsee (via CounterpartySponsor) can submit this + * transaction to establish or modify the sponsorship relationship. + * + * @category Transaction Models + */ +export interface SponsorshipSet extends BaseTransaction { + TransactionType: 'SponsorshipSet' + /** + * The account to be sponsored. This is the account that will benefit from + * the sponsorship (fees and/or reserves paid by the sponsor). + * Required when Account is the sponsor; omitted when using CounterpartySponsor. + */ + Sponsee?: string + /** + * (Optional) The sponsor's address. Used when the sponsee (Account) is + * submitting the transaction to accept or request a sponsorship. When present, + * this field identifies the sponsor, and Account is the sponsee. + */ + CounterpartySponsor?: string + /** + * (Optional) The amount of XRP (in drops) to pre-fund for paying transaction + * fees on behalf of the sponsee. This creates a balance that gets decremented + * as the sponsor pays fees. + */ + FeeAmount?: string + /** + * The maximum fee (in drops) that the sponsor is willing to pay per + * transaction on behalf of the sponsee. If not specified, there is no + * per-transaction limit. + */ + MaxFee?: string + /** + * (Optional) The number of reserve units the sponsor agrees to cover. + * Used when establishing reserve-based sponsorship. + */ + ReserveCount?: number + Flags?: number | SponsorshipSetFlagsInterface +} + +/** + * Verify the form and type of a SponsorshipSet at runtime. + * + * @param tx - A SponsorshipSet Transaction. + * @throws Malformed. + */ +// eslint-disable-next-line max-lines-per-function, max-statements -- necessary for validation +export function validateSponsorshipSet(tx: Record): void { + validateBaseTransaction(tx) + + // Either Sponsee or CounterpartySponsor must be present, but not both + const hasSponsee = tx.Sponsee !== undefined + const hasCounterpartySponsor = tx.CounterpartySponsor !== undefined + + if (!hasSponsee && !hasCounterpartySponsor) { + throw new ValidationError( + 'SponsorshipSet: must have either Sponsee or CounterpartySponsor', + ) + } + + if (hasSponsee && hasCounterpartySponsor) { + throw new ValidationError( + 'SponsorshipSet: cannot have both Sponsee and CounterpartySponsor', + ) + } + + // Validate Sponsee if present + if (hasSponsee) { + if (!isString(tx.Sponsee)) { + throw new ValidationError('SponsorshipSet: Sponsee must be a string') + } + + // Check identity before validating address format + if (isString(tx.Account) && areAddressesEqual(tx.Account, tx.Sponsee)) { + throw new ValidationError( + 'SponsorshipSet: Account and Sponsee cannot be the same', + ) + } + + if (!isAccount(tx.Sponsee)) { + throw new ValidationError( + 'SponsorshipSet: Sponsee must be a valid account address', + ) + } + } + + // Validate CounterpartySponsor if present + if (hasCounterpartySponsor) { + if (!isString(tx.CounterpartySponsor)) { + throw new ValidationError( + 'SponsorshipSet: CounterpartySponsor must be a string', + ) + } + + // Check identity before validating address format + if ( + isString(tx.Account) && + areAddressesEqual(tx.Account, tx.CounterpartySponsor) + ) { + throw new ValidationError( + 'SponsorshipSet: Account and CounterpartySponsor cannot be the same', + ) + } + + if (!isAccount(tx.CounterpartySponsor)) { + throw new ValidationError( + 'SponsorshipSet: CounterpartySponsor must be a valid account address', + ) + } + } + + // Validate FeeAmount if present + if (tx.FeeAmount !== undefined) { + if (!isString(tx.FeeAmount)) { + throw new ValidationError('SponsorshipSet: FeeAmount must be a string') + } + + // Use strict regex to reject non-canonical strings (whitespace, scientific notation, decimals, etc.) + if (!INTEGER_SANITY_CHECK.exec(tx.FeeAmount)) { + throw new ValidationError( + 'SponsorshipSet: FeeAmount must be a non-negative numeric string', + ) + } + } + + // Validate MaxFee if present + if (tx.MaxFee !== undefined) { + if (!isString(tx.MaxFee)) { + throw new ValidationError('SponsorshipSet: MaxFee must be a string') + } + + // Use strict regex to reject non-canonical strings (whitespace, scientific notation, decimals, etc.) + if (!INTEGER_SANITY_CHECK.exec(tx.MaxFee)) { + throw new ValidationError( + 'SponsorshipSet: MaxFee must be a non-negative numeric string', + ) + } + } + + // Validate ReserveCount if present + if (tx.ReserveCount !== undefined) { + if (typeof tx.ReserveCount !== 'number') { + throw new ValidationError('SponsorshipSet: ReserveCount must be a number') + } + + if (tx.ReserveCount < 0 || !Number.isInteger(tx.ReserveCount)) { + throw new ValidationError( + 'SponsorshipSet: ReserveCount must be a non-negative integer', + ) + } + + // Prevent overflow - UInt32 max value + const MAX_UINT32 = 4294967295 + if (tx.ReserveCount > MAX_UINT32) { + throw new ValidationError( + `SponsorshipSet: ReserveCount cannot exceed ${MAX_UINT32}`, + ) + } + } +} diff --git a/packages/xrpl/src/models/transactions/sponsorshipTransfer.ts b/packages/xrpl/src/models/transactions/sponsorshipTransfer.ts new file mode 100644 index 0000000000..c0ab43f2eb --- /dev/null +++ b/packages/xrpl/src/models/transactions/sponsorshipTransfer.ts @@ -0,0 +1,190 @@ +import { ValidationError } from '../../errors' + +import { + BaseTransaction, + GlobalFlagsInterface, + isAccount, + isString, + validateBaseTransaction, + areAddressesEqual, +} from './common' + +/** + * Flags for the SponsorshipTransfer transaction. + * + * @category Transaction Flags + */ +export enum SponsorshipTransferFlags { + /** + * End an existing sponsorship relationship for the specified object. + */ + tfSponsorshipEnd = 0x00010000, + /** + * Create a new sponsorship relationship for the specified object. + */ + tfSponsorshipCreate = 0x00020000, + /** + * Reassign sponsorship from one sponsor to another for the specified object. + */ + tfSponsorshipReassign = 0x00040000, +} + +/** + * Map of flags to boolean values representing the SponsorshipTransfer transaction + * flags. + * + * @category Transaction Flags + */ +// eslint-disable-next-line max-len -- interface name is descriptive +export interface SponsorshipTransferFlagsInterface extends GlobalFlagsInterface { + /** + * End an existing sponsorship relationship for the specified object. + */ + tfSponsorshipEnd?: boolean + /** + * Create a new sponsorship relationship for the specified object. + */ + tfSponsorshipCreate?: boolean + /** + * Reassign sponsorship from one sponsor to another for the specified object. + */ + tfSponsorshipReassign?: boolean +} + +/** + * A SponsorshipTransfer transaction transfers ownership of a ledger object's + * reserve sponsorship from one sponsor to another, creates a new sponsorship, + * or removes sponsorship entirely. + * + * This transaction allows changing which account is paying the reserve for a + * specific ledger object (such as a trust line, offer, escrow, etc.) or for + * account-level sponsorship. + * + * @category Transaction Models + */ +export interface SponsorshipTransfer extends BaseTransaction { + TransactionType: 'SponsorshipTransfer' + /** + * (Optional) The ledger object ID of the object whose sponsorship is being + * transferred. This identifies the specific ledger entry whose reserve + * sponsorship will be changed. When omitted, this transaction refers to + * account-level sponsorship. + */ + ObjectID?: string + /** + * (Optional) The new sponsor account that will pay the reserve for the object. + * Required for tfSponsorshipCreate and tfSponsorshipReassign scenarios. + * Omitted for tfSponsorshipEnd scenario. + * + * Note: In the context of SponsorshipTransfer, this field indicates the new + * reserve-payer for the ledger object. This is distinct from the inherited + * BaseTransaction.Sponsor field, which when used with BaseTransaction.SponsorFlags + * indicates fee sponsorship for the transaction itself. + */ + Sponsor?: string + Flags?: number | SponsorshipTransferFlagsInterface +} + +/** + * Verify the form and type of a SponsorshipTransfer at runtime. + * + * @param tx - A SponsorshipTransfer Transaction. + * @throws Malformed. + */ +// eslint-disable-next-line max-lines-per-function, max-statements -- necessary for validation +export function validateSponsorshipTransfer(tx: Record): void { + validateBaseTransaction(tx) + + // Validate flag scenario - exactly one of the three scenario flags must be set + // Handle both numeric flags and boolean flag objects + let hasEnd = false + let hasCreate = false + let hasReassign = false + + if (typeof tx.Flags === 'number') { + /* eslint-disable no-bitwise -- bitwise operations required for flag validation */ + hasEnd = (tx.Flags & SponsorshipTransferFlags.tfSponsorshipEnd) !== 0 + hasCreate = (tx.Flags & SponsorshipTransferFlags.tfSponsorshipCreate) !== 0 + hasReassign = + (tx.Flags & SponsorshipTransferFlags.tfSponsorshipReassign) !== 0 + /* eslint-enable no-bitwise */ + } else if (typeof tx.Flags === 'object') { + // Handle boolean flags object + const flagsObj = tx.Flags + hasEnd = + 'tfSponsorshipEnd' in flagsObj && flagsObj.tfSponsorshipEnd === true + hasCreate = + 'tfSponsorshipCreate' in flagsObj && flagsObj.tfSponsorshipCreate === true + hasReassign = + 'tfSponsorshipReassign' in flagsObj && + flagsObj.tfSponsorshipReassign === true + } + + const scenarioCount = + (hasEnd ? 1 : 0) + (hasCreate ? 1 : 0) + (hasReassign ? 1 : 0) + + if (scenarioCount === 0) { + throw new ValidationError( + 'SponsorshipTransfer: must specify exactly one scenario flag (tfSponsorshipEnd, tfSponsorshipCreate, or tfSponsorshipReassign)', + ) + } + + if (scenarioCount > 1) { + throw new ValidationError( + 'SponsorshipTransfer: cannot specify multiple scenario flags (tfSponsorshipEnd, tfSponsorshipCreate, tfSponsorshipReassign are mutually exclusive)', + ) + } + + // Validate ObjectID if present (optional for account-level sponsorship) + if (tx.ObjectID !== undefined) { + if (!isString(tx.ObjectID)) { + throw new ValidationError( + 'SponsorshipTransfer: ObjectID must be a string', + ) + } + + // ObjectID should be a 64-character hex string (ledger object ID) + if (!/^[0-9A-Fa-f]{64}$/u.test(tx.ObjectID)) { + throw new ValidationError( + 'SponsorshipTransfer: ObjectID must be a 64-character hexadecimal string', + ) + } + } + + // Validate Sponsor based on scenario + const hasSponsor = tx.Sponsor !== undefined + + // tfSponsorshipEnd: Sponsor should NOT be present + if (hasEnd && hasSponsor) { + throw new ValidationError( + 'SponsorshipTransfer: Sponsor field must not be present for tfSponsorshipEnd scenario', + ) + } + + // tfSponsorshipCreate or tfSponsorshipReassign: Sponsor is REQUIRED + if ((hasCreate || hasReassign) && !hasSponsor) { + throw new ValidationError( + 'SponsorshipTransfer: Sponsor field is required for tfSponsorshipCreate and tfSponsorshipReassign scenarios', + ) + } + + // Validate Sponsor if present + if (tx.Sponsor !== undefined) { + if (!isString(tx.Sponsor)) { + throw new ValidationError('SponsorshipTransfer: Sponsor must be a string') + } + + // Check identity before validating address format + if (isString(tx.Account) && areAddressesEqual(tx.Account, tx.Sponsor)) { + throw new ValidationError( + 'SponsorshipTransfer: Account and Sponsor cannot be the same', + ) + } + + if (!isAccount(tx.Sponsor)) { + throw new ValidationError( + 'SponsorshipTransfer: Sponsor must be a valid account address', + ) + } + } +} diff --git a/packages/xrpl/src/models/transactions/transaction.ts b/packages/xrpl/src/models/transactions/transaction.ts index 97f710a2ea..7c3d37a795 100644 --- a/packages/xrpl/src/models/transactions/transaction.ts +++ b/packages/xrpl/src/models/transactions/transaction.ts @@ -109,6 +109,11 @@ import { import { SetFee } from './setFee' import { SetRegularKey, validateSetRegularKey } from './setRegularKey' import { SignerListSet, validateSignerListSet } from './signerListSet' +import { SponsorshipSet, validateSponsorshipSet } from './sponsorshipSet' +import { + SponsorshipTransfer, + validateSponsorshipTransfer, +} from './sponsorshipTransfer' import { TicketCreate, validateTicketCreate } from './ticketCreate' import { TrustSet, validateTrustSet } from './trustSet' import { UNLModify } from './UNLModify' @@ -206,6 +211,8 @@ export type SubmittableTransaction = | PermissionedDomainDelete | SetRegularKey | SignerListSet + | SponsorshipSet + | SponsorshipTransfer | TicketCreate | TrustSet | VaultClawback @@ -510,6 +517,14 @@ export function validate(transaction: Record): void { validateSignerListSet(tx) break + case 'SponsorshipSet': + validateSponsorshipSet(tx) + break + + case 'SponsorshipTransfer': + validateSponsorshipTransfer(tx) + break + case 'TicketCreate': validateTicketCreate(tx) break diff --git a/packages/xrpl/src/models/utils/flags.ts b/packages/xrpl/src/models/utils/flags.ts index 4ebdb40d5f..4348c51a8b 100644 --- a/packages/xrpl/src/models/utils/flags.ts +++ b/packages/xrpl/src/models/utils/flags.ts @@ -20,6 +20,8 @@ import { NFTokenMintFlags } from '../transactions/NFTokenMint' import { OfferCreateFlags } from '../transactions/offerCreate' import { PaymentFlags } from '../transactions/payment' import { PaymentChannelClaimFlags } from '../transactions/paymentChannelClaim' +import { SponsorshipSetFlags } from '../transactions/sponsorshipSet' +import { SponsorshipTransferFlags } from '../transactions/sponsorshipTransfer' import type { Transaction } from '../transactions/transaction' import { TrustSetFlags } from '../transactions/trustSet' import { VaultCreateFlags } from '../transactions/vaultCreate' @@ -67,6 +69,8 @@ const txToFlag = { OfferCreate: OfferCreateFlags, PaymentChannelClaim: PaymentChannelClaimFlags, Payment: PaymentFlags, + SponsorshipSet: SponsorshipSetFlags, + SponsorshipTransfer: SponsorshipTransferFlags, TrustSet: TrustSetFlags, VaultCreate: VaultCreateFlags, XChainModifyBridge: XChainModifyBridgeFlags, diff --git a/packages/xrpl/src/sugar/autofill.ts b/packages/xrpl/src/sugar/autofill.ts index bb7809ac98..476bba687d 100644 --- a/packages/xrpl/src/sugar/autofill.ts +++ b/packages/xrpl/src/sugar/autofill.ts @@ -138,6 +138,10 @@ export function setValidAddresses(tx: Transaction): void { convertToClassicAddress(tx, 'Owner') // SetRegularKey: convertToClassicAddress(tx, 'RegularKey') + // XLS-68 Sponsorship: + convertToClassicAddress(tx, 'Sponsor') + convertToClassicAddress(tx, 'Sponsee') + convertToClassicAddress(tx, 'CounterpartySponsor') } /** @@ -308,6 +312,38 @@ async function fetchCounterPartySignersCount( return signerList?.SignerEntries.length ?? 1 } +/** + * Calculates additional fees for sponsor signatures. + * + * Only adds sponsor signer fees when a SponsorSignature is present on the transaction. + * Pre-funded sponsorships (which only have Sponsor and SponsorFlags without SponsorSignature) + * do not require the sponsor to sign, so no additional signer fees are needed. + * + * @param tx - The transaction object. + * @param netFeeDrops - The network fee in drops. + * @returns The additional sponsor fee as a BigNumber. + */ +function calculateSponsorFee(tx: Transaction, netFeeDrops: string): BigNumber { + // Only add sponsor signer fees when SponsorSignature is present. + // Pre-funded sponsorships (tx.Sponsor without SponsorSignature) use an existing + // Sponsorship ledger object and don't require additional sponsor signatures, + // so they should not incur extra signer fees. + if (tx.SponsorSignature != null) { + const sponsorSignersCount = Math.max( + tx.SponsorSignature.Signers?.length ?? 0, + 1, + ) + // eslint-disable-next-line no-console -- necessary to inform users about autofill behavior + console.warn( + `For sponsored transaction the auto calculated Fee accounts for sponsor signers to avoid transaction failure.`, + ) + return new BigNumber(scaleValue(netFeeDrops, sponsorSignersCount)) + } + // Note: tx.Sponsor without SponsorSignature indicates a pre-funded sponsorship flow. + // No additional sponsor fees are charged since the sponsor is not signing. + return new BigNumber(0) +} + /** * Calculates the fee per transaction type. * @@ -381,7 +417,14 @@ async function calculateFeePerTransactionType( ) } + // Add sponsor signature fees if applicable + const sponsorFee = calculateSponsorFee(tx, netFeeDrops) + baseFee = BigNumber.sum(baseFee, sponsorFee) + const maxFeeDrops = xrpToDrops(client.maxFeeXRP) + // For special transactions (AccountDelete, AMMCreate, VaultCreate), the fee cap is bypassed. + // This means sponsor fees are also not subject to the cap for these transactions. + // For normal transactions, the total fee (base + sponsor) is capped at maxFeeXRP. const totalFee = isSpecialTxCost ? baseFee : BigNumber.min(baseFee, maxFeeDrops) @@ -457,7 +500,7 @@ export async function checkAccountDeleteBlockers( if (response.result.account_objects.length > 0) { reject( new XrplError( - `Account ${tx.Account} cannot be deleted; there are Escrows, PayChannels, RippleStates, or Checks associated with the account.`, + `Account ${tx.Account} cannot be deleted; there are Escrows, PayChannels, RippleStates, Checks, or Sponsorships associated with the account.`, response.result.account_objects, ), ) diff --git a/packages/xrpl/src/sugar/getOrderbook.ts b/packages/xrpl/src/sugar/getOrderbook.ts index c31041e8e3..e795ee6e81 100644 --- a/packages/xrpl/src/sugar/getOrderbook.ts +++ b/packages/xrpl/src/sugar/getOrderbook.ts @@ -17,6 +17,7 @@ function sortOffers(offers: BookOffer[]): BookOffer[] { const qualityA = offerA.quality ?? 0 const qualityB = offerB.quality ?? 0 + // comparedTo returns null when comparing with NaN; treat as equal (0) return new BigNumber(qualityA).comparedTo(qualityB) ?? 0 }) } diff --git a/packages/xrpl/src/sugar/index.ts b/packages/xrpl/src/sugar/index.ts index 0047432011..b332d36c29 100644 --- a/packages/xrpl/src/sugar/index.ts +++ b/packages/xrpl/src/sugar/index.ts @@ -1,3 +1,5 @@ export * from './submit' export * from './utils' + +export * from './validateSponsorship' diff --git a/packages/xrpl/src/sugar/validateSponsorship.ts b/packages/xrpl/src/sugar/validateSponsorship.ts new file mode 100644 index 0000000000..00ce1e22a8 --- /dev/null +++ b/packages/xrpl/src/sugar/validateSponsorship.ts @@ -0,0 +1,192 @@ +import BigNumber from 'bignumber.js' + +import type { Client } from '../client' +import { XrplError } from '../errors' +import type Sponsorship from '../models/ledger/Sponsorship' +import type { LedgerEntryRequest } from '../models/methods/ledgerEntry' +import type { Transaction } from '../models/transactions' +import { SponsorFlags } from '../models/transactions/common' + +/** + * Validation result for a pre-funded sponsorship. + */ +export interface SponsorshipValidationResult { + /** Whether the sponsorship is valid */ + valid: boolean + /** Error message if validation failed */ + error?: string + /** The Sponsorship ledger entry if found */ + sponsorship?: Sponsorship + /** The estimated transaction fee in drops */ + estimatedFee?: string +} + +/** + * Validates that a pre-funded Sponsorship ledger entry exists and has sufficient + * balance to cover the sponsored transaction. + * + * This helper should be called before submitting a transaction that uses pre-funded + * sponsorship (i.e., has Sponsor and SponsorFlags but no SponsorSignature). + * + * @param client - The XRPL client to use for querying the ledger. + * @param tx - The transaction to validate sponsorship for. + * @param estimatedFee - Optional estimated fee in drops. If not provided, uses tx.Fee. + * @returns A promise that resolves to the validation result. + * + * @example + * ```typescript + * const result = await validatePreFundedSponsorship(client, payment, '100') + * if (!result.valid) { + * console.error(`Sponsorship validation failed: ${result.error}`) + * } + * ``` + */ +// eslint-disable-next-line max-lines-per-function, complexity, max-statements -- necessary for validation +export async function validatePreFundedSponsorship( + client: Client, + tx: Transaction, + estimatedFee?: string, +): Promise { + // Only validate if transaction has Sponsor and SponsorFlags but no SponsorSignature + if (!tx.Sponsor || !tx.SponsorFlags) { + return { + valid: false, + error: 'Transaction does not have Sponsor and SponsorFlags fields', + } + } + + if (tx.SponsorSignature) { + return { + valid: false, + error: + 'Transaction has SponsorSignature - this validation is only for pre-funded sponsorships', + } + } + + const fee = estimatedFee ?? tx.Fee ?? '0' + + try { + // Query for the Sponsorship ledger entry + const sponsorship = await getSponsorshipEntry( + client, + tx.Sponsor, + tx.Account, + ) + + if (!sponsorship) { + return { + valid: false, + error: `No Sponsorship ledger entry found for sponsor ${tx.Sponsor} and sponsee ${tx.Account}`, + } + } + + // Check if sponsorship is for fee payment + /* eslint-disable no-bitwise -- bitwise operations required for flag checking */ + const isSponsoringFee = (tx.SponsorFlags & SponsorFlags.tfSponsorFee) !== 0 + /* eslint-enable no-bitwise */ + + if (!isSponsoringFee) { + // Only reserve sponsorship, no fee validation needed + return { + valid: true, + sponsorship, + estimatedFee: fee, + } + } + + // When sponsoring fee, FeeAmount must be present + if (sponsorship.FeeAmount == null) { + return { + valid: false, + error: + 'Sponsorship FeeAmount is required when tfSponsorFee flag is set', + sponsorship, + estimatedFee: fee, + } + } + + // Validate FeeAmount has sufficient balance + if (sponsorship.FeeAmount) { + const feeAmount = new BigNumber(sponsorship.FeeAmount) + const txFee = new BigNumber(fee) + + if (feeAmount.isLessThan(txFee)) { + return { + valid: false, + error: `Sponsorship FeeAmount (${sponsorship.FeeAmount} drops) is insufficient for transaction fee (${fee} drops)`, + sponsorship, + estimatedFee: fee, + } + } + } + + // Validate MaxFee if set + if (sponsorship.MaxFee) { + const maxFee = new BigNumber(sponsorship.MaxFee) + const txFee = new BigNumber(fee) + + if (txFee.isGreaterThan(maxFee)) { + return { + valid: false, + error: `Transaction fee (${fee} drops) exceeds sponsorship MaxFee (${sponsorship.MaxFee} drops)`, + sponsorship, + estimatedFee: fee, + } + } + } + + return { + valid: true, + sponsorship, + estimatedFee: fee, + } + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : String(error), + } + } +} + +/** + * Retrieves a Sponsorship ledger entry from the ledger. + * + * @param client - The XRPL client. + * @param sponsor - The sponsor account address. + * @param sponsee - The sponsee account address. + * @returns The Sponsorship ledger entry, or null if not found. + */ +async function getSponsorshipEntry( + client: Client, + sponsor: string, + sponsee: string, +): Promise { + try { + const request: LedgerEntryRequest = { + command: 'ledger_entry', + sponsorship: { + sponsor, + sponsee, + }, + } + + const response = await client.request(request) + const entry = response.result.node + + if ( + entry && + typeof entry === 'object' && + 'LedgerEntryType' in entry && + entry.LedgerEntryType === 'Sponsorship' + ) { + return entry + } + + return null + } catch (error) { + if (error instanceof XrplError && error.message.includes('entryNotFound')) { + return null + } + throw error + } +} diff --git a/packages/xrpl/test/client/autofill.test.ts b/packages/xrpl/test/client/autofill.test.ts index b68b2ddd08..a2025c63dc 100644 --- a/packages/xrpl/test/client/autofill.test.ts +++ b/packages/xrpl/test/client/autofill.test.ts @@ -5,6 +5,7 @@ import { AccountDelete, EscrowFinish, Payment, + SponsorshipSet, Transaction, Batch, type LoanSet, @@ -23,6 +24,7 @@ const Fee = '10' const Sequence = 1432 const LastLedgerSequence = 2908734 +/* eslint-disable max-statements -- test file with many test cases */ describe('client.autofill', function () { let testContext: XrplTestContext const AMOUNT = '1234' @@ -226,6 +228,78 @@ describe('client.autofill', function () { ) }) + it('converts Sponsee X-address to classic address in SponsorshipSet', async function () { + const tx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Sponsee: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee: '12', + } + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.Sponsee, 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59') + }) + + it('converts CounterpartySponsor X-address to classic address in SponsorshipSet', async function () { + const tx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Sponsee: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + CounterpartySponsor: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + Fee: '12', + } + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual( + txResult.CounterpartySponsor, + 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59', + ) + }) + + it('converts Sponsor X-address to classic address in Payment with sponsor', async function () { + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Amount: '1234', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Sponsor: 'X7AcgcsBL6XDcUb289X4mJ8djcdyKaB5hJDWMArnXr61cqZ', + SponsorFlags: 1, + } + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + assert.strictEqual(txResult.Sponsor, 'r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59') + }) + it("should autofill Sequence when it's missing", async function () { const tx: Transaction = { TransactionType: 'DepositPreauth', @@ -591,4 +665,120 @@ describe('client.autofill', function () { // base_fee + 3 * base_fee assert.strictEqual(txResult.Fee, '48') }) + + describe('Sponsorship autofill', function () { + it('does not add sponsor fees for pre-funded sponsorship (Sponsor without SponsorSignature)', async function () { + // Pre-funded sponsorship: Sponsor field is present but no SponsorSignature. + // The sponsor uses a pre-existing Sponsorship ledger object, so no additional + // sponsor signature is required and no extra fees should be added. + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Amount: '1234', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Sponsor: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + SponsorFlags: 1, + } + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + // Fee should be base fee only (12) - no sponsor signature fees for pre-funded sponsorship + assert.strictEqual(txResult.Fee, '12') + }) + + it('calculates fee for co-signed sponsorship with single sponsor signature', async function () { + // Co-signed sponsorship: SponsorSignature is present, so the sponsor is + // actively signing the transaction and fees should include the signature. + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Amount: '1234', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Sponsor: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + SponsorFlags: 1, + SponsorSignature: { + SigningPubKey: + '02FE9932A9C4AA2AC9F0ED0F2B89302DE7C2C95F91D782DA3CF06E64E1C1216449', + TxnSignature: '3045...', + }, + } + + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + // Fee should include base fee (12) + sponsor signature fee (12) = 24 + assert.strictEqual(txResult.Fee, '24') + }) + + it('calculates fee for co-signed sponsorship with multi-sig sponsor', async function () { + // Co-signed sponsorship with multiple sponsor signers. + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rGWrZyQqhTp9Xu7G5Pkayo7bXjH4k4QYpf', + Amount: '1234', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Sponsor: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + SponsorFlags: 1, + SponsorSignature: { + Signers: [ + { + Signer: { + Account: 'rSigner1', + SigningPubKey: '02AAAA...', + TxnSignature: '3045...', + }, + }, + { + Signer: { + Account: 'rSigner2', + SigningPubKey: '02BBBB...', + TxnSignature: '3045...', + }, + }, + { + Signer: { + Account: 'rSigner3', + SigningPubKey: '02CCCC...', + TxnSignature: '3045...', + }, + }, + ], + }, + } + + testContext.mockRippled!.addResponse( + 'account_info', + rippled.account_info.normal, + ) + testContext.mockRippled!.addResponse( + 'server_info', + rippled.server_info.normal, + ) + testContext.mockRippled!.addResponse('ledger', rippled.ledger.normal) + + const txResult = await testContext.client.autofill(tx) + + // Fee should include base fee (12) + 3 sponsor signatures (36) = 48 + assert.strictEqual(txResult.Fee, '48') + }) + }) }) +/* eslint-enable max-statements */ diff --git a/packages/xrpl/test/integration/transactions/sponsorship.test.ts b/packages/xrpl/test/integration/transactions/sponsorship.test.ts new file mode 100644 index 0000000000..adfdd2ad78 --- /dev/null +++ b/packages/xrpl/test/integration/transactions/sponsorship.test.ts @@ -0,0 +1,893 @@ +import { assert } from 'chai' + +import { + CheckCreate, + EscrowCreate, + LedgerEntryResponse, + Payment, + SponsorFlags, + SponsorshipSet, + SponsorshipSetFlags, + SponsorshipTransfer, + SponsorshipTransferFlags, + Wallet, + signAsSponsor, + combineSponsorSigners, + addPreFundedSponsor, + validatePreFundedSponsorship, +} from '../../../src' +import type Sponsorship from '../../../src/models/ledger/Sponsorship' +import type { AccountInfoResponse } from '../../../src/models/methods' +import serverUrl from '../serverUrl' +import { + setupClient, + teardownClient, + type XrplIntegrationTestContext, +} from '../setup' +import { generateFundedWallet, testTransaction } from '../utils' + +// how long before each test case times out +const TIMEOUT = 30000 + +describe('Sponsorship (XLS-68)', function () { + let testContext: XrplIntegrationTestContext + let sponsorWallet: Wallet + let sponseeWallet: Wallet + + beforeAll(async () => { + testContext = await setupClient(serverUrl) + sponsorWallet = await generateFundedWallet(testContext.client) + sponseeWallet = await generateFundedWallet(testContext.client) + }) + + afterAll(async () => teardownClient(testContext)) + + describe('SponsorshipSet', function () { + it( + 'creates a basic sponsorship with MaxFee', + async () => { + const tx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: sponseeWallet.classicAddress, + MaxFee: '1000', + } + + const result = await testTransaction( + testContext.client, + tx, + sponsorWallet, + ) + assert.equal(result.result.engine_result, 'tesSUCCESS') + + // Verify sponsorship was created + const sponsorshipEntry: LedgerEntryResponse = + await testContext.client.request({ + command: 'ledger_entry', + sponsorship: { + sponsor: sponsorWallet.classicAddress, + sponsee: sponseeWallet.classicAddress, + }, + }) + + const sponsorship = sponsorshipEntry.result.node as Sponsorship + assert.equal(sponsorship.LedgerEntryType, 'Sponsorship') + assert.equal(sponsorship.Owner, sponsorWallet.classicAddress) + assert.equal(sponsorship.Sponsee, sponseeWallet.classicAddress) + assert.equal(sponsorship.MaxFee, '1000') + }, + TIMEOUT, + ) + + it( + 'creates sponsorship with FeeAmount', + async () => { + const newSponsee = await generateFundedWallet(testContext.client) + + const tx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: newSponsee.classicAddress, + FeeAmount: '10000', + MaxFee: '500', + } + + const result = await testTransaction( + testContext.client, + tx, + sponsorWallet, + ) + assert.equal(result.result.engine_result, 'tesSUCCESS') + + // Verify FeeAmount was set + const sponsorshipEntry: LedgerEntryResponse = + await testContext.client.request({ + command: 'ledger_entry', + sponsorship: { + sponsor: sponsorWallet.classicAddress, + sponsee: newSponsee.classicAddress, + }, + }) + + const sponsorship = sponsorshipEntry.result.node as Sponsorship + assert.equal(sponsorship.FeeAmount, '10000') + }, + TIMEOUT, + ) + + it( + 'modifies existing sponsorship', + async () => { + // Update MaxFee + const tx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: sponseeWallet.classicAddress, + // Increase from 1000 + MaxFee: '2000', + } + + const result = await testTransaction( + testContext.client, + tx, + sponsorWallet, + ) + assert.equal(result.result.engine_result, 'tesSUCCESS') + + // Verify update + const sponsorshipEntry: LedgerEntryResponse = + await testContext.client.request({ + command: 'ledger_entry', + sponsorship: { + sponsor: sponsorWallet.classicAddress, + sponsee: sponseeWallet.classicAddress, + }, + }) + + const sponsorship = sponsorshipEntry.result.node as Sponsorship + assert.equal(sponsorship.MaxFee, '2000') + }, + TIMEOUT, + ) + + it( + 'deletes sponsorship with tfDeleteObject flag', + async () => { + // Create a temporary sponsorship to delete + const tempSponsee = await generateFundedWallet(testContext.client) + + const createTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: tempSponsee.classicAddress, + MaxFee: '500', + } + await testTransaction(testContext.client, createTx, sponsorWallet) + + // Now delete it + const deleteTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: tempSponsee.classicAddress, + Flags: SponsorshipSetFlags.tfDeleteObject, + } + + const result = await testTransaction( + testContext.client, + deleteTx, + sponsorWallet, + ) + assert.equal(result.result.engine_result, 'tesSUCCESS') + }, + TIMEOUT, + ) + }) + + describe('Pre-funded Sponsorship', function () { + it( + 'submits sponsored Payment using pre-funded sponsorship', + async () => { + // First ensure sponsorship exists with FeeAmount + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: sponseeWallet.classicAddress, + FeeAmount: '10000', + MaxFee: '500', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + // Create payment with sponsor fields + let payment: Payment = { + TransactionType: 'Payment', + Account: sponseeWallet.classicAddress, + Destination: sponsorWallet.classicAddress, + Amount: '100', + } + + // Add pre-funded sponsor fields + payment = addPreFundedSponsor( + payment, + sponsorWallet.classicAddress, + SponsorFlags.tfSponsorFee, + ) as Payment + + // Validate sponsorship before submitting + const prepared = await testContext.client.autofill(payment) + const validation = await validatePreFundedSponsorship( + testContext.client, + prepared, + prepared.Fee, + ) + + assert.isTrue( + validation.valid, + `Validation failed: ${String(validation.error)}`, + ) + + // Submit the sponsored transaction (only sponsee signs) + const result = await testTransaction( + testContext.client, + prepared, + sponseeWallet, + ) + assert.equal(result.result.engine_result, 'tesSUCCESS') + + // Verify sponsor paid the fee (check FeeAmount decreased) + const sponsorshipAfter: LedgerEntryResponse = + await testContext.client.request({ + command: 'ledger_entry', + sponsorship: { + sponsor: sponsorWallet.classicAddress, + sponsee: sponseeWallet.classicAddress, + }, + }) + + const sponsorship = sponsorshipAfter.result.node as Sponsorship + // FeeAmount should have decreased + assert.isTrue(Number(sponsorship.FeeAmount) < 10000) + }, + TIMEOUT, + ) + + it( + 'validates sponsorship with insufficient FeeAmount', + async () => { + // Create sponsorship with very low FeeAmount + const lowSponsee = await generateFundedWallet(testContext.client) + + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: lowSponsee.classicAddress, + // Very low amount + FeeAmount: '5', + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + // Try to create payment with high fee + let payment: Payment = { + TransactionType: 'Payment', + Account: lowSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + Amount: '100', + } + + payment = addPreFundedSponsor( + payment, + sponsorWallet.classicAddress, + SponsorFlags.tfSponsorFee, + ) as Payment + + const prepared = await testContext.client.autofill(payment) + + // Validation should fail - requesting higher fee than available + const validation = await validatePreFundedSponsorship( + testContext.client, + prepared, + '100', + ) + + assert.isFalse(validation.valid) + assert.include(validation.error ?? '', 'insufficient') + }, + TIMEOUT, + ) + + it( + 'validates sponsorship MaxFee enforcement', + async () => { + // Create sponsorship with strict MaxFee + const maxFeeSponsee = await generateFundedWallet(testContext.client) + + // Very low max fee + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: maxFeeSponsee.classicAddress, + FeeAmount: '10000', + MaxFee: '20', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + let payment: Payment = { + TransactionType: 'Payment', + Account: maxFeeSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + Amount: '100', + } + + payment = addPreFundedSponsor( + payment, + sponsorWallet.classicAddress, + SponsorFlags.tfSponsorFee, + ) as Payment + + const prepared = await testContext.client.autofill(payment) + + // If autofill fee exceeds MaxFee, validation should fail + if (Number(prepared.Fee) > 20) { + const validation = await validatePreFundedSponsorship( + testContext.client, + prepared, + prepared.Fee, + ) + + assert.isFalse(validation.valid) + assert.include(validation.error ?? '', 'MaxFee') + } + }, + TIMEOUT, + ) + }) + + describe('Co-signing Sponsorship', function () { + it( + 'submits sponsored Payment with sponsor signature', + async () => { + const coSignSponsee = await generateFundedWallet(testContext.client) + + // Create payment + const payment: Payment = { + TransactionType: 'Payment', + Account: coSignSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + Amount: '100', + Sponsor: sponsorWallet.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorFee, + } + + // Sponsee signs first + const prepared = await testContext.client.autofill(payment) + const sponseeSigned = coSignSponsee.sign(prepared) + + // Sponsor adds signature + const sponsorSigned = signAsSponsor( + sponsorWallet, + sponseeSigned.tx_blob, + ) + + // Submit with both signatures + const result = await testContext.client.submitAndWait( + sponsorSigned.tx_blob, + ) + assert.equal(result.result.validated, true) + + // Verify transaction has both signatures + const tx = result.result.tx_json + assert.isDefined(tx.TxnSignature, 'Should have sponsee signature') + assert.isDefined(tx.SponsorSignature, 'Should have sponsor signature') + }, + TIMEOUT, + ) + + it( + 'combines multiple sponsor signers for multisig sponsorship', + async () => { + const multiSigSponsee = await generateFundedWallet(testContext.client) + const sponsor1 = await generateFundedWallet(testContext.client) + const sponsor2 = await generateFundedWallet(testContext.client) + + // Create payment with sponsor (using sponsor1's address as the main sponsor) + const payment: Payment = { + TransactionType: 'Payment', + Account: multiSigSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + Amount: '100', + Sponsor: sponsor1.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorFee, + } + + // Sponsee signs first + const prepared = await testContext.client.autofill(payment) + const sponseeSigned = multiSigSponsee.sign(prepared) + + // Both sponsors sign as multisig + const sponsor1Signed = signAsSponsor(sponsor1, sponseeSigned.tx_blob, { + multisign: true, + }) + const sponsor2Signed = signAsSponsor(sponsor2, sponseeSigned.tx_blob, { + multisign: true, + }) + + // Combine sponsor signatures + const combined = combineSponsorSigners([ + sponsor1Signed.tx_blob, + sponsor2Signed.tx_blob, + ]) + + // Verify combined transaction has multiple signers in SponsorSignature + assert.isDefined(combined.tx.SponsorSignature) + const sponsorSig = combined.tx.SponsorSignature as { + Signers: Array<{ Signer: unknown }> + } + assert.isDefined(sponsorSig.Signers) + assert.equal(sponsorSig.Signers.length, 2) + }, + TIMEOUT, + ) + }) + + describe('SponsorshipTransfer', function () { + it( + 'ends object sponsorship with tfSponsorshipEnd', + async () => { + const transferSponsee = await generateFundedWallet(testContext.client) + + // First create a sponsorship + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: transferSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + // Create a Check that will be sponsored (creates a ledger object) + const checkTx: CheckCreate = { + TransactionType: 'CheckCreate', + Account: transferSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + SendMax: '1000000', + Sponsor: sponsorWallet.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorReserve, + } + + // Sponsee signs, then sponsor co-signs + const preparedCheck = await testContext.client.autofill(checkTx) + const sponseeSigned = transferSponsee.sign(preparedCheck) + const sponsorSigned = signAsSponsor( + sponsorWallet, + sponseeSigned.tx_blob, + ) + + const checkResult = await testContext.client.submitAndWait( + sponsorSigned.tx_blob, + ) + assert.equal(checkResult.result.validated, true) + + // Get the Check object ID from account_objects + const accountObjects = await testContext.client.request({ + command: 'account_objects', + account: transferSponsee.classicAddress, + type: 'check', + }) + assert.isAtLeast(accountObjects.result.account_objects.length, 1) + const checkObject = accountObjects.result.account_objects[0] + const objectId = checkObject.index + + // End the sponsorship for this object + const endTx: SponsorshipTransfer = { + TransactionType: 'SponsorshipTransfer', + Account: transferSponsee.classicAddress, + ObjectID: objectId, + Flags: SponsorshipTransferFlags.tfSponsorshipEnd, + } + + const endResult = await testTransaction( + testContext.client, + endTx, + transferSponsee, + ) + assert.equal(endResult.result.engine_result, 'tesSUCCESS') + }, + TIMEOUT, + ) + + it( + 'creates sponsorship for existing object with tfSponsorshipCreate', + async () => { + const createSponsee = await generateFundedWallet(testContext.client) + const newSponsor = await generateFundedWallet(testContext.client) + + // Create a Check without sponsorship first + const checkTx: CheckCreate = { + TransactionType: 'CheckCreate', + Account: createSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + SendMax: '1000000', + } + await testTransaction(testContext.client, checkTx, createSponsee) + + // Get the Check object ID + const accountObjects = await testContext.client.request({ + command: 'account_objects', + account: createSponsee.classicAddress, + type: 'check', + }) + assert.isAtLeast(accountObjects.result.account_objects.length, 1) + const checkObject = accountObjects.result.account_objects[0] + const objectId = checkObject.index + + // Set up sponsorship relationship first + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: newSponsor.classicAddress, + Sponsee: createSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, newSponsor) + + // Create sponsorship for the existing object + const createSponsorshipTx: SponsorshipTransfer = { + TransactionType: 'SponsorshipTransfer', + Account: createSponsee.classicAddress, + ObjectID: objectId, + Sponsor: newSponsor.classicAddress, + Flags: SponsorshipTransferFlags.tfSponsorshipCreate, + } + + // Sponsee signs, then new sponsor co-signs + const prepared = await testContext.client.autofill(createSponsorshipTx) + const sponseeSigned = createSponsee.sign(prepared) + const sponsorSigned = signAsSponsor(newSponsor, sponseeSigned.tx_blob) + + const result = await testContext.client.submitAndWait( + sponsorSigned.tx_blob, + ) + assert.equal(result.result.validated, true) + }, + TIMEOUT, + ) + + it( + 'reassigns sponsorship to new sponsor with tfSponsorshipReassign', + async () => { + const reassignSponsee = await generateFundedWallet(testContext.client) + const originalSponsor = await generateFundedWallet(testContext.client) + const newSponsor = await generateFundedWallet(testContext.client) + + // Set up original sponsorship + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: originalSponsor.classicAddress, + Sponsee: reassignSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, originalSponsor) + + // Create a Check with original sponsor + const checkTx: CheckCreate = { + TransactionType: 'CheckCreate', + Account: reassignSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + SendMax: '1000000', + Sponsor: originalSponsor.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorReserve, + } + const preparedCheck = await testContext.client.autofill(checkTx) + const sponseeSignedCheck = reassignSponsee.sign(preparedCheck) + const originalSponsorSigned = signAsSponsor( + originalSponsor, + sponseeSignedCheck.tx_blob, + ) + await testContext.client.submitAndWait(originalSponsorSigned.tx_blob) + + // Get the Check object ID + const accountObjects = await testContext.client.request({ + command: 'account_objects', + account: reassignSponsee.classicAddress, + type: 'check', + }) + const checkObject = accountObjects.result.account_objects[0] + const objectId = checkObject.index + + // Set up new sponsorship relationship + const newSetupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: newSponsor.classicAddress, + Sponsee: reassignSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, newSetupTx, newSponsor) + + // Reassign sponsorship to new sponsor + const reassignTx: SponsorshipTransfer = { + TransactionType: 'SponsorshipTransfer', + Account: reassignSponsee.classicAddress, + ObjectID: objectId, + Sponsor: newSponsor.classicAddress, + Flags: SponsorshipTransferFlags.tfSponsorshipReassign, + } + + const prepared = await testContext.client.autofill(reassignTx) + const sponseeSigned = reassignSponsee.sign(prepared) + const newSponsorSigned = signAsSponsor( + newSponsor, + sponseeSigned.tx_blob, + ) + + const result = await testContext.client.submitAndWait( + newSponsorSigned.tx_blob, + ) + assert.equal(result.result.validated, true) + }, + TIMEOUT, + ) + }) + + describe('Reserve Sponsorship', function () { + it( + 'sponsors reserve for CheckCreate with tfSponsorReserve', + async () => { + const reserveSponsee = await generateFundedWallet(testContext.client) + + // Set up sponsorship relationship + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: reserveSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + // Get initial account info + const initialInfo: AccountInfoResponse = + await testContext.client.request({ + command: 'account_info', + account: reserveSponsee.classicAddress, + }) + const initialOwnerCount = initialInfo.result.account_data.OwnerCount + + // Create a Check with reserve sponsorship + const checkTx: CheckCreate = { + TransactionType: 'CheckCreate', + Account: reserveSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + SendMax: '1000000', + Sponsor: sponsorWallet.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorReserve, + } + + const prepared = await testContext.client.autofill(checkTx) + const sponseeSigned = reserveSponsee.sign(prepared) + const sponsorSigned = signAsSponsor( + sponsorWallet, + sponseeSigned.tx_blob, + ) + + const result = await testContext.client.submitAndWait( + sponsorSigned.tx_blob, + ) + assert.equal(result.result.validated, true) + + // Verify Check was created + const accountObjects = await testContext.client.request({ + command: 'account_objects', + account: reserveSponsee.classicAddress, + type: 'check', + }) + assert.isAtLeast(accountObjects.result.account_objects.length, 1) + + // Verify sponsee's OwnerCount did not increase (sponsor pays reserve) + const finalInfo: AccountInfoResponse = await testContext.client.request( + { + command: 'account_info', + account: reserveSponsee.classicAddress, + }, + ) + const finalOwnerCount = finalInfo.result.account_data.OwnerCount + + // OwnerCount should remain the same since reserve is sponsored + assert.equal( + finalOwnerCount, + initialOwnerCount, + 'OwnerCount should not increase when reserve is sponsored', + ) + }, + TIMEOUT, + ) + + it( + 'sponsors reserve for EscrowCreate with tfSponsorReserve', + async () => { + const escrowSponsee = await generateFundedWallet(testContext.client) + + // Set up sponsorship + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: escrowSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + // Get ledger close time for FinishAfter + const ledgerResponse = await testContext.client.request({ + command: 'ledger', + ledger_index: 'validated', + }) + const closeTime = ledgerResponse.result.ledger.close_time + + // Create an Escrow with reserve sponsorship + const escrowTx: EscrowCreate = { + TransactionType: 'EscrowCreate', + Account: escrowSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + Amount: '10000', + FinishAfter: closeTime + 60, + Sponsor: sponsorWallet.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorReserve, + } + + const prepared = await testContext.client.autofill(escrowTx) + const sponseeSigned = escrowSponsee.sign(prepared) + const sponsorSigned = signAsSponsor( + sponsorWallet, + sponseeSigned.tx_blob, + ) + + const result = await testContext.client.submitAndWait( + sponsorSigned.tx_blob, + ) + assert.equal(result.result.validated, true) + + // Verify Escrow was created + const accountObjects = await testContext.client.request({ + command: 'account_objects', + account: escrowSponsee.classicAddress, + type: 'escrow', + }) + assert.isAtLeast(accountObjects.result.account_objects.length, 1) + }, + TIMEOUT, + ) + + it( + 'sponsors both fee and reserve with combined flags', + async () => { + const combinedSponsee = await generateFundedWallet(testContext.client) + + // Set up sponsorship with FeeAmount + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: sponsorWallet.classicAddress, + Sponsee: combinedSponsee.classicAddress, + FeeAmount: '10000', + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, sponsorWallet) + + // Create a Check with both fee and reserve sponsorship + const checkTx: CheckCreate = { + TransactionType: 'CheckCreate', + Account: combinedSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + SendMax: '1000000', + Sponsor: sponsorWallet.classicAddress, + /* eslint-disable no-bitwise -- combining sponsor flags */ + SponsorFlags: + SponsorFlags.tfSponsorFee | SponsorFlags.tfSponsorReserve, + /* eslint-enable no-bitwise */ + } + + const prepared = await testContext.client.autofill(checkTx) + const sponseeSigned = combinedSponsee.sign(prepared) + const sponsorSigned = signAsSponsor( + sponsorWallet, + sponseeSigned.tx_blob, + ) + + const result = await testContext.client.submitAndWait( + sponsorSigned.tx_blob, + ) + assert.equal(result.result.validated, true) + + // Verify Check was created + const accountObjects = await testContext.client.request({ + command: 'account_objects', + account: combinedSponsee.classicAddress, + type: 'check', + }) + assert.isAtLeast(accountObjects.result.account_objects.length, 1) + + // Verify FeeAmount decreased (sponsor paid fee) + const sponsorshipAfter: LedgerEntryResponse = + await testContext.client.request({ + command: 'ledger_entry', + sponsorship: { + sponsor: sponsorWallet.classicAddress, + sponsee: combinedSponsee.classicAddress, + }, + }) + const sponsorship = sponsorshipAfter.result.node as Sponsorship + assert.isTrue( + Number(sponsorship.FeeAmount) < 10000, + 'FeeAmount should decrease after sponsored transaction', + ) + }, + TIMEOUT, + ) + + it( + 'verifies SponsoredOwnerCount and SponsoringOwnerCount', + async () => { + const countSponsee = await generateFundedWallet(testContext.client) + const countSponsor = await generateFundedWallet(testContext.client) + + // Get initial sponsor account info + const initialSponsorInfo: AccountInfoResponse = + await testContext.client.request({ + command: 'account_info', + account: countSponsor.classicAddress, + }) + const initialSponsoringCount = + initialSponsorInfo.result.account_data.SponsoringOwnerCount ?? 0 + + // Set up sponsorship + const setupTx: SponsorshipSet = { + TransactionType: 'SponsorshipSet', + Account: countSponsor.classicAddress, + Sponsee: countSponsee.classicAddress, + MaxFee: '1000', + } + await testTransaction(testContext.client, setupTx, countSponsor) + + // Create a Check with reserve sponsorship + const checkTx: CheckCreate = { + TransactionType: 'CheckCreate', + Account: countSponsee.classicAddress, + Destination: sponsorWallet.classicAddress, + SendMax: '1000000', + Sponsor: countSponsor.classicAddress, + SponsorFlags: SponsorFlags.tfSponsorReserve, + } + + const prepared = await testContext.client.autofill(checkTx) + const sponseeSigned = countSponsee.sign(prepared) + const sponsorSigned = signAsSponsor(countSponsor, sponseeSigned.tx_blob) + + await testContext.client.submitAndWait(sponsorSigned.tx_blob) + + // Verify SponsoredOwnerCount increased on sponsee + const sponseeInfo: AccountInfoResponse = + await testContext.client.request({ + command: 'account_info', + account: countSponsee.classicAddress, + }) + const sponsoredCount = + sponseeInfo.result.account_data.SponsoredOwnerCount ?? 0 + assert.isAtLeast( + Number(sponsoredCount), + 1, + 'SponsoredOwnerCount should be at least 1', + ) + + // Verify SponsoringOwnerCount increased on sponsor + const sponsorInfo: AccountInfoResponse = + await testContext.client.request({ + command: 'account_info', + account: countSponsor.classicAddress, + }) + const sponsoringCount = + sponsorInfo.result.account_data.SponsoringOwnerCount ?? 0 + assert.isTrue( + Number(sponsoringCount) > Number(initialSponsoringCount), + 'SponsoringOwnerCount should increase', + ) + }, + TIMEOUT, + ) + }) +}) diff --git a/packages/xrpl/test/models/sponsorshipSet.test.ts b/packages/xrpl/test/models/sponsorshipSet.test.ts new file mode 100644 index 0000000000..ba3bb48c94 --- /dev/null +++ b/packages/xrpl/test/models/sponsorshipSet.test.ts @@ -0,0 +1,140 @@ +import { + SponsorshipSetFlags, + validateSponsorshipSet, +} from '../../src/models/transactions/sponsorshipSet' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateSponsorshipSet) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateSponsorshipSet, message) + +/** + * SponsorshipSet Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('SponsorshipSet', function () { + let sponsorshipSetTx: any + + beforeEach(function () { + sponsorshipSetTx = { + TransactionType: 'SponsorshipSet', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Sponsee: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + Fee: '12', + } as any + }) + + it('verifies valid SponsorshipSet', function () { + assertValid(sponsorshipSetTx) + }) + + it('verifies valid SponsorshipSet with MaxFee', function () { + sponsorshipSetTx.MaxFee = '1000' + assertValid(sponsorshipSetTx) + }) + + it('verifies valid SponsorshipSet with tfDeleteObject flag', function () { + sponsorshipSetTx.Flags = SponsorshipSetFlags.tfDeleteObject + assertValid(sponsorshipSetTx) + }) + + it('verifies valid SponsorshipSet with boolean tfDeleteObject flag', function () { + sponsorshipSetTx.Flags = { tfDeleteObject: true } + assertValid(sponsorshipSetTx) + }) + + it('throws when Sponsee is missing', function () { + delete sponsorshipSetTx.Sponsee + assertInvalid( + sponsorshipSetTx, + 'SponsorshipSet: must have either Sponsee or CounterpartySponsor', + ) + }) + + it('throws when Sponsee is not a string', function () { + sponsorshipSetTx.Sponsee = 123 + assertInvalid(sponsorshipSetTx, 'SponsorshipSet: Sponsee must be a string') + }) + + it('throws when Sponsee is not a valid account address', function () { + sponsorshipSetTx.Sponsee = 'invalid_address' + assertInvalid( + sponsorshipSetTx, + 'SponsorshipSet: Sponsee must be a valid account address', + ) + }) + + it('throws when Account and Sponsee are the same', function () { + sponsorshipSetTx.Sponsee = sponsorshipSetTx.Account + assertInvalid( + sponsorshipSetTx, + 'SponsorshipSet: Account and Sponsee cannot be the same', + ) + }) + + it('throws when MaxFee is not a string', function () { + sponsorshipSetTx.MaxFee = 1000 + assertInvalid(sponsorshipSetTx, 'SponsorshipSet: MaxFee must be a string') + }) + + it('throws when MaxFee is negative', function () { + sponsorshipSetTx.MaxFee = '-100' + assertInvalid( + sponsorshipSetTx, + 'SponsorshipSet: MaxFee must be a non-negative numeric string', + ) + }) + + it('throws when MaxFee is not numeric', function () { + sponsorshipSetTx.MaxFee = 'not_a_number' + assertInvalid( + sponsorshipSetTx, + 'SponsorshipSet: MaxFee must be a non-negative numeric string', + ) + }) + + it('verifies valid SponsorshipSet with MaxFee as zero', function () { + sponsorshipSetTx.MaxFee = '0' + assertValid(sponsorshipSetTx) + }) + + it('verifies valid SponsorshipSet with large MaxFee', function () { + sponsorshipSetTx.MaxFee = '100000000000' + assertValid(sponsorshipSetTx) + }) + + it('verifies valid SponsorshipSet with X-Address for Sponsee', function () { + sponsorshipSetTx.Sponsee = 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi' + assertValid(sponsorshipSetTx) + }) + + it('verifies valid SponsorshipSet with X-Address for Account', function () { + sponsorshipSetTx.Account = 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi' + assertValid(sponsorshipSetTx) + }) + + it('throws when both Account and Sponsee are the same X-Address', function () { + const xAddress = 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi' + sponsorshipSetTx.Account = xAddress + sponsorshipSetTx.Sponsee = xAddress + assertInvalid( + sponsorshipSetTx, + 'SponsorshipSet: Account and Sponsee cannot be the same', + ) + }) + + it('verifies valid SponsorshipSet with all optional fields', function () { + sponsorshipSetTx.MaxFee = '5000' + sponsorshipSetTx.Flags = SponsorshipSetFlags.tfDeleteObject + sponsorshipSetTx.Memos = [ + { + Memo: { + MemoData: '54657374', + }, + }, + ] + assertValid(sponsorshipSetTx) + }) +}) diff --git a/packages/xrpl/test/models/sponsorshipTransfer.test.ts b/packages/xrpl/test/models/sponsorshipTransfer.test.ts new file mode 100644 index 0000000000..3bd2c0e8f9 --- /dev/null +++ b/packages/xrpl/test/models/sponsorshipTransfer.test.ts @@ -0,0 +1,280 @@ +import { + SponsorshipTransferFlags, + validateSponsorshipTransfer, +} from '../../src/models/transactions/sponsorshipTransfer' +import { assertTxIsValid, assertTxValidationError } from '../testUtils' + +const assertValid = (tx: any): void => + assertTxIsValid(tx, validateSponsorshipTransfer) +const assertInvalid = (tx: any, message: string): void => + assertTxValidationError(tx, validateSponsorshipTransfer, message) + +const LEDGER_INDEX = + 'AED08CC1F50DD5F23A1948AF86153A3F3B7593E5EC77D65A02BB1B29E05AB6AF' + +/** + * SponsorshipTransfer Transaction Verification Testing. + * + * Providing runtime verification testing for each specific transaction type. + */ +describe('SponsorshipTransfer', function () { + let sponsorshipTransferTx: any + + beforeEach(function () { + sponsorshipTransferTx = { + TransactionType: 'SponsorshipTransfer', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + ObjectID: LEDGER_INDEX, + // Default to End scenario + Flags: SponsorshipTransferFlags.tfSponsorshipEnd, + Fee: '12', + } as any + }) + + it('verifies valid SponsorshipTransfer with tfSponsorshipEnd', function () { + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with tfSponsorshipCreate and Sponsor', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with tfSponsorshipReassign and Sponsor', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipReassign + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer without ObjectID (account-level sponsorship)', function () { + delete sponsorshipTransferTx.ObjectID + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipEnd + assertValid(sponsorshipTransferTx) + }) + + it('throws when ObjectID is not a string', function () { + sponsorshipTransferTx.ObjectID = 123 + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: ObjectID must be a string', + ) + }) + + it('throws when ObjectID is not 64 hex characters', function () { + sponsorshipTransferTx.ObjectID = 'ABCD1234' + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: ObjectID must be a 64-character hexadecimal string', + ) + }) + + it('throws when ObjectID contains non-hex characters', function () { + sponsorshipTransferTx.ObjectID = + 'ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: ObjectID must be a 64-character hexadecimal string', + ) + }) + + it('throws when Sponsor is not a string', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Sponsor = 123 + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Sponsor must be a string', + ) + }) + + it('throws when Sponsor is not a valid account address', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Sponsor = 'invalid_address' + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Sponsor must be a valid account address', + ) + }) + + it('throws when Account and Sponsor are the same', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Sponsor = sponsorshipTransferTx.Account + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Account and Sponsor cannot be the same', + ) + }) + + it('verifies valid SponsorshipTransfer with X-Address for Sponsor', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Sponsor = + 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with X-Address for Account', function () { + sponsorshipTransferTx.Account = + 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi' + assertValid(sponsorshipTransferTx) + }) + + it('throws when both Account and Sponsor are the same X-Address', function () { + const xAddress = 'XVLhHMPHU98es4dbozjVtdWzVrDjtV18pX8yuPT7y4xaEHi' + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Account = xAddress + sponsorshipTransferTx.Sponsor = xAddress + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Account and Sponsor cannot be the same', + ) + }) + + it('verifies valid SponsorshipTransfer with lowercase hex ObjectID', function () { + sponsorshipTransferTx.ObjectID = + 'aed08cc1f50dd5f23a1948af86153a3f3b7593e5ec77d65a02bb1b29e05ab6af' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with mixed case hex ObjectID', function () { + sponsorshipTransferTx.ObjectID = + 'AeD08Cc1F50dD5f23A1948aF86153a3F3b7593E5eC77d65A02bB1b29E05aB6aF' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with all optional fields', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipEnd + sponsorshipTransferTx.Memos = [ + { + Memo: { + MemoData: '54657374', + }, + }, + ] + assertValid(sponsorshipTransferTx) + }) + + // Scenario Flag Tests + describe('Scenario Flag Validation', function () { + it('throws when no scenario flag is set', function () { + // Remove the Flags field to test the validator + delete sponsorshipTransferTx.Flags + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: must specify exactly one scenario flag (tfSponsorshipEnd, tfSponsorshipCreate, or tfSponsorshipReassign)', + ) + }) + + it('verifies valid SponsorshipTransfer with tfSponsorshipEnd flag', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipEnd + // tfSponsorshipEnd should NOT have Sponsor field + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with tfSponsorshipCreate flag and Sponsor', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with tfSponsorshipReassign flag and Sponsor', function () { + sponsorshipTransferTx.Flags = + SponsorshipTransferFlags.tfSponsorshipReassign + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertValid(sponsorshipTransferTx) + }) + + it('throws when tfSponsorshipEnd has Sponsor field present', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipEnd + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Sponsor field must not be present for tfSponsorshipEnd scenario', + ) + }) + + it('throws when tfSponsorshipCreate missing Sponsor field', function () { + sponsorshipTransferTx.Flags = SponsorshipTransferFlags.tfSponsorshipCreate + // No Sponsor field + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Sponsor field is required for tfSponsorshipCreate and tfSponsorshipReassign scenarios', + ) + }) + + it('throws when tfSponsorshipReassign missing Sponsor field', function () { + sponsorshipTransferTx.Flags = + SponsorshipTransferFlags.tfSponsorshipReassign + // No Sponsor field + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: Sponsor field is required for tfSponsorshipCreate and tfSponsorshipReassign scenarios', + ) + }) + + it('throws when multiple scenario flags are set (tfSponsorshipEnd + tfSponsorshipCreate)', function () { + /* eslint-disable no-bitwise -- Testing bitwise flag combinations */ + sponsorshipTransferTx.Flags = + SponsorshipTransferFlags.tfSponsorshipEnd | + SponsorshipTransferFlags.tfSponsorshipCreate + /* eslint-enable no-bitwise */ + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: cannot specify multiple scenario flags (tfSponsorshipEnd, tfSponsorshipCreate, tfSponsorshipReassign are mutually exclusive)', + ) + }) + + it('throws when multiple scenario flags are set (tfSponsorshipCreate + tfSponsorshipReassign)', function () { + /* eslint-disable no-bitwise -- Testing bitwise flag combinations */ + sponsorshipTransferTx.Flags = + SponsorshipTransferFlags.tfSponsorshipCreate | + SponsorshipTransferFlags.tfSponsorshipReassign + /* eslint-enable no-bitwise */ + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: cannot specify multiple scenario flags (tfSponsorshipEnd, tfSponsorshipCreate, tfSponsorshipReassign are mutually exclusive)', + ) + }) + + it('throws when multiple scenario flags are set (tfSponsorshipEnd + tfSponsorshipReassign)', function () { + /* eslint-disable no-bitwise -- Testing bitwise flag combinations */ + sponsorshipTransferTx.Flags = + SponsorshipTransferFlags.tfSponsorshipEnd | + SponsorshipTransferFlags.tfSponsorshipReassign + /* eslint-enable no-bitwise */ + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: cannot specify multiple scenario flags (tfSponsorshipEnd, tfSponsorshipCreate, tfSponsorshipReassign are mutually exclusive)', + ) + }) + + it('throws when all three scenario flags are set', function () { + /* eslint-disable no-bitwise -- Testing bitwise flag combinations */ + sponsorshipTransferTx.Flags = + SponsorshipTransferFlags.tfSponsorshipEnd | + SponsorshipTransferFlags.tfSponsorshipCreate | + SponsorshipTransferFlags.tfSponsorshipReassign + /* eslint-enable no-bitwise */ + assertInvalid( + sponsorshipTransferTx, + 'SponsorshipTransfer: cannot specify multiple scenario flags (tfSponsorshipEnd, tfSponsorshipCreate, tfSponsorshipReassign are mutually exclusive)', + ) + }) + + it('verifies valid SponsorshipTransfer with boolean tfSponsorshipEnd flag', function () { + sponsorshipTransferTx.Flags = { tfSponsorshipEnd: true } + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with boolean tfSponsorshipCreate flag', function () { + sponsorshipTransferTx.Flags = { tfSponsorshipCreate: true } + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertValid(sponsorshipTransferTx) + }) + + it('verifies valid SponsorshipTransfer with boolean tfSponsorshipReassign flag', function () { + sponsorshipTransferTx.Flags = { tfSponsorshipReassign: true } + sponsorshipTransferTx.Sponsor = 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy' + assertValid(sponsorshipTransferTx) + }) + }) +}) diff --git a/packages/xrpl/test/sugar/validateSponsorship.test.ts b/packages/xrpl/test/sugar/validateSponsorship.test.ts new file mode 100644 index 0000000000..f24186204c --- /dev/null +++ b/packages/xrpl/test/sugar/validateSponsorship.test.ts @@ -0,0 +1,191 @@ +import { assert } from 'chai' + +import { Client } from '../../src/client' +import type Sponsorship from '../../src/models/ledger/Sponsorship' +import type { LedgerEntryRequest } from '../../src/models/methods' +import { SponsorFlags } from '../../src/models/transactions/common' +import type { Payment } from '../../src/models/transactions/payment' +import { validatePreFundedSponsorship } from '../../src/sugar/validateSponsorship' +import serverUrl from '../integration/serverUrl' + +interface MockRequest { + command: string +} + +interface MockResponse { + status: string + type: string + result: { node: Sponsorship } +} + +interface MockRequestFnInterface { + (req: MockRequest): Promise +} + +describe('validatePreFundedSponsorship', function () { + let client: Client + + beforeEach(async function () { + client = new Client(serverUrl) + }) + + afterEach(async function () { + if (client.isConnected()) { + await client.disconnect() + } + }) + + it('rejects transaction without Sponsor field', async function () { + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Amount: '1000000', + } + + const result = await validatePreFundedSponsorship(client, tx, '100') + + assert.isFalse(result.valid) + assert.include(result.error ?? '', 'Sponsor and SponsorFlags') + }) + + it('rejects transaction with SponsorSignature', async function () { + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Amount: '1000000', + Sponsor: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + SponsorFlags: SponsorFlags.tfSponsorFee, + SponsorSignature: { + SigningPubKey: + '02FE9932A9C4AA2AC9F0ED0F2B89302DE7C2C95F91D782DA3CF06E64E1C1216449', + TxnSignature: '3045...', + }, + } + + const result = await validatePreFundedSponsorship(client, tx, '100') + + assert.isFalse(result.valid) + assert.include(result.error ?? '', 'pre-funded') + }) + + it('validates reserve-only sponsorship without fee checks', async function () { + const mockResponse = { + status: 'success', + type: 'response', + result: { + node: { + LedgerEntryType: 'Sponsorship', + Owner: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + Sponsee: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Flags: 0, + } as Sponsorship, + }, + } + + // Mock the ledger_entry request + const originalRequest = client.request.bind(client) + const mockFn: MockRequestFnInterface = async (req) => { + if (req.command === 'ledger_entry') { + return mockResponse + } + return originalRequest(req as LedgerEntryRequest) + } + client.request = mockFn as typeof client.request + + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Amount: '1000000', + Sponsor: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + SponsorFlags: SponsorFlags.tfSponsorReserve, + } + + const result = await validatePreFundedSponsorship(client, tx, '100') + + assert.isTrue(result.valid) + assert.isDefined(result.sponsorship) + }) + + it('rejects when FeeAmount is insufficient', async function () { + const mockResponse = { + status: 'success', + type: 'response', + result: { + node: { + LedgerEntryType: 'Sponsorship', + Owner: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + Sponsee: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Flags: 0, + // Only 50 drops available + FeeAmount: '50', + } as Sponsorship, + }, + } + + const originalRequest = client.request.bind(client) + const mockFn: MockRequestFnInterface = async (req) => { + if (req.command === 'ledger_entry') { + return mockResponse + } + return originalRequest(req as LedgerEntryRequest) + } + client.request = mockFn as typeof client.request + + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Amount: '1000000', + Sponsor: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + SponsorFlags: SponsorFlags.tfSponsorFee, + } + + const result = await validatePreFundedSponsorship(client, tx, '100') + + assert.isFalse(result.valid) + assert.include(result.error ?? '', 'insufficient') + }) + + it('rejects when MaxFee is exceeded', async function () { + const mockResponse = { + status: 'success', + type: 'response', + result: { + node: { + LedgerEntryType: 'Sponsorship', + Owner: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + Sponsee: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Flags: 0, + FeeAmount: '1000000', + MaxFee: '50', + } as Sponsorship, + }, + } + + const originalRequest = client.request.bind(client) + const mockFn: MockRequestFnInterface = async (req) => { + if (req.command === 'ledger_entry') { + return mockResponse + } + return originalRequest(req as LedgerEntryRequest) + } + client.request = mockFn as typeof client.request + + const tx: Payment = { + TransactionType: 'Payment', + Account: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfXoKk', + Destination: 'rpZc4mVfWUif9CRoHRKKcmhu1nx2xktxBo', + Amount: '1000000', + Sponsor: 'rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy', + SponsorFlags: SponsorFlags.tfSponsorFee, + } + + const result = await validatePreFundedSponsorship(client, tx, '100') + + assert.isFalse(result.valid) + assert.include(result.error ?? '', 'MaxFee') + }) +}) diff --git a/packages/xrpl/test/wallet/sponsorSigner.test.ts b/packages/xrpl/test/wallet/sponsorSigner.test.ts new file mode 100644 index 0000000000..580dfe4fa0 --- /dev/null +++ b/packages/xrpl/test/wallet/sponsorSigner.test.ts @@ -0,0 +1,422 @@ +import { assert } from 'chai' + +import { Payment, SponsorFlags, TrustSet, Wallet } from '../../src' +import { + addPreFundedSponsor, + combineSponsorSigners, + signAsSponsor, +} from '../../src/Wallet/sponsorSigner' + +/* eslint-disable max-statements -- test file with many assertions */ +describe('sponsorSigner', function () { + it('single sign', function () { + const sponsorWallet = Wallet.fromSeed('sEdSyBUScyy9msTU36wdR68XkskQky5') + + const signedPayment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + Sponsor: 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un', + SponsorFlags: 1, + SigningPubKey: + 'EDFF8D8C5AC309EAA4F3A0C6D2AAF9A9DFA0724063398110365D4631971F604C4C', + TxnSignature: + '1AF5B3118F5F292EDCEAB34A4180792240AF86258C6BC8340D7523D396424F63B4BD4EAF20DE7C5AA9B472DB86AC36E956DAD02288638E59D90C7A0F6BF6E802', + } + + const expectedPayment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + Sponsor: 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un', + SponsorFlags: 1, + SigningPubKey: + 'EDFF8D8C5AC309EAA4F3A0C6D2AAF9A9DFA0724063398110365D4631971F604C4C', + TxnSignature: + '1AF5B3118F5F292EDCEAB34A4180792240AF86258C6BC8340D7523D396424F63B4BD4EAF20DE7C5AA9B472DB86AC36E956DAD02288638E59D90C7A0F6BF6E802', + SponsorSignature: { + SigningPubKey: + 'EDD184F5FE58EC1375AB1CF17A3C5A12A8DEE89DD5228772D69E28EE37438FE59E', + TxnSignature: + '8F13B45F365C9362F06A0DE63F544B7B9D87EE6F10180E5DC997D8184B4666E2158D4AA870DEDDCBB21D405F901EBC332B1F8139EC1672291629DF65D112960B', + }, + } + + // Test error: transaction not signed by account + assert.throws(() => { + signAsSponsor(sponsorWallet, { + ...signedPayment, + SigningPubKey: undefined, + TxnSignature: undefined, + } as Payment) + }, 'Transaction must be first signed by the account.') + + // Test error: transaction already signed by sponsor + assert.throws(() => { + signAsSponsor(sponsorWallet, { + ...signedPayment, + SponsorSignature: { + SigningPubKey: '', + TxnSignature: '', + }, + } as Payment) + }, 'Transaction is already signed by the sponsor.') + + // Test error: missing SponsorFlags + assert.throws(() => { + signAsSponsor(sponsorWallet, { + ...signedPayment, + SponsorFlags: undefined, + } as Payment) + }, 'Transaction must have SponsorFlags field set before sponsor can sign.') + + // Test error: missing Sponsor field + assert.throws(() => { + signAsSponsor(sponsorWallet, { + ...signedPayment, + Sponsor: undefined, + } as Payment) + }, 'Transaction must have Sponsor field set before sponsor can sign.') + + // Test error: Sponsor field doesn't match wallet + assert.throws(() => { + signAsSponsor(sponsorWallet, { + ...signedPayment, + Sponsor: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + } as Payment) + }, /Transaction Sponsor field .* does not match the signing wallet address/u) + + // Test successful single signature + const { tx: sponsorSignedTx } = signAsSponsor( + sponsorWallet, + signedPayment as Payment, + ) + + // Verify structure and key fields (signatures are non-deterministic) + assert.equal( + sponsorSignedTx.TransactionType, + expectedPayment.TransactionType, + ) + assert.equal(sponsorSignedTx.Account, expectedPayment.Account) + assert.equal(sponsorSignedTx.Destination, expectedPayment.Destination) + assert.equal(sponsorSignedTx.Amount, expectedPayment.Amount) + assert.equal(sponsorSignedTx.Fee, expectedPayment.Fee) + assert.equal(sponsorSignedTx.Sponsor, expectedPayment.Sponsor) + assert.equal(sponsorSignedTx.SponsorFlags, expectedPayment.SponsorFlags) + assert.equal(sponsorSignedTx.SigningPubKey, expectedPayment.SigningPubKey) + assert.equal(sponsorSignedTx.TxnSignature, expectedPayment.TxnSignature) + + // Verify SponsorSignature exists with correct structure + assert.exists(sponsorSignedTx.SponsorSignature) + const sponsorSig = sponsorSignedTx.SponsorSignature as { + SigningPubKey: string + TxnSignature: string + } + assert.equal( + sponsorSig.SigningPubKey, + expectedPayment.SponsorSignature.SigningPubKey, + ) + // TxnSignature is 128 hex chars for Ed25519 (64 bytes) + assert.equal(sponsorSig.TxnSignature.length, 128) + assert.match(sponsorSig.TxnSignature, /^[0-9A-F]+$/u) + }) + + it('multi sign', function () { + const signerWallet1 = Wallet.fromSeed('sEdSyBUScyy9msTU36wdR68XkskQky5') + const signerWallet2 = Wallet.fromSeed('sEdT8LubWzQv3VAx1JQqctv78N28zLA') + + const signedPayment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + Sponsor: 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un', + SponsorFlags: 1, + SigningPubKey: + 'EDFF8D8C5AC309EAA4F3A0C6D2AAF9A9DFA0724063398110365D4631971F604C4C', + TxnSignature: + '1AF5B3118F5F292EDCEAB34A4180792240AF86258C6BC8340D7523D396424F63B4BD4EAF20DE7C5AA9B472DB86AC36E956DAD02288638E59D90C7A0F6BF6E802', + } + + const expectedMultiSignedPayment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + Sponsor: 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un', + SponsorFlags: 1, + SigningPubKey: + 'EDFF8D8C5AC309EAA4F3A0C6D2AAF9A9DFA0724063398110365D4631971F604C4C', + TxnSignature: + '1AF5B3118F5F292EDCEAB34A4180792240AF86258C6BC8340D7523D396424F63B4BD4EAF20DE7C5AA9B472DB86AC36E956DAD02288638E59D90C7A0F6BF6E802', + SponsorSignature: { + Signers: [ + { + Signer: { + Account: 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un', + SigningPubKey: + 'EDD184F5FE58EC1375AB1CF17A3C5A12A8DEE89DD5228772D69E28EE37438FE59E', + TxnSignature: + 'CEC3A0F14AC5E9E9984F9E8B07182DBC783BC6F0F3D7AC0DF24B974AF1F302AEBB0583A4DF410BFC50E1E01A69731737C95D6BC0D7F2226492A888F026275E08', + }, + }, + { + Signer: { + Account: 'rKQhhSnRXJyqDq5BFtWG2E6zxAdq6wDyQC', + SigningPubKey: + 'ED121AF03981F6496E47854955F65FC8763232D74EBF73877889514137BB72720A', + TxnSignature: + 'F1F1E791B6C89631C13BC2605CF0EA0983612F13956F90958C00F922AEB69236650D560EEA14A40AD15108D05C5FBCB11570745C239EF9C7DB7548A5F9204107', + }, + }, + ], + }, + } + + // Test error: no transactions to combine + assert.throws(() => { + combineSponsorSigners([]) + }, 'There are 0 transactions to combine.') + + // Sign with both wallets using multisign + const { tx: tx1 } = signAsSponsor(signerWallet1, signedPayment as Payment, { + multisign: true, + }) + const { tx: tx2 } = signAsSponsor(signerWallet2, signedPayment as Payment, { + multisign: true, + }) + + // Test error: transaction not signed by account + assert.throws(() => { + combineSponsorSigners([ + { + ...tx1, + SigningPubKey: undefined, + TxnSignature: undefined, + } as Payment, + ]) + }, 'Transaction must be first signed by the account.') + + // Test error: missing Signers in SponsorSignature + assert.throws(() => { + combineSponsorSigners([ + { + ...tx1, + SponsorSignature: { + SigningPubKey: + 'EDD184F5FE58EC1375AB1CF17A3C5A12A8DEE89DD5228772D69E28EE37438FE59E', + TxnSignature: + 'CEC3A0F14AC5E9E9984F9E8B07182DBC783BC6F0F3D7AC0DF24B974AF1F302AEBB0583A4DF410BFC50E1E01A69731737C95D6BC0D7F2226492A888F026275E08', + }, + } as Payment, + ]) + }, 'SponsorSignature must have Signers.') + + // Test error: transactions are not the same + assert.throws(() => { + combineSponsorSigners([ + tx1 as Payment, + { + ...tx2, + Amount: '6000000', + } as Payment, + ]) + }, 'Sponsor transactions are not the same.') + + // Test successful combination + const { tx: combinedTx } = combineSponsorSigners([ + tx1 as Payment, + tx2 as Payment, + ]) + + // Verify structure (signatures are non-deterministic) + assert.equal( + combinedTx.TransactionType, + expectedMultiSignedPayment.TransactionType, + ) + assert.equal(combinedTx.Account, expectedMultiSignedPayment.Account) + assert.equal(combinedTx.Destination, expectedMultiSignedPayment.Destination) + assert.equal(combinedTx.Amount, expectedMultiSignedPayment.Amount) + assert.equal(combinedTx.Fee, expectedMultiSignedPayment.Fee) + assert.equal(combinedTx.Sponsor, expectedMultiSignedPayment.Sponsor) + assert.equal( + combinedTx.SponsorFlags, + expectedMultiSignedPayment.SponsorFlags, + ) + assert.equal( + combinedTx.SigningPubKey, + expectedMultiSignedPayment.SigningPubKey, + ) + assert.equal( + combinedTx.TxnSignature, + expectedMultiSignedPayment.TxnSignature, + ) + + // Verify SponsorSignature has Signers array with correct structure + assert.exists(combinedTx.SponsorSignature) + const sponsorSig = combinedTx.SponsorSignature as { + Signers: Array<{ + Signer: { Account: string; SigningPubKey: string; TxnSignature: string } + }> + } + assert.isArray(sponsorSig.Signers) + assert.equal(sponsorSig.Signers.length, 2) + + // Verify each signer has correct structure and accounts + const expectedSigners = + expectedMultiSignedPayment.SponsorSignature.Signers.map( + (signerEntry) => signerEntry.Signer.Account, + ) + const actualSigners = sponsorSig.Signers.map( + (signerEntry) => signerEntry.Signer.Account, + ) + assert.sameMembers(actualSigners, expectedSigners) + + // Verify signatures are valid hex strings of correct length + for (const signerEntry of sponsorSig.Signers) { + assert.equal(signerEntry.Signer.TxnSignature.length, 128) + assert.match(signerEntry.Signer.TxnSignature, /^[0-9A-F]+$/u) + } + }) + + describe('addPreFundedSponsor', function () { + it('adds Sponsor and SponsorFlags to transaction successfully', function () { + const payment: Payment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + } + + const sponsorAddress = 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un' + const sponsorFlags = SponsorFlags.tfSponsorFee + + const result = addPreFundedSponsor(payment, sponsorAddress, sponsorFlags) + + assert.equal(result.Sponsor, sponsorAddress) + assert.equal(result.SponsorFlags, sponsorFlags) + // Verify original transaction fields are preserved + assert.equal(result.Account, payment.Account) + assert.equal(result.Destination, payment.Destination) + assert.equal(result.Amount, payment.Amount) + }) + + it('adds SponsorFlags for both fee and reserve', function () { + const payment: Payment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + } + + const sponsorAddress = 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un' + /* eslint-disable no-bitwise -- combining sponsor flags */ + const sponsorFlags = + SponsorFlags.tfSponsorFee | SponsorFlags.tfSponsorReserve + /* eslint-enable no-bitwise */ + + const result = addPreFundedSponsor(payment, sponsorAddress, sponsorFlags) + + assert.equal(result.SponsorFlags, sponsorFlags) + // Combined flags: tfSponsorFee (1) + tfSponsorReserve (2) = 3 + assert.equal(result.SponsorFlags, 3) + }) + + it('throws when Sponsor and Account are the same (self-sponsorship)', function () { + const payment: Payment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + } + + // Same as Account (self-sponsorship test) + const sponsorAddress = payment.Account + const sponsorFlags = SponsorFlags.tfSponsorFee + + assert.throws(() => { + addPreFundedSponsor(payment, sponsorAddress, sponsorFlags) + }, 'addPreFundedSponsor: Sponsor and Account cannot be the same (self-sponsorship not allowed)') + }) + + it('throws when SponsorFlags is 0', function () { + const payment: Payment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + } + + const sponsorAddress = 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un' + const sponsorFlags = 0 + + assert.throws(() => { + addPreFundedSponsor(payment, sponsorAddress, sponsorFlags) + }, 'addPreFundedSponsor: SponsorFlags must have at least one flag set') + }) + + it('does not mutate the original transaction', function () { + const payment: Payment = { + TransactionType: 'Payment', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Destination: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + Amount: '5000000', + Fee: '12', + Sequence: 1, + } + + const originalPayment = { ...payment } + const sponsorAddress = 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un' + const sponsorFlags = SponsorFlags.tfSponsorFee + + addPreFundedSponsor(payment, sponsorAddress, sponsorFlags) + + // Verify original transaction is unchanged + assert.deepEqual(payment, originalPayment) + assert.isUndefined(payment.Sponsor) + assert.isUndefined(payment.SponsorFlags) + }) + + it('works with different transaction types', function () { + const trustSet: TrustSet = { + TransactionType: 'TrustSet', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + LimitAmount: { + currency: 'USD', + issuer: 'rN7n7otQDd6FczFgLdlqtyMVrn3HMfra5e', + value: '100', + }, + Fee: '12', + Sequence: 1, + } + + const sponsorAddress = 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un' + const sponsorFlags = SponsorFlags.tfSponsorReserve + + const result = addPreFundedSponsor(trustSet, sponsorAddress, sponsorFlags) + + assert.equal(result.Sponsor, sponsorAddress) + assert.equal(result.SponsorFlags, SponsorFlags.tfSponsorReserve) + assert.equal(result.TransactionType, 'TrustSet') + }) + }) +}) +/* eslint-enable max-statements */