diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index b6e989e37e..5624ee8014 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -11,28 +11,28 @@ on: push: branches: [main] tags: - - '**' + - "**" pull_request: types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: workflow_call: inputs: git_ref: - description: 'Git ref to checkout (branch, tag, or commit SHA)' + description: "Git ref to checkout (branch, tag, or commit SHA)" required: true type: string run_unit_tests: - description: 'Run unit tests job' + description: "Run unit tests job" required: false type: boolean default: true run_integration_tests: - description: 'Run integration tests job' + description: "Run integration tests job" required: false type: boolean default: true run_browser_tests: - description: 'Run browser tests job' + description: "Run browser tests job" required: false type: boolean default: true @@ -141,7 +141,7 @@ jobs: - name: Run docker in background run: | - docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a" + docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "mkdir -p /var/lib/rippled/db/ && rippled -a" - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 @@ -201,7 +201,7 @@ jobs: - name: Run docker in background run: | - docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "rippled -a" + docker run --detach --rm -p 6006:6006 --volume "${{ github.workspace }}/.ci-config/":"/etc/opt/ripple/" --name rippled-service --health-cmd="rippled server_info || exit 1" --health-interval=5s --health-retries=10 --health-timeout=2s --env GITHUB_ACTIONS=true --env CI=true --entrypoint bash ${{ env.RIPPLED_DOCKER_IMAGE }} -c "mkdir -p /var/lib/rippled/db/ && rippled -a" - name: Setup npm version 10 run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 541d956f6b..6d4917ce73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ From the top-level xrpl.js folder (one level above `packages`), run the followin ```bash npm install # sets up the rippled standalone Docker container - you can skip this step if you already have it set up -docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:develop -c 'rippled -a' +docker run -p 6006:6006 --rm -it --name rippled_standalone --volume $PWD/.ci-config:/etc/opt/ripple/ --entrypoint bash rippleci/rippled:develop -c 'mkdir -p /var/lib/rippled/db/ && rippled -a' npm run build npm run test:integration ``` diff --git a/packages/ripple-binary-codec/HISTORY.md b/packages/ripple-binary-codec/HISTORY.md index 6a1a7352aa..15884b6291 100644 --- a/packages/ripple-binary-codec/HISTORY.md +++ b/packages/ripple-binary-codec/HISTORY.md @@ -2,6 +2,9 @@ ## Unreleased +### Fixed +* Fix STNumber serialization logic to work with large mantissa scale [10^18, 10^19-1]. + ## 2.6.0 (2025-12-16) ### Added diff --git a/packages/ripple-binary-codec/src/enums/definitions.json b/packages/ripple-binary-codec/src/enums/definitions.json index 4bbdfe402c..ccd7b7990f 100644 --- a/packages/ripple-binary-codec/src/enums/definitions.json +++ b/packages/ripple-binary-codec/src/enums/definitions.json @@ -731,7 +731,7 @@ } ], [ - "PreviousPaymentDate", + "PreviousPaymentDueDate", { "isSerialized": true, "isSigningField": true, @@ -3549,6 +3549,7 @@ "tecXCHAIN_SELF_COMMIT": 184, "tecXCHAIN_SENDING_ACCOUNT_MISMATCH": 179, "tecXCHAIN_WRONG_CHAIN": 176, + "tefALREADY": -198, "tefBAD_ADD_AUTH": -197, "tefBAD_AUTH": -196, @@ -3571,6 +3572,7 @@ "tefPAST_SEQ": -190, "tefTOO_BIG": -181, "tefWRONG_PRIOR": -189, + "telBAD_DOMAIN": -398, "telBAD_PATH_COUNT": -397, "telBAD_PUBLIC_KEY": -396, @@ -3588,6 +3590,7 @@ "telNO_DST_PARTIAL": -393, "telREQUIRES_NETWORK_ID": -385, "telWRONG_NETWORK": -386, + "temARRAY_EMPTY": -253, "temARRAY_TOO_LARGE": -252, "temBAD_AMM_TOKENS": -261, @@ -3638,6 +3641,7 @@ "temXCHAIN_BRIDGE_BAD_REWARD_AMOUNT": -255, "temXCHAIN_BRIDGE_NONDOOR_OWNER": -257, "temXCHAIN_EQUAL_DOOR_ACCOUNTS": -260, + "terADDRESS_COLLISION": -86, "terFUNDS_SPENT": -98, "terINSUF_FEE_B": -97, @@ -3653,6 +3657,7 @@ "terPRE_TICKET": -88, "terQUEUED": -89, "terRETRY": -99, + "tesSUCCESS": 0 }, "TRANSACTION_TYPES": { diff --git a/packages/ripple-binary-codec/src/types/st-number.ts b/packages/ripple-binary-codec/src/types/st-number.ts index 6fadffc41f..34f13c4173 100644 --- a/packages/ripple-binary-codec/src/types/st-number.ts +++ b/packages/ripple-binary-codec/src/types/st-number.ts @@ -1,3 +1,4 @@ +/* eslint-disable complexity -- required for various checks */ import { BinaryParser } from '../serdes/binary-parser' import { SerializedType } from './serialized-type' import { writeInt32BE, writeInt64BE, readInt32BE, readInt64BE } from '../utils' @@ -6,8 +7,8 @@ import { writeInt32BE, writeInt64BE, readInt32BE, readInt64BE } from '../utils' * Constants for mantissa and exponent normalization per XRPL Number spec. * These define allowed magnitude for mantissa and exponent after normalization. */ -const MIN_MANTISSA = BigInt('1000000000000000') -const MAX_MANTISSA = BigInt('9999999999999999') +const MIN_MANTISSA = BigInt('1000000000000000000') // 10^18 +const MAX_INT64 = BigInt('9223372036854775807') // 2^63 - 1, max signed 64-bit integer const MIN_EXPONENT = -32768 const MAX_EXPONENT = 32768 const DEFAULT_VALUE_EXPONENT = -2147483648 @@ -62,6 +63,12 @@ function extractNumberPartsFromString(val: string): { } if (expPart) exponent += parseInt(expPart, 10) + // Remove trailing zeros from mantissa and adjust exponent + while (mantissaStr.length > 1 && mantissaStr.endsWith('0')) { + mantissaStr = mantissaStr.slice(0, -1) + exponent += 1 + } + let mantissa = BigInt(mantissaStr) if (sign === '-') mantissa = -mantissa const isNegative = mantissa < BigInt(0) @@ -72,7 +79,7 @@ function extractNumberPartsFromString(val: string): { /** * Normalize the mantissa and exponent to XRPL constraints. * - * Ensures that after normalization, the mantissa is between MIN_MANTISSA and MAX_MANTISSA (unless zero). + * Ensures that after normalization, the mantissa is between MIN_MANTISSA and MAX_INT64. * Adjusts the exponent as needed by shifting the mantissa left/right (multiplying/dividing by 10). * * @param mantissa - The unnormalized mantissa (BigInt). @@ -87,16 +94,41 @@ function normalize( let m = mantissa < BigInt(0) ? -mantissa : mantissa const isNegative = mantissa < BigInt(0) - while (m !== BigInt(0) && m < MIN_MANTISSA && exponent > MIN_EXPONENT) { + if (m > MAX_INT64) { + throw new Error('Mantissa overflow: value too large to represent') + } + + // Handle zero + if (m === BigInt(0)) { + return { mantissa: BigInt(0), exponent: DEFAULT_VALUE_EXPONENT } + } + + // Grow mantissa until it reaches MIN_MANTISSA + while (m < MIN_MANTISSA && exponent > MIN_EXPONENT) { exponent -= 1 m *= BigInt(10) } - while (m > MAX_MANTISSA) { - if (exponent >= MAX_EXPONENT) - throw new Error('Mantissa and exponent are too large') + + // Handle underflow: if exponent too small or mantissa too small, throw error + if (exponent < MIN_EXPONENT || m < MIN_MANTISSA) { + throw new Error('Underflow: value too small to represent') + } + + // Handle overflow: if exponent exceeds MAX_EXPONENT after growing. + if (exponent > MAX_EXPONENT) { + throw new Error('Exponent overflow: value too large to represent') + } + + // Handle overflow: if mantissa exceeds MAX_INT64 (2^63-1) after growing. + // Rounding is not required because last digit is always 0 after growing. + if (m > MAX_INT64) { + if (exponent >= MAX_EXPONENT) { + throw new Error('Exponent overflow: value too large to represent') + } exponent += 1 m /= BigInt(10) } + if (isNegative) m = -m return { mantissa: m, exponent } } @@ -159,17 +191,9 @@ export class STNumber extends SerializedType { * @throws Error if val is not a valid number string. */ static fromValue(val: string): STNumber { - const { mantissa, exponent, isNegative } = extractNumberPartsFromString(val) - let normalizedMantissa: bigint - let normalizedExponent: number - - if (mantissa === BigInt(0) && exponent === 0 && !isNegative) { - normalizedMantissa = BigInt(0) - normalizedExponent = DEFAULT_VALUE_EXPONENT - } else { - ;({ mantissa: normalizedMantissa, exponent: normalizedExponent } = - normalize(mantissa, exponent)) - } + const { mantissa, exponent } = extractNumberPartsFromString(val) + const { mantissa: normalizedMantissa, exponent: normalizedExponent } = + normalize(mantissa, exponent) const bytes = new Uint8Array(12) writeInt64BE(bytes, normalizedMantissa, 0) @@ -193,7 +217,6 @@ export class STNumber extends SerializedType { * * @returns String representation of the value */ - // eslint-disable-next-line complexity -- required toJSON(): string { const b = this.bytes if (!b || b?.length !== 12) @@ -202,30 +225,58 @@ export class STNumber extends SerializedType { // Signed 64-bit mantissa const mantissa = readInt64BE(b, 0) // Signed 32-bit exponent - const exponent = readInt32BE(b, 8) + let exponent = readInt32BE(b, 8) // Special zero: XRPL encodes canonical zero as mantissa=0, exponent=DEFAULT_VALUE_EXPONENT. if (mantissa === BigInt(0) && exponent === DEFAULT_VALUE_EXPONENT) { return '0' } - if (exponent === 0) return mantissa.toString() - // Use scientific notation for small/large exponents, decimal otherwise - if (exponent < -25 || exponent > -5) { - return `${mantissa}e${exponent}` + const isNegative = mantissa < BigInt(0) + let mantissaAbs = isNegative ? -mantissa : mantissa + + // If mantissa < MIN_MANTISSA, it was shrunk for int64 serialization (mantissa > 2^63-1). + // Restore it for proper string rendering to match rippled's internal representation. + if (mantissaAbs !== BigInt(0) && mantissaAbs < MIN_MANTISSA) { + mantissaAbs *= BigInt(10) + exponent -= 1 } - // Decimal rendering for -25 <= exp <= -5 - const isNegative = mantissa < BigInt(0) - const mantissaAbs = mantissa < BigInt(0) ? -mantissa : mantissa + // For large mantissa range (default), rangeLog = 18 + const rangeLog = 18 + + // Use scientific notation for exponents that are too small or too large + // Condition from rippled: exponent != 0 AND (exponent < -(rangeLog + 10) OR exponent > -(rangeLog - 10)) + // For rangeLog = 18: exponent != 0 AND (exponent < -28 OR exponent > -8) + if ( + exponent !== 0 && + (exponent < -(rangeLog + 10) || exponent > -(rangeLog - 10)) + ) { + // Strip trailing zeros from mantissa (matches rippled behavior) + let exp = exponent + while ( + mantissaAbs !== BigInt(0) && + mantissaAbs % BigInt(10) === BigInt(0) && + exp < MAX_EXPONENT + ) { + mantissaAbs /= BigInt(10) + exp += 1 + } + const sign = isNegative ? '-' : '' + return `${sign}${mantissaAbs}e${exp}` + } + + // Decimal rendering for -(rangeLog + 10) <= exponent <= -(rangeLog - 10) + // i.e., -28 <= exponent <= -8, or exponent == 0 + const padPrefix = rangeLog + 12 // 30 + const padSuffix = rangeLog + 8 // 26 - const padPrefix = 27 - const padSuffix = 23 const mantissaStr = mantissaAbs.toString() const rawValue = '0'.repeat(padPrefix) + mantissaStr + '0'.repeat(padSuffix) - const OFFSET = exponent + 43 - const integerPart = rawValue.slice(0, OFFSET).replace(/^0+/, '') || '0' - const fractionPart = rawValue.slice(OFFSET).replace(/0+$/, '') + const offset = exponent + padPrefix + rangeLog + 1 // exponent + 49 + + const integerPart = rawValue.slice(0, offset).replace(/^0+/, '') || '0' + const fractionPart = rawValue.slice(offset).replace(/0+$/, '') return `${isNegative ? '-' : ''}${integerPart}${ fractionPart ? '.' + fractionPart : '' diff --git a/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json b/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json index 842aae4bee..d6ab8a72cd 100644 --- a/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json +++ b/packages/ripple-binary-codec/test/fixtures/codec-fixtures.json @@ -5127,6 +5127,60 @@ "TransactionType": "VaultCreate", "TxnSignature": "A7C6C1EE9989F9F195A02BEA4DCFEBB887E4CA1F4D30083C84616E0FD1BCA4F4C1B84A6DA26A44B94FBBDA67FB603C78995361DEAF8120093959C639E9255702" } + }, + { + "binary": "120041240000001A6840000000009896807321ED5E83997E2D57EF5C32DFF74AAA836E5C149CA9E49711D220289E610975560EC474401BCB501B08FAA564E4DB82843E77024585827EC1C3FEB784F656215DC3B79C90CE0B387B33661219B074E8CCF965CF2824A92B79FFB9C4A9F988F7AB19B45502701B0E7661756C74206D65746164617461701E0E7368617265206D657461646174618114B67FFA466543506DF81A26FBA98CFB604DE5FE44930DE0B6B3A763FF9C0000004E00101401031800000000000000000000000055534400000000002B13011144920AF528764094A05ADFE3C3DD58B7", + "json": { + "TransactionType": "VaultCreate", + "Sequence": 26, + "Fee": "10000000", + "SigningPubKey": "ED5E83997E2D57EF5C32DFF74AAA836E5C149CA9E49711D220289E610975560EC4", + "TxnSignature": "1BCB501B08FAA564E4DB82843E77024585827EC1C3FEB784F656215DC3B79C90CE0B387B33661219B074E8CCF965CF2824A92B79FFB9C4A9F988F7AB19B45502", + "Data": "7661756C74206D65746164617461", + "MPTokenMetadata": "7368617265206D65746164617461", + "Account": "rHdyHxQMCKyoUFeR1GW7F6pt4AR7r6D4b4", + "AssetsMaximum": "9999999999999999e80", + "WithdrawalPolicy": 1, + "Asset": { + "currency": "USD", + "issuer": "rhvkFgXoWfrDJJHdV3hKTcqXQk7ChXtf18" + } + } + }, + { + "binary": "120041240000002F6840000000009896807321ED65C3F14697756CF0952CE5B00E2374E4D91C7B74687B19C451317B67D52C3B4F7440733B3F7C63271A62E55335D4BB62448ED459ACF7BE03D624844F696406DB84A2D3FDF638F16DB4A3B6CB9BD463756003F795AA26AFCA9EB5BA53FF1ADFC4E50F701B0E7661756C74206D65746164617461701E0E7368617265206D65746164617461811499EBF31121DE67CD1015F13328E18EE2490A139D931122D7D8F56AFD68FFFFFFF500101401031800000000000000000000000055534400000000002FD289D349D05565C1D43CF5D694EECB5D8C3FF0", + "json": { + "TransactionType": "VaultCreate", + "Sequence": 47, + "Fee": "10000000", + "SigningPubKey": "ED65C3F14697756CF0952CE5B00E2374E4D91C7B74687B19C451317B67D52C3B4F", + "TxnSignature": "733B3F7C63271A62E55335D4BB62448ED459ACF7BE03D624844F696406DB84A2D3FDF638F16DB4A3B6CB9BD463756003F795AA26AFCA9EB5BA53FF1ADFC4E50F", + "Data": "7661756C74206D65746164617461", + "MPTokenMetadata": "7368617265206D65746164617461", + "Account": "rEp1sQdJbwDKmhMWmdJgx9ywnoGGr4oxKu", + "AssetsMaximum": "12347865.746832746", + "WithdrawalPolicy": 1, + "Asset": { + "currency": "USD", + "issuer": "rnMiy78qjm8KwantFnrfouvMGqzCR7apap" + } + } + }, + { + "binary": "12004124000001F36840000000004C4B407321EDAFD7A83CD7A09CF126100882728F9FA790B6CC2A29099F274B2B7F614BFF143C7440FA4C253A28EC949593E50C7A29061EA2F44F9E9BD64267DE8932C82C93658EC0224406E12A104B1FA448E0095434A32AE845883FB1404513FF9D57E58CEE8E0881149C1A8144EE13A6B71398C54632E94D4D523BB20A930DBD2FC137A300000000000403180000000000000000000000005553440000000000473C02A2B1C2E8D35A57EE0968ECCF07B9AA291A", + "json": { + "TransactionType": "VaultCreate", + "Sequence": 499, + "Fee": "5000000", + "SigningPubKey": "EDAFD7A83CD7A09CF126100882728F9FA790B6CC2A29099F274B2B7F614BFF143C", + "TxnSignature": "FA4C253A28EC949593E50C7A29061EA2F44F9E9BD64267DE8932C82C93658EC0224406E12A104B1FA448E0095434A32AE845883FB1404513FF9D57E58CEE8E08", + "Account": "rENQwXJhz9kPQ6EZErfV2UV49EtpF43yLp", + "AssetsMaximum": "99e20", + "Asset": { + "currency": "USD", + "issuer": "rfVe1DnuC7nuvGWTAFzBxaTxroEiD8XZ3w" + } + } } ], "ledgerData": [ diff --git a/packages/ripple-binary-codec/test/signing-data-encoding.test.ts b/packages/ripple-binary-codec/test/signing-data-encoding.test.ts index 1a914f6437..a90127fc65 100644 --- a/packages/ripple-binary-codec/test/signing-data-encoding.test.ts +++ b/packages/ripple-binary-codec/test/signing-data-encoding.test.ts @@ -228,7 +228,7 @@ describe('Signing data', function () { 'B32A0D322D38281C81D4F49DCCDC260A81879B57', // PrincipalRequested '9E', - '00038D7EA4C68000FFFFFFF6', + '0DE0B6B3A7640000FFFFFFF3', // signingAccount suffix 'BF9B4C3302798C111649BFA38DB60525C6E1021C', ].join(''), diff --git a/packages/ripple-binary-codec/test/st-number.test.ts b/packages/ripple-binary-codec/test/st-number.test.ts index 08e050949e..8764f6776b 100644 --- a/packages/ripple-binary-codec/test/st-number.test.ts +++ b/packages/ripple-binary-codec/test/st-number.test.ts @@ -4,21 +4,58 @@ import { coreTypes } from '../src/types' const { Number: STNumber } = coreTypes describe('STNumber', () => { - it('should encode and decode integers', () => { - const value = '9876543210' + it('+ve normal value', () => { + const value = '99' const sn = STNumber.from(value) expect(sn.toJSON()).toEqual(value) }) - it('roundtrip integer', () => { - const value = '123456789' - const num = STNumber.from(value) - expect(num.toJSON()).toEqual('123456789') + + // scientific notation triggers in when abs(value) >= 10^11 + it('+ve very large value', () => { + const value = '100000000000' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('1e11') }) - it('roundtrip negative integer', () => { - const value = '-987654321' - const num = STNumber.from(value) - expect(num.toJSON()).toEqual('-987654321') + // scientific notation triggers in when abs(value) >= 10^11 + it('+ve large value', () => { + const value = '10000000000' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('10000000000') + }) + + it('-ve normal value', () => { + const value = '-123' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual(value) + }) + + // scientific notation triggers in when abs(value) >= 10^11 + it('-ve very large value', () => { + const value = '-100000000000' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('-1e11') + }) + + // scientific notation triggers in when abs(value) >= 10^11 + it('-ve large value', () => { + const value = '-10000000000' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('-10000000000') + }) + + // scientific notation triggers in when abs(value) < 10^-10 + it('+ve very small value', () => { + const value = '0.00000000001' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('1e-11') + }) + + // scientific notation triggers in when abs(value) < 10^-10 + it('+ve small value', () => { + const value = '0.0001' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('0.0001') }) it('roundtrip zero', () => { @@ -36,26 +73,27 @@ describe('STNumber', () => { it('roundtrip scientific notation positive', () => { const value = '1.23e5' const num = STNumber.from(value) - // NOTE: The codec always outputs the normalized value as a decimal string, - // not necessarily in scientific notation. Only exponents < -25 or > -5 - // will use scientific notation. So "1.23e5" becomes "123000". + // scientific notation triggers in when abs(value) >= 10^11 expect(num.toJSON()).toEqual('123000') }) it('roundtrip scientific notation negative', () => { const value = '-4.56e-7' const num = STNumber.from(value) - // NOTE: The output is the normalized decimal form of the value. - // "-4.56e-7" becomes "-0.000000456" as per XRPL codec behavior. + // scientific notation triggers in when abs(value) < 10^-10 expect(num.toJSON()).toEqual('-0.000000456') }) - it('roundtrip large exponent', () => { - const value = '7.89e+20' - const num = STNumber.from(value) - // NOTE: The XRPL codec will output this in normalized scientific form, - // as mantissa=7890000000000000, exponent=5, so the output is '7890000000000000e5'. - expect(num.toJSON()).toEqual('7890000000000000e5') + it('-ve normal value', () => { + const value = '-987654321' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('-987654321') + }) + + it('+ve normal value', () => { + const value = '987654321' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('987654321') }) it('roundtrip via parser', () => { @@ -98,6 +136,60 @@ describe('STNumber', () => { expect(num.toJSON()).toEqual('-120') }) + it('decimal without exponent', () => { + const value = '0.5' + const num = STNumber.from(value) + const parser = new BinaryParser(num.toHex()) + const parsedNum = STNumber.fromParser(parser) + expect(parsedNum.toJSON()).toEqual('0.5') + }) + + it('throws error on mantissa overflow', () => { + const value = '9223372036854775895' + expect(() => { + STNumber.from(value) + }).toThrow(new Error('Mantissa overflow: value too large to represent')) + }) + + it('small value with trailing zeros', () => { + const value = '0.002500' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('0.0025') + }) + + it('large value with trailing zeros', () => { + const value = '9900000000000000000000' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('99e20') + }) + + it('small value with leading zeros', () => { + const value = '0.0000000000000000000099' + const sn = STNumber.from(value) + expect(sn.toJSON()).toEqual('99e-22') + }) + + it('mantissa overflow', () => { + expect(() => { + STNumber.from('9999999999999999999999999999999999999999999999') + }).toThrow(new Error('Mantissa overflow: value too large to represent')) + }) + + it('throws on exponent overflow (value too large)', () => { + // 1e40000 has exponent 40000, after normalization exponent = 40000 - 18 = 39982 + // which exceeds MAX_EXPONENT (32768) + expect(() => { + STNumber.from('1e40000') + }).toThrow(new Error('Exponent overflow: value too large to represent')) + }) + + it('underflow returns zero (value too small)', () => { + // 1e-40000 has exponent -40000, which is less than MIN_EXPONENT (-32768) + expect(() => { + STNumber.from('1e-40000') + }).toThrow(new Error('Underflow: value too small to represent')) + }) + it('throws with invalid input (non-number string)', () => { expect(() => { STNumber.from('abc123') diff --git a/packages/xrpl/HISTORY.md b/packages/xrpl/HISTORY.md index 89d63685b5..93ec06dca8 100644 --- a/packages/xrpl/HISTORY.md +++ b/packages/xrpl/HISTORY.md @@ -6,6 +6,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr ### Added * Add `faucetProtocol` (http or https) option to `fundWallet` method. Makes `fundWallet` work with locally running faucet servers. +* Add `signLoanSetByCounterparty` and `combineLoanSetCounterpartySigners` helper functions to sign and combine LoanSet transactions signed by the counterparty. ## 4.5.0 (2025-12-16) diff --git a/packages/xrpl/src/Wallet/counterpartySigner.ts b/packages/xrpl/src/Wallet/counterpartySigner.ts new file mode 100644 index 0000000000..3226a5bd14 --- /dev/null +++ b/packages/xrpl/src/Wallet/counterpartySigner.ts @@ -0,0 +1,213 @@ +import stringify from 'fast-json-stable-stringify' +import { encode } from 'ripple-binary-codec' + +import { ValidationError } from '../errors' +import { LoanSet, Signer, Transaction, validate } from '../models' +import { hashSignedTx } from '../utils/hashes' + +import { + compareSigners, + computeSignature, + getDecodedTransaction, +} from './utils' + +import type { Wallet } from '.' + +/** + * Signs a LoanSet transaction as the counterparty. + * + * This function adds a counterparty signature to a LoanSet transaction that has already been + * signed by the first party. The counterparty uses their wallet to sign the transaction, + * which is required for multi-party loan agreements on the XRP Ledger. + * + * @param wallet - The counterparty's wallet used for signing the transaction. + * @param transaction - The LoanSet transaction to sign. Can be either: + * - A LoanSet transaction object that has been signed by the first party + * - 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 LoanSet transaction object + * - `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 not a LoanSet transaction + * - The transaction is already signed by the counterparty + * - The transaction has not been signed by the first party yet + * - The transaction fails validation + */ +// eslint-disable-next-line max-lines-per-function -- for extensive validations +export function signLoanSetByCounterparty( + wallet: Wallet, + transaction: LoanSet | string, + opts: { multisign?: boolean | string } = {}, +): { + tx: LoanSet + tx_blob: string + hash: string +} { + const tx = getDecodedTransaction(transaction) + + if (tx.TransactionType !== 'LoanSet') { + throw new ValidationError('Transaction must be a LoanSet transaction.') + } + if (tx.CounterpartySignature) { + throw new ValidationError( + 'Transaction is already signed by the counterparty.', + ) + } + if (tx.TxnSignature == null || tx.SigningPubKey == null) { + throw new ValidationError( + 'Transaction must be first signed by first party.', + ) + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type + validate(tx as unknown as Record) + + let multisignAddress: boolean | string = false + if (typeof opts.multisign === 'string') { + multisignAddress = opts.multisign + } else if (opts.multisign) { + multisignAddress = wallet.classicAddress + } + + if (multisignAddress) { + tx.CounterpartySignature = { + Signers: [ + { + Signer: { + Account: multisignAddress, + SigningPubKey: wallet.publicKey, + TxnSignature: computeSignature( + tx, + wallet.privateKey, + multisignAddress, + ), + }, + }, + ], + } + } else { + tx.CounterpartySignature = { + SigningPubKey: wallet.publicKey, + TxnSignature: computeSignature(tx, wallet.privateKey), + } + } + + const serialized = encode(tx) + return { + tx, + tx_blob: serialized, + hash: hashSignedTx(serialized), + } +} + +/** + * Combines multiple LoanSet transactions signed by the counterparty into a single transaction. + * + * @param transactions - An array of signed LoanSet transactions (in object or blob form) to combine. + * @returns An object containing: + * - `tx`: The combined LoanSet 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 are not LoanSet transactions + * - Any of the transactions do not have Signers + * - Any of the transactions do not have a first party signature + */ +export function combineLoanSetCounterpartySigners( + transactions: Array, +): { + tx: LoanSet + 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.TransactionType !== 'LoanSet') { + throw new ValidationError('Transaction must be a LoanSet transaction.') + } + + if ( + tx.CounterpartySignature?.Signers == null || + tx.CounterpartySignature.Signers.length === 0 + ) { + throw new ValidationError('CounterpartySignature must have Signers.') + } + + if (tx.TxnSignature == null || tx.SigningPubKey == null) { + throw new ValidationError( + 'Transaction must be first signed by first party.', + ) + } + }) + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- checked above + const loanSetTransactions = decodedTransactions as LoanSet[] + + validateLoanSetTransactionEquivalence(loanSetTransactions) + + const tx = + getTransactionWithAllLoanSetCounterpartySigners(loanSetTransactions) + + return { + tx, + tx_blob: encode(tx), + } +} + +function validateLoanSetTransactionEquivalence(transactions: LoanSet[]): void { + const exampleTransaction = stringify({ + ...transactions[0], + CounterpartySignature: { + ...transactions[0].CounterpartySignature, + Signers: null, + }, + }) + + if ( + transactions.slice(1).some( + (tx) => + stringify({ + ...tx, + CounterpartySignature: { + ...tx.CounterpartySignature, + Signers: null, + }, + }) !== exampleTransaction, + ) + ) { + throw new ValidationError('LoanSet transactions are not the same.') + } +} + +function getTransactionWithAllLoanSetCounterpartySigners( + transactions: LoanSet[], +): LoanSet { + // Signers must be sorted in the combined transaction - See compareSigners' documentation for more details + const sortedSigners: Signer[] = transactions + .flatMap((tx) => tx.CounterpartySignature?.Signers ?? []) + .sort((signer1, signer2) => compareSigners(signer1.Signer, signer2.Signer)) + + return { + ...transactions[0], + CounterpartySignature: { Signers: sortedSigners }, + } +} diff --git a/packages/xrpl/src/Wallet/index.ts b/packages/xrpl/src/Wallet/index.ts index 9efaa033a2..7f59dac9b0 100644 --- a/packages/xrpl/src/Wallet/index.ts +++ b/packages/xrpl/src/Wallet/index.ts @@ -3,23 +3,9 @@ import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39' import { wordlist } from '@scure/bip39/wordlists/english' import { bytesToHex } from '@xrplf/isomorphic/utils' import BigNumber from 'bignumber.js' -import { - classicAddressToXAddress, - isValidXAddress, - xAddressToClassicAddress, - encodeSeed, -} from 'ripple-address-codec' -import { - encodeForSigning, - encodeForMultisigning, - encode, -} from 'ripple-binary-codec' -import { - deriveAddress, - deriveKeypair, - generateSeed, - sign, -} from 'ripple-keypairs' +import { classicAddressToXAddress, encodeSeed } from 'ripple-address-codec' +import { encode } from 'ripple-binary-codec' +import { deriveAddress, deriveKeypair, generateSeed } from 'ripple-keypairs' import ECDSA from '../ECDSA' import { ValidationError } from '../errors' @@ -32,6 +18,7 @@ import { hashSignedTx } from '../utils/hashes/hashLedger' import { rfc1751MnemonicToKey } from './rfc1751' import { verifySignature } from './signer' +import { computeSignature } from './utils' const DEFAULT_ALGORITHM: ECDSA = ECDSA.ed25519 const DEFAULT_DERIVATION_PATH = "m/44'/144'/0'/0/0" @@ -467,30 +454,6 @@ export class Wallet { } } -/** - * Signs a transaction with the proper signing encoding. - * - * @param tx - A transaction to sign. - * @param privateKey - A key to sign the transaction with. - * @param signAs - Multisign only. An account address to include in the Signer field. - * Can be either a classic address or an XAddress. - * @returns A signed transaction in the proper format. - */ -function computeSignature( - tx: Transaction, - privateKey: string, - signAs?: string, -): string { - if (signAs) { - const classicAddress = isValidXAddress(signAs) - ? xAddressToClassicAddress(signAs).classicAddress - : signAs - - return sign(encodeForMultisigning(tx, classicAddress), privateKey) - } - return sign(encodeForSigning(tx), privateKey) -} - /** * Remove trailing insignificant zeros for non-XRP Payment amount. * This resolves the serialization mismatch bug when encoding/decoding a non-XRP Payment transaction @@ -518,3 +481,8 @@ export { signMultiBatch, combineBatchSigners } from './batchSigner' export { multisign, verifySignature } from './signer' export { authorizeChannel } from './authorizeChannel' + +export { + signLoanSetByCounterparty, + combineLoanSetCounterpartySigners, +} from './counterpartySigner' diff --git a/packages/xrpl/src/Wallet/utils.ts b/packages/xrpl/src/Wallet/utils.ts index 30480eaaad..42ac308d65 100644 --- a/packages/xrpl/src/Wallet/utils.ts +++ b/packages/xrpl/src/Wallet/utils.ts @@ -1,7 +1,17 @@ import { bytesToHex } from '@xrplf/isomorphic/utils' import BigNumber from 'bignumber.js' -import { decodeAccountID } from 'ripple-address-codec' -import { decode, encode } from 'ripple-binary-codec' +import { + decodeAccountID, + isValidXAddress, + xAddressToClassicAddress, +} from 'ripple-address-codec' +import { + decode, + encode, + encodeForMultisigning, + encodeForSigning, +} from 'ripple-binary-codec' +import { sign } from 'ripple-keypairs' import { Transaction } from '../models' @@ -66,3 +76,27 @@ export function getDecodedTransaction( // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We are casting here to get strong typing return decode(txOrBlob) as unknown as Transaction } + +/** + * Signs a transaction with the proper signing encoding. + * + * @param tx - A transaction to sign. + * @param privateKey - A key to sign the transaction with. + * @param signAs - Multisign only. An account address to include in the Signer field. + * Can be either a classic address or an XAddress. + * @returns A signed transaction in the proper format. + */ +export function computeSignature( + tx: Transaction, + privateKey: string, + signAs?: string, +): string { + if (signAs) { + const classicAddress = isValidXAddress(signAs) + ? xAddressToClassicAddress(signAs).classicAddress + : signAs + + return sign(encodeForMultisigning(tx, classicAddress), privateKey) + } + return sign(encodeForSigning(tx), privateKey) +} diff --git a/packages/xrpl/src/models/ledger/Loan.ts b/packages/xrpl/src/models/ledger/Loan.ts index 94a33728c4..4ebaf97e47 100644 --- a/packages/xrpl/src/models/ledger/Loan.ts +++ b/packages/xrpl/src/models/ledger/Loan.ts @@ -105,7 +105,7 @@ export default interface Loan extends BaseLedgerEntry, HasPreviousTxnID { /** * The timestamp of when the previous payment was made in Ripple Epoch. */ - PreviousPaymentDate?: number + PreviousPaymentDueDate?: number /** * The timestamp of when the next payment is due in Ripple Epoch. diff --git a/packages/xrpl/test/integration/transactions/ammBid.test.ts b/packages/xrpl/test/integration/transactions/ammBid.test.ts index 8f0dd34b0a..8284140405 100644 --- a/packages/xrpl/test/integration/transactions/ammBid.test.ts +++ b/packages/xrpl/test/integration/transactions/ammBid.test.ts @@ -58,7 +58,7 @@ describe('AMMBid', function () { const afterPriceValue = parseFloat(auction_slot.price.value) const beforePriceValue = parseFloat(preAuctionSlot.price.value) - const diffPriceValue = 0.00268319257224121 + const diffPriceValue = 0.002683192572241211 const expectedPriceValue = beforePriceValue + diffPriceValue const afterLPTokenValue = parseFloat(lp_token.value) diff --git a/packages/xrpl/test/integration/transactions/lendingProtocol.test.ts b/packages/xrpl/test/integration/transactions/lendingProtocol.test.ts index b991bcb4a0..61a9d38cb1 100644 --- a/packages/xrpl/test/integration/transactions/lendingProtocol.test.ts +++ b/packages/xrpl/test/integration/transactions/lendingProtocol.test.ts @@ -1,8 +1,6 @@ /* eslint-disable max-statements -- required to test entire flow */ import { assert } from 'chai' -import { decode } from 'ripple-binary-codec' -import { sign } from 'ripple-keypairs/src' import { type MPTokenAuthorize, @@ -23,9 +21,10 @@ import { type LoanPay, verifySignature, type SignerListSet, - encodeForMultiSigning, LoanManageFlags, type MPTAmount, + signLoanSetByCounterparty, + combineLoanSetCounterpartySigners, } from '../../../src' import { LoanFlags, @@ -34,7 +33,6 @@ import { } from '../../../src/models/ledger' import { type MPTokenIssuanceCreateMetadata } from '../../../src/models/transactions/MPTokenIssuanceCreate' import { hashLoan, hashLoanBroker, hashVault } from '../../../src/utils/hashes' -import { compareSigners } from '../../../src/Wallet/utils' import serverUrl from '../serverUrl' import { setupClient, @@ -62,6 +60,131 @@ describe('Lending Protocol IT', () => { await teardownClient(testContext) }, TIMEOUT) + it( + 'LoanSet: with single signing', + async () => { + const vaultOwnerWallet = await generateFundedWallet(testContext.client) + const depositorWallet = await generateFundedWallet(testContext.client) + const borrowerWallet = await generateFundedWallet(testContext.client) + + // The Vault Owner and Loan Broker must be on the same account. + const loanBrokerWallet = vaultOwnerWallet + + const vaultCreateTx: VaultCreate = { + TransactionType: 'VaultCreate', + Asset: { + currency: 'XRP', + }, + Account: vaultOwnerWallet.address, + AssetsMaximum: '1e17', + } + + const vaultCreateResp = await testTransaction( + testContext.client, + vaultCreateTx, + vaultOwnerWallet, + ) + + const vaultObjectId = hashVault( + vaultCreateResp.result.tx_json.Account, + vaultCreateResp.result.tx_json.Sequence as number, + ) + + // Depositor deposits 10 XRP into the vault + const vaultDepositTx: VaultDeposit = { + TransactionType: 'VaultDeposit', + Account: depositorWallet.address, + VaultID: vaultObjectId, + Amount: '10000000', + } + + await testTransaction(testContext.client, vaultDepositTx, depositorWallet) + + // Create LoanBroker ledger object to capture attributes of the Lending Protocol + const loanBrokerSetTx: LoanBrokerSet = { + TransactionType: 'LoanBrokerSet', + Account: loanBrokerWallet.address, + VaultID: vaultObjectId, + DebtMaximum: '25000000', + } + + const loanBrokerTxResp = await testTransaction( + testContext.client, + loanBrokerSetTx, + loanBrokerWallet, + ) + + const loanBrokerObjectId = hashLoanBroker( + loanBrokerTxResp.result.tx_json.Account, + loanBrokerTxResp.result.tx_json.Sequence as number, + ) + + // Assert LoanBroker object exists in objects tracked by Lender. + const loanBrokerObjects = await testContext.client.request({ + command: 'account_objects', + account: loanBrokerWallet.address, + type: 'loan_broker', + }) + + const loanBrokerObject: LoanBroker = + loanBrokerObjects.result.account_objects.find( + (obj) => obj.index === loanBrokerObjectId, + ) as LoanBroker + + assert.equal(loanBrokerObject.index, loanBrokerObjectId) + assert.equal(loanBrokerObject.DebtMaximum, loanBrokerSetTx.DebtMaximum) + + // Broker initiates the Loan. + let loanSetTx: LoanSet = { + TransactionType: 'LoanSet', + Account: loanBrokerWallet.address, + LoanBrokerID: loanBrokerObjectId, + PrincipalRequested: '5000000', + Counterparty: borrowerWallet.address, + PaymentTotal: 1, + } + loanSetTx = await testContext.client.autofill(loanSetTx) + const { tx_blob } = loanBrokerWallet.sign(loanSetTx) + + assert.isTrue(verifySignature(tx_blob)) + + const { tx: borrowerSignedTx } = signLoanSetByCounterparty( + borrowerWallet, + tx_blob, + ) + + await testTransaction( + testContext.client, + borrowerSignedTx, + loanBrokerWallet, + ) + + const loanObjectId = hashLoan( + loanBrokerObjectId, + loanBrokerObject.LoanSequence, + ) + const loanObjects = await testContext.client.request({ + command: 'account_objects', + account: borrowerWallet.address, + type: 'loan', + }) + + const loanObject: Loan = loanObjects.result.account_objects.find( + (obj) => obj.index === loanObjectId, + ) as Loan + + assert.equal(loanObject.index, loanObjectId) + assert.equal( + loanObject.PrincipalOutstanding, + loanSetTx.PrincipalRequested, + ) + assert.equal(loanObject.LoanBrokerID, loanBrokerObject.index) + assert.equal(loanObject.Borrower, borrowerWallet.address) + assert.equal(loanObject.PaymentRemaining, loanSetTx.PaymentTotal) + }, + TIMEOUT, + ) + it( 'Lending protocol integration test with multi-signing', async () => { @@ -203,45 +326,34 @@ describe('Lending Protocol IT', () => { // Loan broker signs the transaction and sends it to the borrower loanSetTx = await testContext.client.autofill(loanSetTx) const { tx_blob } = loanBrokerWallet.sign(loanSetTx) - loanSetTx = decode(tx_blob) as LoanSet // Borrower first verifies the TxnSignature for to make sure that it came from the loan broker. - assert.isTrue(verifySignature(loanSetTx, loanSetTx.SigningPubKey)) + assert.isTrue(verifySignature(tx_blob)) // Borrower signs the transaction and fills in the CounterpartySignature to confirm the // loan terms. - const sign1 = sign( - encodeForMultiSigning(loanSetTx, signer1.address), - signer1.privateKey, - ) - const sign2 = sign( - encodeForMultiSigning(loanSetTx, signer2.address), - signer2.privateKey, + const { tx: signer1SignedTx } = signLoanSetByCounterparty( + signer1, + tx_blob, + { multisign: true }, ) - loanSetTx.CounterpartySignature = {} - loanSetTx.CounterpartySignature.Signers = [] - const signers = [ - { - Signer: { - Account: signer1.address, - SigningPubKey: signer1.publicKey, - TxnSignature: sign1, - }, - }, - { - Signer: { - Account: signer2.address, - SigningPubKey: signer2.publicKey, - TxnSignature: sign2, - }, - }, - ] + const { tx: signer2SignedTx } = signLoanSetByCounterparty( + signer2, + tx_blob, + { multisign: true }, + ) - signers.sort((s1, s2) => compareSigners(s1.Signer, s2.Signer)) - loanSetTx.CounterpartySignature.Signers = signers + const { tx: combinedSignedTx } = combineLoanSetCounterpartySigners([ + signer1SignedTx, + signer2SignedTx, + ]) - await testTransaction(testContext.client, loanSetTx, borrowerWallet) + await testTransaction( + testContext.client, + combinedSignedTx, + borrowerWallet, + ) // Assert Loan object exists in objects tracked by Borrower. const loanObjectId = hashLoan( diff --git a/packages/xrpl/test/integration/transactions/singleAssetVault.test.ts b/packages/xrpl/test/integration/transactions/singleAssetVault.test.ts index 9ce4b65305..7c8f1e8824 100644 --- a/packages/xrpl/test/integration/transactions/singleAssetVault.test.ts +++ b/packages/xrpl/test/integration/transactions/singleAssetVault.test.ts @@ -78,7 +78,7 @@ describe('Single Asset Vault', function () { LimitAmount: { currency: currencyCode, issuer: issuerWallet.classicAddress, - value: '1000', + value: '9999999999999999e80', }, } @@ -91,7 +91,7 @@ describe('Single Asset Vault', function () { Amount: { currency: currencyCode, issuer: issuerWallet.classicAddress, - value: '1000', + value: '9999999999', }, } @@ -109,7 +109,7 @@ describe('Single Asset Vault', function () { VaultWithdrawalPolicy.vaultStrategyFirstComeFirstServe, Data: stringToHex('vault metadata'), MPTokenMetadata: stringToHex('share metadata'), - AssetsMaximum: '500', + AssetsMaximum: '9999900000000000000000000', } await testTransaction(testContext.client, tx, vaultOwnerWallet) @@ -135,7 +135,7 @@ describe('Single Asset Vault', function () { VaultWithdrawalPolicy.vaultStrategyFirstComeFirstServe, ) assert.equal(vault.Data, tx.Data) - assert.equal(assetsMaximum, '500') + assert.equal(assetsMaximum, '99999e20') // --- VaultSet Transaction --- // Increase the AssetsMaximum to 1000 and update Data diff --git a/packages/xrpl/test/wallet/counterpartySigner.test.ts b/packages/xrpl/test/wallet/counterpartySigner.test.ts new file mode 100644 index 0000000000..8f233532d2 --- /dev/null +++ b/packages/xrpl/test/wallet/counterpartySigner.test.ts @@ -0,0 +1,171 @@ +// Add one test for single signing and one test for multi-signing + +import { assert } from 'chai' + +import { LoanSet, Wallet } from '../../src' +import { + combineLoanSetCounterpartySigners, + signLoanSetByCounterparty, +} from '../../src/Wallet/counterpartySigner' + +describe('counterpartySigner', function () { + it('single sign', function () { + const borrowerWallet = Wallet.fromSeed('sEd7FqVHfNZ2UdGAwjssxPev2ujwJoT') + const singedLoanSet = { + TransactionType: 'LoanSet', + Flags: 0, + Sequence: 1702, + LastLedgerSequence: 1725, + PaymentTotal: 1, + LoanBrokerID: + '033D9B59DBDC4F48FB6708892E7DB0E8FBF9710C3A181B99D9FAF7B9C82EF077', + Fee: '480', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Counterparty: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + PrincipalRequested: '5000000', + } + + const expectedLoanSet = { + TransactionType: 'LoanSet', + Flags: 0, + Sequence: 1702, + LastLedgerSequence: 1725, + PaymentTotal: 1, + LoanBrokerID: + '033D9B59DBDC4F48FB6708892E7DB0E8FBF9710C3A181B99D9FAF7B9C82EF077', + Fee: '480', + SigningPubKey: + 'EDFF8D8C5AC309EAA4F3A0C6D2AAF9A9DFA0724063398110365D4631971F604C4C', + TxnSignature: + '1AF5B3118F5F292EDCEAB34A4180792240AF86258C6BC8340D7523D396424F63B4BD4EAF20DE7C5AA9B472DB86AC36E956DAD02288638E59D90C7A0F6BF6E802', + Account: 'rpfK3KEEBwXjUXKQnvAs1SbQhVKu7CSkY1', + Counterparty: 'rp7Tj3Uu1RDrDd1tusge3bVBhUjNvzD19Y', + PrincipalRequested: '5000000', + CounterpartySignature: { + SigningPubKey: + 'ED1139D765C2C8F175153EE663D2CBE574685D5FCF61A6A33DF7AC72C9903D3F94', + TxnSignature: + '440B839B41834A9292B23A8DB547EA34DC89FC8313056C96812384A860848381C4F11867F1092594D3E263DB2433CEB07E2AD312944FF68F2E2EF995ABAE9C05', + }, + } + + assert.throws(() => { + signLoanSetByCounterparty(borrowerWallet, singedLoanSet as LoanSet) + }, 'Transaction must be first signed by first party.') + + assert.throws(() => { + signLoanSetByCounterparty(borrowerWallet, { + ...singedLoanSet, + TransactionType: 'Payment', + } as unknown as LoanSet) + }, 'Transaction must be a LoanSet transaction.') + + assert.throws(() => { + signLoanSetByCounterparty(borrowerWallet, { + ...singedLoanSet, + CounterpartySignature: { + SigningPubKey: '', + TxnSignature: '', + }, + } as LoanSet) + }, 'Transaction is already signed by the counterparty.') + + const { tx: borrowerSignedTx } = signLoanSetByCounterparty(borrowerWallet, { + ...singedLoanSet, + TxnSignature: + '1AF5B3118F5F292EDCEAB34A4180792240AF86258C6BC8340D7523D396424F63B4BD4EAF20DE7C5AA9B472DB86AC36E956DAD02288638E59D90C7A0F6BF6E802', + SigningPubKey: + 'EDFF8D8C5AC309EAA4F3A0C6D2AAF9A9DFA0724063398110365D4631971F604C4C', + } as LoanSet) + + assert.deepEqual(borrowerSignedTx, expectedLoanSet as LoanSet) + }) + + it('multi sign', function () { + const signerWallet1 = Wallet.fromSeed('sEdSyBUScyy9msTU36wdR68XkskQky5') + const signerWallet2 = Wallet.fromSeed('sEdT8LubWzQv3VAx1JQqctv78N28zLA') + + const singedLoanSet = { + TransactionType: 'LoanSet', + Flags: 0, + Sequence: 1807, + LastLedgerSequence: 1838, + PaymentTotal: 1, + InterestRate: 0, + LoanBrokerID: + 'D1902EFBFF8C6536322D48B9F3B974AEC29AC826CF6BEA6218C886581A712AFE', + Fee: '720', + SigningPubKey: + 'EDE7E70883C11FFDEB28A1FEDA20C89352E3FCFEAABFF9EF890A08664E5687ECD2', + TxnSignature: + '0438178AF327FC54C42638A4EDB0EB9A701B2D6192388BE8A4C7A61DD82EA4510D10C0CADAD3D8A7EBC7B08C3F2A50F12F686B47ED2562EE6792434322E94B0E', + Account: 'rpmFCkiUFiufA3HdLagJCWGbzByaQLJKKJ', + Counterparty: 'rQnFUSfgnLNA2KzvKUjRX69tbv7WX76UXW', + PrincipalRequested: '100000', + } + + const expectedLoanSet = { + TransactionType: 'LoanSet', + Flags: 0, + Sequence: 1807, + LastLedgerSequence: 1838, + PaymentTotal: 1, + InterestRate: 0, + LoanBrokerID: + 'D1902EFBFF8C6536322D48B9F3B974AEC29AC826CF6BEA6218C886581A712AFE', + Fee: '720', + SigningPubKey: + 'EDE7E70883C11FFDEB28A1FEDA20C89352E3FCFEAABFF9EF890A08664E5687ECD2', + TxnSignature: + '0438178AF327FC54C42638A4EDB0EB9A701B2D6192388BE8A4C7A61DD82EA4510D10C0CADAD3D8A7EBC7B08C3F2A50F12F686B47ED2562EE6792434322E94B0E', + Account: 'rpmFCkiUFiufA3HdLagJCWGbzByaQLJKKJ', + Counterparty: 'rQnFUSfgnLNA2KzvKUjRX69tbv7WX76UXW', + PrincipalRequested: '100000', + CounterpartySignature: { + Signers: [ + { + Signer: { + SigningPubKey: + 'EDD184F5FE58EC1375AB1CF17A3C5A12A8DEE89DD5228772D69E28EE37438FE59E', + TxnSignature: + 'C3A989FFA24CE21AE9E1734653387B34044A82B13F34B7B1175CB20118F9EF904ABEA691E4D3EFFD1EBF63C3B50F29AA89B68AF4A70CF74601CD326772D1680E', + Account: 'rBJMcbqnAaxcUeEPF7WiaoHCtFiTmga7un', + }, + }, + { + Signer: { + SigningPubKey: + 'ED121AF03981F6496E47854955F65FC8763232D74EBF73877889514137BB72720A', + TxnSignature: + '3A3D91798FCF56289BBF53A97D0CB07CFB5050CFBA05451A1C9A3A9E370AE81DCC3134E6CC35579ACA8937F15DF358DAB728054AC17C3858177C6947C1E21806', + Account: 'rKQhhSnRXJyqDq5BFtWG2E6zxAdq6wDyQC', + }, + }, + ], + }, + } + + const { tx: signer1SignedTx } = signLoanSetByCounterparty( + signerWallet1, + singedLoanSet as LoanSet, + { multisign: true }, + ) + + const { tx: signer2SignedTx } = signLoanSetByCounterparty( + signerWallet2, + singedLoanSet as LoanSet, + { multisign: true }, + ) + + assert.throws(() => { + combineLoanSetCounterpartySigners([]) + }, 'There are 0 transactions to combine.') + + const { tx: combinedSignedTx } = combineLoanSetCounterpartySigners([ + signer1SignedTx, + signer2SignedTx, + ]) + + assert.deepEqual(combinedSignedTx, expectedLoanSet as LoanSet) + }) +})