Authorize a Contract Call
Some contract calls must be authorized by an account other than the one sending
the transaction: a token transfer the sender does not own, a withdrawal from a
smart wallet, a payment routed through x402 or MPP. This guide signs that
authorization, using the path that stays correct as Stellar moves to its new
credential, AddressV2, which writes the signer’s address into the signed
bytes (it is “address-bound”). Everything runs on testnet, so it is free and safe
to repeat.
What this guide is for
This guide is for code that signs Soroban contract authorization entries itself by building the signature payload by hand: smart wallets, custom signers, and integrations like x402 or MPP. That is the one kind of code AddressV2 affects.
It is not for:
- Classic Stellar apps (payments, trustlines, no contract calls). AddressV2 does not touch classic transaction signing, so nothing here applies to you.
- Apps that let the SDK sign their contract calls (the
contract.Clientflow from Invoke a Contract,basicNodeSigner, orauthorizeEntry). Bump to the Protocol 27 SDK and change nothing. Those paths build the correct payload for whichever credential they are handed, so they signAddressV2(now the default from simulation) and legacyADDRESSalike with no code change. - Apps that build entries from scratch with
authorizeInvocation. This one constructs the credential rather than being handed one, and it now buildsAddressV2by default. PassauthV2: falsefor the legacyADDRESSshape. See the Protocol 27 auth guide.
What Protocol 27 changes. Protocol 27 introduces
AddressV2, an address-bound Soroban authorization credential (CAP-0071-02). Stellar Core acceptsAddressV2on a network running Protocol 27, and the SDK now asks for it by default: simulation requests it viauseUpgradedAuth, andauthorizeInvocationbuilds it (see the Protocol 27 upgrade guide). The signing path here builds the correct payload from whichever credential an entry carries, so the same code is right onAddressV2and on legacyADDRESS, with no change from you.
Two signatures, not one
An invoke transaction can carry two kinds of signature, and AddressV2 touches only one of them.
The envelope signature is the transaction source signing the whole
transaction hash with tx.sign(keypair), the same call as in
Send a Payment. It authorizes the source to submit
the transaction and pay its fee, and it covers the InvokeHostFunction operation
itself. AddressV2 does not change it.
The authorization signature is separate. Contracts mark the parts of a call
that need consent by calling require_auth() on an address. Each such address
gets its own authorization entry (a SorobanAuthorizationEntry): a small signed
object that approves this one invocation. If that address is the transaction
source, the envelope signature already covers it and there is nothing extra to do.
If it is a different account, that account signs its own authorization entry, and
that entry’s payload is the only thing AddressV2 changes. This guide is about that
second signature.
| Envelope signature | Authorization-entry signature | |
|---|---|---|
| Who signs | the transaction source (plus classic multisig signers) | each address a contract calls require_auth on, when it is not the source |
| What it signs | the transaction hash (the whole tx, including the invoke op) | one invocation, per authorizing address |
| API | tx.sign / signTransaction | signAuthEntries, authorizeEntry |
| AddressV2 | unchanged | payload becomes address-bound |
AddressV2 lives only in the authorization-entry payload, and only for an address that is not the transaction source. A source-account authorization carries no separate signature, so it is unaffected.
Prerequisites
- A funded testnet account and its keypair. If you need one, see Connect and Fund an Account.
- A second funded account whose authorization a call requires (the examples call
it
signer). - A deployed contract with a method that calls
require_authon an address argument. The examples use the Stellar Auth example contract (increment(user, value)callsuser.require_auth()). Deploying is a one-time setup with the Stellar CLI, the same path as Invoke a Contract. - You have read Invoke a Contract; this guide
builds on its
contract.Clientflow and does not repeat it.
Sign a non-source authorization entry
When a call requires another account’s authorization, simulation returns an authorization entry that account must sign. Build the transaction as in Invoke a Contract, then ask which accounts still need to sign:
const tx = await client.increment({ user: signer.publicKey(), value: 1 });
tx.needsNonInvokerSigningBy(); // [signer.publicKey()]Awaiting the method call simulates it, which is what populates the authorization
entries; needsNonInvokerSigningBy then reads them back, skipping source-account
credentials because the envelope covers those. Delegate the signing to the SDK
with
signAuthEntries and
a signAuthEntry callback.
basicNodeSigner is the
simple Node signer (a browser app swaps in a wallet such as Freighter):
const { signAuthEntry } = contract.basicNodeSigner(signer, networkPassphrase);
await tx.signAuthEntries({ address: signer.publicKey(), signAuthEntry });
const sent = await tx.signAndSend();basicNodeSigner signs the exact payload the SDK builds, so the same call is
correct on either credential.
What needsNonInvokerSigningBy can and cannot see
needsNonInvokerSigningBy is a signature-presence heuristic, not an
authorization check. It lists every address in an address-credential entry
whose signature payload is still empty: the top-level address, plus any CAP-71
delegates under a contract account (see below). Only source-account credentials
are skipped, since the envelope signature covers them; an address-credential
entry is listed even when its address is the transaction source, so your own
account can appear in the list. It does not verify signatures, check signer
weights, or evaluate a custom account’s policy, so an empty signature may be
intentional, and a filled one does not prove the entry will pass.
Simulation never returns a CAP-71 AddressWithDelegates entry (it returns
ADDRESS_V2, or legacy ADDRESS with useUpgradedAuth: false), so delegated
signing is up to you: wrap the simulated entry with buildWithDelegatesEntry,
then sign each delegate node with authorizeEntry and its forAddress
argument. signAuthEntries does not select delegate nodes. All nodes sign one
payload that includes the expiration, so sign every node with the same
validUntilLedgerSeq. When a node signs with a different value,
authorizeEntry clears the signatures of the other nodes. On an entry that
still stores 0, it keeps an ed25519 signature that verifies at the new value,
and keeps a custom signature as it is. signAuthEntries keeps the stored
expiration of a delegates entry while it lies within the next 100 ledgers, so
the top-level account can sign last without an expiration argument.
Once delegates are attached, the heuristic reports only what the signatures
show. A G… account must sign its own top-level entry on protocol 27, and its
delegates are ignored there. A contract account’s __check_auth decides what
it needs: some accept their delegates alone, others need their own signature
as well, and the signatures can’t tell the two apart. So an unsigned C…
account stays listed even after its delegates have signed; if yours
authorizes only through delegates, filter its address out. The unsigned
delegates of a C… account are listed too, even when the account itself has
signed. Sign them with authorizeEntry and forAddress, since
signAuthEntries signs top-level addresses only.
One kind of requirement stays invisible to it. Recording-mode simulation does
not run a custom account’s __check_auth, so a require_auth_for_args made
inside it never appears in the returned entries at all. Such a requirement
needs an auth entry built by hand, and enforcement-mode simulation to check it.
sign and signAndSend run this heuristic before signing the envelope, and reject any G… address still listed. They skip
contract (C…) addresses, since a contract’s own policy can’t be checked
locally. The delegate check assumes every listed delegate must sign. If a
custom account’s __check_auth uses only some of its delegates (a 2-of-3
multisig, say), sign rejects the unused ones even though the network would
accept the transaction. Pass ignoreContractDelegates: true to sign or
signAndSend to skip that check for contract accounts’ delegates; G…
addresses are still checked. To check a custom account’s entries, call
simulate() again after signing: enforcement-mode simulation runs
__check_auth and fails if a signature is still missing.
For ordinary multi-party signing, serialize the transaction with toJson, send
it to the next account’s signer, have them deserialize it with txFromJson and
call signAuthEntries, then re-serialize before passing it on. Signers go one
at a time, since the SDK does not merge separately signed copies.
When a wallet signs with a different key
When a wallet signs with a key other than the account being authorized (classic
multisig), it should return that key as signerAddress, and the SDK verifies the
signature against it. That check proves the key signed the payload, not that the
key is a signer on the account with enough weight; the network decides that.
If you sign the payload yourself
If you build and sign the authorization payload yourself, here is the one change
to make. In the snippets below, entry is one of the authorization entries off
the built transaction (the ones needsNonInvokerSigningBy flagged), validUntil
is a future ledger sequence (for example
(await server.getLatestLedger()).sequence + 100), and hash, authorizeEntry,
and buildAuthorizationEntryPreimage import from @stellar/stellar-sdk.
Before. Code that reconstructs the payload by hand hardcodes the legacy,
non-address-bound credential. It is silently fine on today’s ADDRESS entry, but
the moment an entry is AddressV2 (the Protocol 28 flip) it signs the wrong bytes
and the network rejects it. A latent bug:
// ❌ Before: hardcodes the legacy ENVELOPE_TYPE_SOROBAN_AUTHORIZATION payload.const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( new xdr.HashIdPreimageSorobanAuthorization({ networkId: hash(networkPassphrase), nonce: credentials.nonce, invocation: entry.rootInvocation, signatureExpirationLedger: validUntil, }),);const signature = keypair.sign(hash(preimage.toXdr()));After. Swap the hand-built preimage for
buildAuthorizationEntryPreimage,
which reads the entry’s credential type and builds the matching payload. The
signing line is unchanged, and the same code is now correct on both ADDRESS and
AddressV2:
// ✅ After: picks the right payload from the entry's own credential type.const preimage = buildAuthorizationEntryPreimage( entry, validUntil, networkPassphrase,);const signature = keypair.sign(hash(preimage.toXdr()));Better still, drop the preimage step entirely and hand the whole entry to
authorizeEntry, which
builds the payload, signs it, verifies it, and returns a new, signed entry:
// ✅ Even simpler: authorizeEntry does the whole thing.const signed = await authorizeEntry( entry, keypair, validUntil, networkPassphrase,);For a custom signer that is not a Keypair, pass a SigningCallback to
authorizeEntry. It receives the full xdr.HashIdPreimage, so it can inspect
what it signs. It can return { signature, publicKey }, which names the
signer; a bare Uint8Array signature, which is checked against the entry’s
address (or forAddress); or { signatureScVal } for a custom account
contract. Return { signature, publicKey } when the signer is not the entry’s
address.
Why one call covers both
buildAuthorizationEntryPreimage reads the entry’s credential type and builds the
matching payload: the legacy ENVELOPE_TYPE_SOROBAN_AUTHORIZATION for ADDRESS,
and the address-bound ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS for
AddressV2. (AddressV2 is CAP-0071-02; it reuses the address-bound envelope
introduced for custom accounts in
CAP-0071-01,
which is why both CAPs appear here.) You call one function either way, so the flip
from ADDRESS to AddressV2 needs no code change from you.
AddressV2 exists to close a narrow gap. The legacy payload commits to the network,
nonce, invocation, and expiration, but not to the signer’s address. If two
accounts share a private key, a signature made for one could be replayed against
the other. AddressV2 binds the address into the signed bytes, which closes that
gap. The gap is narrow (it needs accounts that share keys), which is why ADDRESS
stays valid and Core accepts both.
Put it together
The whole flow as one runnable script. It funds a throwaway source account and a
separate signer account, builds a call that requires the signer’s
authorization, signs that entry with basicNodeSigner, and submits. Set
contractId to your deployed Auth contract (see Prerequisites).
import { contract, rpc, Keypair, Networks } from "@stellar/stellar-sdk";
const rpcUrl = "https://soroban-testnet.stellar.org";const networkPassphrase = Networks.TESTNET;let contractId = "C..."; // your deployed Auth contract (see Prerequisites)
interface AuthContract { increment: ( args: { user: string; value: number }, options?: contract.MethodOptions, ) => Promise<contract.AssembledTransaction<number>>;}
const server = new rpc.Server(rpcUrl);
// The transaction source (signs the envelope) and a separate account whose// authorization the call requires.const source = Keypair.random();const signer = Keypair.random();
await server.fundAddress(source.publicKey());await server.fundAddress(signer.publicKey());
const { signTransaction } = contract.basicNodeSigner(source, networkPassphrase);
const client = await contract.Client.from<AuthContract>({ contractId, rpcUrl, networkPassphrase, publicKey: source.publicKey(), signTransaction,});
// A call that requires `signer` (not the source) to authorize it.const tx = await client.increment({ user: signer.publicKey(), value: 1 });console.log("needs signing by:", tx.needsNonInvokerSigningBy());
// Sign that entry as `signer`. basicNodeSigner signs the payload the SDK// builds, so this is correct on whichever credential the network returns.const { signAuthEntry } = contract.basicNodeSigner(signer, networkPassphrase);
await tx.signAuthEntries({ address: signer.publicKey(), signAuthEntry });
const sent = await tx.signAndSend();console.log("applied:", sent.result);You can now authorize a contract call that more than one account must sign, and
your signer is correct on both credentials, on today’s ADDRESS and after the
Protocol 28 flip to AddressV2. Custom accounts can also delegate authorization
to a tree of signers (ADDRESS_WITH_DELEGATES, also from CAP-0071-01); that
advanced flow is covered separately. Next, learn to convert between JavaScript
values and Soroban types.