Skip to content

NestedMultiSign#675

Open
RichardAH wants to merge 11 commits intodevfrom
multi-sig-nested-squash
Open

NestedMultiSign#675
RichardAH wants to merge 11 commits intodevfrom
multi-sig-nested-squash

Conversation

@RichardAH
Copy link
Copy Markdown
Contributor

@RichardAH RichardAH commented Feb 4, 2026

NOTE

Please also see rippled side code @
XRPLF/rippled#6368

And the XLS
XRPLF/XRPL-Standards#472


NestedMultiSign Amendment

Overview

This amendment introduces nested multi-signature validation, allowing signer lists to delegate signing authority to other accounts that themselves have signer lists. This enables hierarchical organizational structures where, for example, a company account can require signatures from department accounts, which in turn require signatures from authorized individuals.

Key Features

Hierarchical Signing (up to 4 levels deep)

  • Signers can now delegate to their own signer lists rather than providing a direct signature
  • Enables corporate governance structures: Company → Departments → Teams → Individuals
  • Each level validates against its own signer list and quorum requirements
  • Weight is only contributed to the parent level when a signer's own quorum is satisfied

Cycle Detection & Quorum Relaxation

  • Detects circular dependencies in signer list configurations (e.g., A→B→A)
  • Prevents permanent fund lockout when accounts accidentally create cyclical signer relationships
  • When a cycle makes certain signers unavailable, the effective quorum is relaxed to the maximum achievable weight
  • Accounts with effectiveQuorum == 0 (all signers cyclic) correctly fail with tefBAD_QUORUM

Transaction Structure

Nested signers are represented by including an sfSigners array instead of sfSigningPubKey/sfTxnSignature:

{
  "Signers": [{
    "Signer": {
      "Account": "DepartmentA",
      "Signers": [{
        "Signer": {
          "Account": "Employee1",
          "SigningPubKey": "...",
          "TxnSignature": "..."
        }
      }]
    }
  }]
}

Validation Rules

  • Maximum nesting depth: 4 levels
  • Signers at each level must be in strict ascending order (consensus requirement)
  • A signer entry cannot have both nested signers AND signature fields
  • Leaf signers must provide valid SigningPubKey and TxnSignature
  • Each level's accumulated weight must meet its quorum (or relaxed quorum if cycles exist)

Error Codes

Code Condition
tefBAD_SIGNATURE Depth limit exceeded, invalid signer, or malformed signer entry
tefBAD_QUORUM Insufficient weight to meet quorum, or all signers are cyclic
tefNOT_MULTI_SIGNING Nested signer account has no signer list configured
tefMASTER_DISABLED Leaf signer's master key is disabled
tefINTERNAL Invariant violation (should never occur)

Backward Compatibility

When featureNestedMultiSign is disabled, nested signer arrays are rejected with temMALFORMED, preserving existing behavior.

Comment thread src/ripple/rpc/impl/TransactionSign.cpp Outdated
Comment on lines +1186 to +1187
return (
// A Signer object always contains these fields and no
// others.
obj.isFieldPresent(sfAccount) &&
obj.isFieldPresent(sfSigningPubKey) &&
obj.isFieldPresent(sfTxnSignature) && obj.getCount() == 3);
if (obj.getCount() != 4 || !obj.isFieldPresent(sfAccount))
return false;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove 4 here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

        if (!obj.isFieldPresent(sfAccount))
            return false;

        // leaf signer
        if (obj.isFieldPresent(sfSigningPubKey) &&
            obj.isFieldPresent(sfTxnSignature) &&
            !obj.isFieldPresent(sfSigners))
            return obj.getCount() == 3;

        // nested signer
        if (!obj.isFieldPresent(sfSigningPubKey) &&
            !obj.isFieldPresent(sfTxnSignature) &&
            obj.isFieldPresent(sfSigners))
            return obj.getCount == 2;

