Skip to content

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.Client flow from Invoke a Contract, basicNodeSigner, or authorizeEntry). Bump to the Protocol 27 SDK and change nothing. Those paths build the correct payload for whichever credential they are handed, so they sign AddressV2 (now the default from simulation) and legacy ADDRESS alike 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 builds AddressV2 by default. Pass authV2: false for the legacy ADDRESS shape. 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 accepts AddressV2 on a network running Protocol 27, and the SDK now asks for it by default: simulation requests it via useUpgradedAuth, and authorizeInvocation builds 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 on AddressV2 and on legacy ADDRESS, 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 signatureAuthorization-entry signature
Who signsthe transaction source (plus classic multisig signers)each address a contract calls require_auth on, when it is not the source
What it signsthe transaction hash (the whole tx, including the invoke op)one invocation, per authorizing address
APItx.sign / signTransactionsignAuthEntries, authorizeEntry
AddressV2unchangedpayload 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_auth on an address argument. The examples use the Stellar Auth example contract (increment(user, value) calls user.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.Client flow 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.