Stellar Atlas
Protocol
CAP-0071-01

Authentication delegation for custom accounts

FinalGitHub

Specification

CAP: 0071-01
Title: Authentication delegation for custom accounts
Working Group:
    Owner: Dmytro Kozhevin <@dmkozh>
    Authors: Dmytro Kozhevin <@dmkozh>
    Consulted: 
Status: Final
Created: 2025-09-10
Discussion: https://github.com/orgs/stellar/discussions/1784
Protocol version: 27

Simple Summary

Add a built-in way for custom (smart contract) accounts to delegate the authentication logic to other addresses.

Working Group

As specified in the Preamble.

Motivation

Custom (smart contract-based) accounts on Stellar had the capability to delegate their authentication logic to a different address starting from their introduction in protocol 20. This can be achieved via calling require_auth_for_args for an Address (or multiple addresses) inside the __check_auth function of the custom account instead of, or in addition to, performing signature validation. The authentication would then be requested from these addresses, thus potentially triggering their __check_auth functions.

This feature has been experimented with in some custom account implementations, and is generally deemed useful, as it allows for creating more flexible and 'modular' custom accounts. A significant motivating use case for the authentication delegation is also introduced in CAP-72: it adds the authentication logic customization capability to the Stellar accounts via adding contracts as additional account signers. This is the same case of authentication delegation, with the only difference being that the authentication procedure for the Stellar accounts is built into the protocol.

However, since the current delegation support happens to be just a by-product of the authorization framework design, there are significant user and developer experience flaws with the approach:

  • It is hard to properly simulate delegated authorization. When simulation records the necessary authorization payloads, it doesn't ever enter __check_auth, as it doesn't have sufficient information to call it. Thus users need to build the authorization payload of the inner call themselves and then they also need to run simulation again.
    • This could be somewhat simplified on the simulation side by introducing a mode in which authorization data may be partially initialized, which would simplify the inner payload building. However, this approach still comes with complexity for developers (such as the need to do more simulation calls), as well as the added simulation complexity.
  • Every account for which __check_auth is called requires an authorization entry in the transaction, with its own nonce. That increases transaction cost and further increases complexity.
  • It is hard to forward the authorization context to the delegated signers. To do that, one needs to pass the whole context into the require_auth_for_args call. But that means that the whole context needs to be present in the authorization payload of the delegated signer, which bloats the transaction size unnecessarily, and increases the complexity for the developers if they want to write logic dependent on the context.

Most of these issues, besides double simulation, can be mitigated with a rather small protocol change proposed in the CAP.

Goals Alignment

This CAP is aligned with the following Stellar Network Goals:

  • The Stellar Network should make it easy for developers of Stellar projects to create highly usable products

Abstract

A new authorization host function delegate_account_auth is introduced for delegating the authentication logic from an Addresses __check_auth function to a different address. Also, a new type of Soroban authorization credentials is introduced that allows specifying the delegate addresses together with their signatures.

Each of the delegated addresses has its own signature and its own authentication logic defined by the address executable. However, the signature payload and authorization context are shared among all the signers and are equivalent to the signature payload and the context of the 'top-level' address to which the authorization entry belongs.

delegate_account_auth calls can also be nested recursively, and every authentication call will still inherit the 'top-level' arguments.

Another host function called get_delegated_signers_for_current_auth_check is also introduced for standardizing and simplifying the delegated signers access by providing the direct access to the delegated signers populated in the authorization payload.

With these changes only a single authorization entry is necessary to accommodate an arbitrary number of potentially nested delegated signers, which reduces the transaction size greatly and simplifies the simulation logic.

Specification

XDR changes

The final cumulative XDR diff for CAP-71 is in CAP-71. The XDR changes relevant to this sub-CAP are the introduction of ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, SorobanDelegateSignature, SorobanAddressCredentialsWithDelegates, and SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES.

New host functions

The diff is based on commit f5153ff476ce7e68ee0bda278188cded135ff301 of rs-soroban-env.

diff --git a/soroban-env-common/env.json b/soroban-env-common/env.json
index 50293483..269862fc 100644
--- a/soroban-env-common/env.json
+++ b/soroban-env-common/env.json
@@ -2467,6 +2467,25 @@
                     "return": "Val",
                     "docs": "Returns the executable corresponding to the provided address. When the address does not exist on-chain, returns `Void` value. When it does exist, returns a value of `AddressExecutable` contract type. It is an enum with `Wasm` value and the corresponding Wasm hash for the Wasm contracts, `StellarAsset` value for Stellar Asset contract instances, and `Account` value for the 'classic' (G-) accounts.",
                     "min_supported_protocol": 23
+                },
+                {
+                    "export": "7",
+                    "name": "get_delegated_signers_for_current_auth_check",
+                    "args": [],
+                    "return": "VecObject",
+                    "docs": "Returns a vector of `Address`es of all the delegated signers that have attached signatures to authorize the current account contract invocation. **Important**: These are user-provided inputs and should be treated accordingly, in a similar fashion to the actual signatures. Specifically, the account contract must ensure that these signers actually belong to it, and perform authentication for every one of them via `delegate_account_auth`. This may only be called within `__check_auth` contract function inside a custom account.",
+                    "min_supported_protocol": 27
+                },
+                {
+                    "export": "8",
+                    "name": "delegate_account_auth",
+                    "args": [
+                        {
+                            "name": "address",
+                            "type": "AddressObject"
+                        }
+                    ],
+                    "return": "Void",
+                    "docs": "Delegates the custom account authentication to the provided address. This is only available when called within `__check_auth` contract function inside a custom account. This call will require the `address` to have authorized exactly the same call tree as the one being authorized by the current `__check_auth` call. Specifically, the same signature payload and the same context will be passed into `address`s authorization check. Panics if the call has not been authorized, or if called not from within `__check_auth`.",
+                    "min_supported_protocol": 27
                 }
             ]
         },

Semantics

SorobanAddressCredentialsWithDelegates validation

When Soroban host accepts authorization entries (SorobanAuthorizationEntry) it performs some basic validation on them (such as signatures being well-formed host values). For credentials of type SorobanAddressCredentialsWithDelegates the following additional validation rules are added:

  • Every SorobanDelegateSignature array (both top-level delegates field and every nestedDelegates field) must be sorted in the increasing order of the address field
  • Every SorobanDelegateSignature array must contain no duplicate addresses

If any of these conditions is violated, the host function invocation will fail immediately before entering the contract.

delegate_account_auth function

A general mechanism for supporting authentication delegation is introduced, both for use to support the delegation for G-accounts, as well as for use in any custom (contract-based) accounts.

delegate_account_auth host function is introduced by this CAP. It may only be called from the reserved __check_auth function, i.e. only when performing account authentication. All the addresses for which delegate_account_auth is called must be present in SorobanAddressCredentialsWithDelegates credentials in the delegates or nestedDelegates fields. Every such address will have __check_auth called for it with the same signature payload and context as for the top-level address the credentials belong to.

More formally, the algorithm for handling delegate_account_auth is defined as follows:

  • Define 'top level' __check_auth call for non-source address A as a call that occurs when require_auth[_for_args] is matched to A for the first time, as per CAP-46-11.
    • Note, that implementation of __check_auth for G-accounts is built into host and is not observed as a contract call; however that doesn't affect the algorithm.
  • Define top level call arguments as A.__check_auth(P, S(A), C) where P is the 32-byte signature payload, S(A) is the signature from the As auth entry, and C is the authorization context derived from the auth entry.
  • For every __check_auth call, define current_delegate_list as a list of delegated signer addresses with the corresponding signatures and a used flag.
  • For top level call, define current_delegate_list as the value of delegates inside SorobanAddressCredentialsWithDelegates in As auth entry, or an empty list if SorobanAddressCredentials is used. Mark every delegate as unused by setting their used flag to false.
  • Any time when delegate_account_auth is called for an address B:
    1. Search for the first unused (used == false) occurrence of B in current_delegate_list and store the found index i. If there is no such occurrence, fail authentication process.
    2. Set current_delegate_list[i].used to true.
    3. Call B.__check_auth(P, current_delegate_list[i].signature, C).
      • For the call, set current_delegate_list to the value of current_delegate_list[i].nestedDelegates and mark every delegate as unused.

get_delegated_signers_for_current_auth_check function

get_delegated_signers_for_current_auth_check is a supplementary host function that simplifies the usage of delegate_account_auth. It returns the current_delegate_list populated as per the algorithm in the previous section as a vector of addresses.

This allows account contracts to have Void/empty signature when only the delegated signers are being used, which further reduces the transaction size and complexity, and simplifies the contract itself.

Signature payload for SorobanAddressCredentialsWithDelegates

When SorobanAddressCredentialsWithDelegates type of credentials is used, the signature payload expected by the protocol is SHA-256 hash of HashIDPreimage XDR with ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS variant. The contents of the payload preimage are equivalent to ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, as defined by CAP-46-11. The only difference is that address field must be filled in as well with the value of the top-level address to which the respective credentials belong.

Design Rationale

New signature payload type

While it would be easier to just re-use the existing payload, that would open up a potential vulnerability.

Specifically, if an account uses only delegated signers, then signatures for a delegated signer could be replayed by using the delegated signer's address in place of the original account's address in the invocation. For example, if account A needs to sign a token transfer token.transfer(A, to, 100) via delegated signer D and token implementation only calls A.require_auth_for_args((to, 100)), then the signature of D would be reusable in token.transfer(D, to, 100) call. That would happen because ENVELOPE_TYPE_SOROBAN_AUTHORIZATION is not explicitly attached to the account address, it's implicitly linked to it via cryptographic signature. Thus the issue would only happen for the contracts that don't explicitly require authorization for the address as an invocation argument.

In order to prevent the vulnerability in all the cases, a new payload type is introduced that fixes the issue by explicitly adding the address to the payload and thus explicitly linking the nonce and signatures to the top-level account.

Note, that in the 'legacy' delegation flow that currently exists in the protocol the same kind of linking is achieved due to the need for D to explicitly authorize A.__check_auth call (i.e. linking happens in D's authorized invocation specification within the signed payload).

Delegated signer recursive nesting

The ability to nest the delegated signers makes protocol a bit more complex and requires 4 bytes of additional space in credentials even if it's not being used. This is introduced mostly for the sake of completeness and consistency, so that any accounts could be used as delegated signers (including ones that have delegated signers of their own). This may also potentially help some complex protocols that may come in the future. The relative protocol complexity increase is pretty marginal and thus should be tolerable.

Delegated signer getter

get_delegated_signers_for_current_auth_check function is technically optional for this CAP, as smart accounts could get away with defining a special signature type to account for the delegated signers.

However, this creates some unnecessary duplication in the transaction data (as delegated signers would need to be specified twice), and introduces unnecessary fragmentation into how delegated signers work, as different contracts may use slightly different UDTs to represent them in signatures. This CAP already provides a standardized approach for storing the delegated signers in the transaction, so there is no good reason not to benefit from that on the contract implementation side as well, while also avoiding duplication.

Protocol Upgrade Transition

The proposed host functions will use the standard mechanism for protocol-gating the host functions and will become available in protocol 27. The new credential type will also only be available starting from protocol 27, otherwise including it into transaction will invalidate the transaction.

Backwards Incompatibilities

This CAP does not introduce any backward incompatibilities.

Resource Utilization

The new host function will have the appropriate metering. No new cost types need to be introduced, as the operations can lean on the existing metering primitives.

Security Concerns

Delegated signers increase the account implementation complexity and thus may increase the probability of it being vulnerable to exploits. However, this CAP doesn't significantly change the risk surface, as similar functionality already exists in the protocol.

Similarly to the existing authorization framework code, the new authorization-related code is very sensitive and must be thoroughly reviewed. Given the correct implementation there aren't significant new concerns from the protocol standpoint.

Test Cases

TBD

Implementation

TBD

Appendix: Simulation flow

While simulation is not a part of the protocol, it is an important part of the overall Stellar developer experience, so it's useful to mention it in order to put this CAP into context.

Even with this CAP, two simulation runs are necessary in order to come up with the correct transaction footprint and resources. Note, that two simulation runs is a general issue that any custom account likely needs to deal with. There is a possibility to avoid the second simulation run via supplying all the signers and 'dummy' signatures for them, but that change is unrelated to this CAP.

However, the process is still significantly simplified and streamlined. Specifically, the flow for any account A with any number of delegated signers will look like the following (for simplicity a call where only a single authorization entry needs to be signed is described, but this scales to an arbitrary number of payloads):

  1. Send a simulation request in the recording authorization mode
  2. Receive simulation response that contains the invocation part of the authorization entry for A
  3. Build HashIDPreimage::ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS (with A's address, invocation tree, network ID, nonce, and signature expiration ledger as inputs) and compute its SHA-256 hash H.
  4. Compute the signature for A, depending on the account implementation (e.g. sign H with As key, or just pass a Void value if only delegate signers are used) and attach it as a top-level signature in the output SorobanAddressCredentialsWithDelegates
  5. For every delegated signer D, sign H according to the implementation and attach Ds address and signature to SorobanAddressCredentialsWithDelegates. In case of nested accounts, the signature must go to the appropriate node of the tree (the assumption is that wallet knows the intricacies of the account authentication scheme, which is necessary anyways to perform signing)
  6. Attach the final SorobanAddressCredentialsWithDelegates with all the signatures to SorobanAuthorizationEntry in the transaction and send it for another round of simulation in the enforcing mode
  7. Use the resources from the second simulation run for sending the final transaction to the network

Preamble

Status
Final
Protocol version
27
Authors
Dmytro Kozhevin
Created
2025-09-10

Discussion

0 linked threads