Comment thread src/ripple/protocol/impl/STTx.cpp Outdated
if (lastAccountID == accountID)
return Unexpected("Duplicate Signers not allowed.");
checkSignersArray = [&](STArray const& signersArray,
AccountID const& parentAccountID,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused parentAccuontID

@zgrguric
Copy link
Copy Markdown

zgrguric commented Feb 6, 2026

First, thank you for doing this.
What is overhead to xahaud for more than 4 levels? Is there benchmark tests for this?

@sublimator
Copy link
Copy Markdown
Collaborator

What is overhead to xahaud for more than 4 levels?

Good question, wonder if there should be some kind of leaf specified limit ?

finishMultiSigningData(accountID, s);
auto const accountID = signer.getAccountID(sfAccount);

// The account owner may not multisign for themselves.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nested signer matching parent account is not rejected in STTx::checkMultiSign
In the recursive checkSignersArray lambda in STTx::checkMultiSign, the check accountID == txnAccountID only compares against the top-level transaction account ID, not the parentAccountID at each recursion level. The parentAccountID parameter is passed into the lambda but never used.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But parentAccountID (the account being signed for at the current nesting level) is never checked. This means if account B's signer list includes B itself, and B is a nested signer for account A, the recursive call would pass parentAccountID = B but never reject B appearing in its own nested signer array.
In the original non-recursive code, this wasn't an issue because there was only one level. Now with nesting, a signer at depth N could list themselves as a sub-signer at depth N+1, which should be rejected but isn't.
Note: Transactor::checkMultiSign has cycle detection via the ancestors set which would catch this at the preclaim stage, but STTx::checkMultiSign (the preflight signature verification) does not, allowing malformed transactions to pass signature verification and consume more processing resources than necessary.

@@ -44,8 +44,9 @@ InnerObjectFormats::InnerObjectFormats()
sfSigner.getCode(),
{
{sfAccount, soeREQUIRED},
{sfSigningPubKey, soeREQUIRED},
{sfTxnSignature, soeREQUIRED},
{sfSigningPubKey, soeOPTIONAL},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making sfSigningPubKey and sfTxnSignature optional changes validation for ALL signers, not just nested
The change in InnerObjectFormats.cpp:47-49 from soeREQUIRED to soeOPTIONAL for sfSigningPubKey and sfTxnSignature in the sfSigner inner object format is a global change that affects all signer deserialization, not just when featureNestedMultiSign is enabled. This means that even without the amendment enabled, a malformed signer object missing sfSigningPubKey or sfTxnSignature will now successfully deserialize (whereas before it would throw during applyTemplate). The runtime checks in STTx::checkMultiSign (STTx.cpp:445-449) and Transactor::checkMultiSign (Transactor.cpp:1101-1107) do validate that leaf signers have these fields, so this is caught later. However, the test in STTx_test.cpp:1797-1803 (Test case 2) had to be commented out because it tested that deserialization itself would reject a signer without sfSigningPubKey. This is a defense-in-depth regression—invalid data that was previously rejected at the serialization layer now passes through to application-level validation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

Comment thread src/ripple/app/tx/impl/Transactor.cpp Outdated
Comment thread src/ripple/rpc/impl/TransactionSign.cpp Outdated
// Attempt to match the SignerEntry with a Signer;
while (iter->account < txSignerAcctID)
// Check depth limit
if (depth > maxDepth)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No recursion depth limit on STObject deserialization for nested sfSigners
The sfSigner inner object format now includes sfSigners as an optional field (InnerObjectFormats.cpp:49), which means sfSigner objects can recursively contain sfSigners arrays. STObject::set at STObject.cpp:180 has a depth limit of 10, so deeply nested structures won't cause stack overflow during deserialization. However, the STTx checkMultiSign limits depth to 4, while the serializer allows up to 10 levels of nesting. This means an attacker could craft a transaction with 10 levels of nested signers that would successfully deserialize and consume resources during signature checking before being rejected at depth 5. The computational cost grows exponentially with nesting depth since each level can have up to 32 signers.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shortthefomo

Please see the latest code @
XRPLF/rippled#6368

And the XLS
XRPLF/XRPL-Standards#472

This PR is stale

Regards

…uashed

# Conflicts:
#	include/xrpl/protocol/Feature.h
#	src/libxrpl/protocol/Feature.cpp
#	src/test/jtx/multisign.h
#	src/xrpld/app/tx/detail/Transactor.cpp
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 29, 2026

Codecov Report

❌ Patch coverage is 78.63248% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.54%. Comparing base (5e8d26f) to head (7245a16).

Files with missing lines Patch % Lines
src/xrpld/app/tx/detail/Transactor.cpp 71.23% 20 Missing and 22 partials ⚠️
src/libxrpl/protocol/STTx.cpp 92.72% 4 Missing ⚠️
include/xrpl/protocol/STTx.h 84.21% 0 Missing and 3 partials ⚠️
src/xrpld/rpc/detail/TransactionSign.cpp 92.85% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #675      +/-   ##
==========================================
- Coverage   66.55%   66.54%   -0.01%     
==========================================
  Files         831      831              
  Lines       78166    78309     +143     
  Branches    44374    44465      +91     
==========================================
+ Hits        52023    52113      +90     
- Misses      17797    17830      +33     
- Partials     8346     8366      +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

# Conflicts:
#	include/xrpl/protocol/Feature.h
#	include/xrpl/protocol/detail/features.macro
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants