api icon indicating copy to clipboard operation
api copied to clipboard

RFC: Deprecate `signPayload` in favour of new `createTransaction`

Open josepot opened this issue 3 months ago • 17 comments

The signPayload function has become a de-facto standard because wallets, extensions, and other signers expose it as the public interface for creating transactions.

However, this interface is very problematic and has held the ecosystem back. In short:

  • It predates Metadata V14 and doesn’t account for custom extensions defined in the metadata.
  • It only supports a small, opinionated set of signed extensions via a restrictive API.
  • Any time we add or change an extension, DApps break (e.g. CheckMetadataHash rollout and changes in ChargeAssetTxPayment). It can't leverage the work of RFC-99.
  • The API isn’t documented rigorously and leaks PJS implementation details, eg:
    • blockNumber: expects big-endian encoding.
    • nonce: is encoded differently from what's defined in the metadata.
  • It cannot fully support Extrinsic V5 (general transactions) and/or versioned transaction extensions.

Goal

Introduce a new, well-specified, forward-compatible function named createTransaction that:

  1. Enables interoperability across libraries, wallets, extensions, and tooling.
  2. Supports both Extrinsic V4 and Extrinsic V5 (including General transactions and versioned transaction extensions).
  3. Allows chains to define custom extensions without requiring bespoke wallets.

This interface should live side-by-side with signPayload. Callers should prefer createTransaction when present; signPayload remains for backward compatibility.


Proposed TypeScript interface

type HexString = `0x${string}`;

export interface TxPayloadV1 {
  /** Payload version. MUST be 1. */
  version: 1;

  /**
   * Signer selection hint. Allows the implementer to identify which private-key / scheme to use.
   * - Use a wallet-defined handle (e.g., address/SS58, account-name, etc). This identifier
   * was previously made available to the consumer. 
   * - Set `null` to let the implementer pick the signer (or if the signer is implied).
   */
  signer: string | null;

  /**
   * SCALE-encoded Call (module indicator + function indicator + params).
   */
  callData: HexString;

  /**
   * Transaction extensions supplied by the caller (order irrelevant).
   * The consumer SHOULD provide every extension that is relevant to them.
   * The implementer MAY infer missing ones.
   */
  extensions: Array<{
    /** Identifier as defined in metadata (e.g., "CheckSpecVersion", "ChargeAssetTxPayment"). */
    id: string;

    /**
     * Explicit "extra" to sign (goes into the extrinsic body).
     * SCALE-encoded per the extension's "extra" type as defined in the metadata.
     */
    extra: HexString;

    /**
     * "Implicit" data to sign (known by the chain, not included into the extrinsic body).
     * SCALE-encoded per the extension's "additionalSigned" type as defined in the metadata.
     */
    additionalSigned: HexString;
  }>;

  /**
   * Transaction Extension Version.
   * - For Extrinsic V4 MUST be 0.
   * - For Extrinsic V5, set to any version supported by the runtime.
   * The implementer:
   *  - MUST use this field to determine the required extensions for creating the extrinsic.
   *  - MAY use this field to infer missing extensions that the implementer could know how to handle.
   */
  txExtVersion: number;

  /**
   * Context needed for decoding, display, and (optionally) inferring certain extensions.
   */
  context: {
    /**
     * RuntimeMetadataPrefixed blob (SCALE), starting with ASCII "meta" magic (`0x6d657461`),
     * then a metadata version (V14+). For V5+ versioned extensions, MUST provide V16+.
     */
    metadata: HexString;

    /**
     * Native token display info (used by some implementers), also needed to compute
     * the `CheckMetadataHash` value.
     */
    tokenSymbol: string;
    tokenDecimals: number;

    /**
     * Highest known block number to aid mortality UX.
     */
    bestBlockHeight: number;
  };
}

/**
 * Creates a SCALE-encoded extrinsic (ready to broadcast).
 */
export type TxCreator = (input: TxPayloadV1) => Promise<HexString>;

Normative behavior (what implementers MUST/SHOULD do)

  • Return value: A Promise that resolves to an Hexadecimal SCALE-encoded extrinsic ready to broadcast.
  • Input invariants
    • HexString values MUST be hexadecimal symbols 0-f, even-length, and 0x prefixed.
    • version MUST be 1.
    • metadata MUST be a SCALE RuntimeMetadataPrefixed blob beginning with ASCII “meta” and a version byte. (V14+ allowed for V4; V16+ required when txExtVersion > 0 to select extension sets by version.)
    • For V4 transactions, txExtVersion MUST be 0. For V5 transactions, txExtVersion MUST be a runtime-supported extension set version (per RFC-0099).
  • Extensions array
    • Match extensions by id exactly as declared in metadata.
    • Unknown ids (not declared in the corresponding txExtVersion of the metadata) are not allowed.
    • There can't be repeated ids.
    • The order is irrelevant.
    • If required by the runtime but not provided, the implementer SHOULD try to infer and append missing extensions using metadata (and chain state, if available).
  • Mortality UX
    • bestBlockHeight is provided so that "offline" implementers can display to the signer the range of blocks in which the transaction will be valid.
  • Errors Implementations SHOULD throw structured errors for:
    • UnsupportedTxExtensionVersion (unknown txExtVersion for this runtime)
    • MissingMandatoryExtension (metadata indicates required extension not provided and not inferable)
    • InvalidHex / DecodeError (malformed callData / extra / additionalSigned)
    • SignerUnavailable (no signer resolution possible)

Backward compatibility & migration

  • createTransaction does not remove signPayload (for now). Libraries can feature-detect and MUST prefer createTransaction; fall back to signPayload where needed.
  • For chains still on extrinsic V4, set txExtVersion = 0.
  • For chains supporting both extrinsic V4 and V5, implementers can decide which version is better suited depending on the extensions provided when txExtVersion = 0. If txExtVersion > 0 they must use extrinsic V5.

FAQ

What if the implementer already has the metadata, or can’t afford receiving such a large payload?

The purpose of this interface is to support both online and offline implementers. It is explicitly designed to be consumed by a JavaScript process, but the concrete implementation details are left flexible.

Different implementers can build compliant libraries while adapting to the constraints of their environment. Some examples:

  • Hardware devices (e.g., Ledger): A Ledger implementer could implement this interface even though it cannot receive the entire metadata blob. In that case, the accompanying JavaScript library would take responsibility for interpreting the metadata and transforming it into the minimal information that needs to be sent to the device.

  • Browser extensions: An extension that only supports a fixed set of chains might already cache the metadata locally. In this case, the extension can safely ignore the provided context.metadata (or reject the promise outright if the chain is not supported).

The guiding principle is that the interface guarantees interoperability at the JavaScript boundary, while implementers remain free to optimize the transport and storage formats used internally.


cc: @0xKheops @valentunn @carlosala @voliva @TarikGul @saltict

josepot avatar Sep 24 '25 13:09 josepot

Great initiative!

0xKheops avatar Sep 24 '25 13:09 0xKheops

I fully support this as well - Very well written @josepot +1

cc: @ap211unitech @valentinfernandez1

TarikGul avatar Sep 24 '25 17:09 TarikGul

In general transactions the transaction extension typically contains signature as extra. How do we expect to request this signature?

Typically the equivalent of a signed transaction is a general transaction where one extension contains the signature.

E.g the transaction extension pipeline is (A, B, VerifySignature, D, E).

VerifySignature contains as explicit an Option which is some is the signature of implicit and explicit of D and E and the call and the transaction extension version.

So you would want to send to the wallet either None, or the request to put the signature.

Some transaction extension are even more complex like e.g. D which can have as explicit d1 and d2, and d2 is the signature of d1, explicit and implicit of E, call, transaction extension version.

Maybe one solution is to be able to request the signature of some fields and the inherited implication: for VerifySignature the app request the signatures of the inherited implication: (D, E, call, version), for D the app request the signature of d1 with the inherited implication.

Some transaction may even request 2 signatures d2 and VerifySignature.

I don't really know what is the best solution.

gui1117 avatar Sep 25 '25 12:09 gui1117

Thanks for raising this @gui1117. The interaction between General transactions and signature-carrying extensions is exactly why this proposal draws a hard line between caller-supplied extensions and signer-resolved extensions.

1) What createTransaction returns (scope of the API)

createTransaction returns a SCALE-encoded extrinsic ready to broadcast (the whole thing, not just a signature). That’s deliberately different from today’s signPayload (which historically returned only the signature, later gained the withSignedTransaction escape hatch in order to accommodate for CheckMetadataHash, which -sometimes- returns the whole transaction).

So the question “how do we request the signature?” is answered by not requesting it at all at this boundary. The signer (wallet/extension/device) is the component that composes and supplies any signature-carrying extensions, then returns the finished extrinsic.

2) Who provides which extensions?

From the spec (condensed):

  • The caller supplies any extensions it understands and cares about (tips, mortality hints, etc.).
  • The implementer (wallet/extension) MAY infer and append missing extensions required by the runtime, and MAY override caller suggestions when the user chooses different preferences at signing time.

Applied to your examples:

  • VerifySignature (or any extension where extra is a signature over inherited data like (D, E, call, version)) is a signer-owned extension. The caller does not request it explicitly; the wallet composes it when creating the extrinsic.
  • If an extension D has extra = (d1, d2) with d2 being a signature over d1 + inherited implication, the wallet computes d2.
  • If a transaction needs two signatures (d2 and VerifySignature), that’s still the implementers job. The implementer either includes both, or rejects with a clear error.

3) Conflict resolution & caller expectations

Think of the caller’s extensions as an ask, not an order. Example: the DApp suggests paying fees in the native token; the user prefers to pay the fee using a different asset. The wallet is allowed to ask the user if they want to pay the fees using their asset of preference or the native token. The caller receives a finished extrinsic and can decode it (using the same metadata) to verify it matches their expectations before broadcasting.


A summary of the flow:

  1. Caller → Implementer Sends:

    • callData
    • extensions (caller’s preferences)
    • txExtVersion
    • context
  2. Implementer: Resolve & Compose

    • Selects the extension set from metadata + txExtVersion.
    • Infers/appends any mandatory extensions missing from the caller’s list.
    • Overrides caller-supplied extension values if the user chooses different preferences.
    • Signs and creates the transaction.
  3. Implementer → Caller

    • Returns the SCALE-encoded extrinsic ready to broadcast.
  4. Caller: Verify & Submit

    • Optionally decodes the returned extrinsic to verify it matches expectations.
    • Broadcasts the transaction (or rejects if it no longer reflects the app’s intent).

Error cases (brief): UnsupportedTxExtensionVersion, MissingMandatoryExtension, InvalidHex/DecodeError, SignerUnavailable.


Note: As I wrote this, I realized some behaviors need clearer wording in the spec. I’ll refine those sections. This interface isn’t a silver bullet, some complex flows won’t fit. When they arise, we can introduce a new version or companion spec. The goal here is to establish a solid, interoperable foundation we can build on. Thanks for the feedback!

josepot avatar Sep 25 '25 13:09 josepot

I see, so for transaction extensions containing signatures, the caller will send those extensions with invalid transaction e.g. 0x000.. , then the implementer will override the caller "preference", or omit the extension and expect the implementer to append it.

In our example d2 will be an invalid transaction, and VerifySignature will be Some(0x000...) or maybe omitted but it seems clearer to send some.

EDIT: maybe we could also introduce the concept of holes, here signature would be send as holes to be filled by the wallet, if the wallet fails to fill all holes then the overall process has failed (or only partially succeeded and result still contains some hole).

gui1117 avatar Sep 26 '25 02:09 gui1117

This is an excellent design. LunoKit will continue to follow up on this proposal!

wbh1328551759 avatar Sep 26 '25 06:09 wbh1328551759

I see, so for transaction extensions containing signatures, the caller will send those extensions with invalid transaction e.g. 0x000.. , then the implementer will override the caller "preference", or omit the extension and expect the implementer to append it

Nope, it simply won't send them at all. There simply won't be an entry in the Array of extensions for them. The reason being that those extensions are for the implementer to "fill".

EDIT: maybe we could also introduce the concept of holes

The spec already has an implicit concept of "holes", although it would probably be a good idea to make this concept more explicit, yes. The holes are basically the extensions that were not provided, but that are needed. That's why the consumer also passes the txExtVersion field. So, that the implementer can figure out which are the extensions missing (the "holes", basically). Now, if the implementer doesn't know how to fill those holes, then they will just throw, but if they do know how to fill them then they should do so.

It's a bit similar to the concept of "partial application" used in FP. In fact, one nice accident about this interface is that it's composable, in the sense that it enables the creation of custom signers that can encapsulate certain complex flows. However, that's just a "happy accident".

Realistically speaking, there is only one extension that the consumer should ALWAYS provide to every implementer, which is CheckGenesis.

For example, if we were asking Talisman or Subwallet to create a transaction, the ChewckGenesis would be the only extension that those implementers would actually need (as long as they know how to connect to that chain). The reason being that those implementers are connected to the chain, and if the consumer doesn't have any preferences for the rest of the extensions, then these "online" implementers are able to "fill those gaps". However, if the implementer was an offline signer (like Vault, or the PJS extension), then the consumer should also -at least- provide the CheckNonce and the CheckMortality extensions. I mean, strictly speaking the CheckMortality wouldn't be necessary, b/c the implementer could create an immortal transaction, but that would be a bad default (an immortal transaction should only be created at the explicit request of the user, not as a default). That being said, the spec encourages consumers to "fill as many holes as they are able to fill":

The consumer SHOULD provide every extension that is relevant to them.

Anyways, the fact that these things were not clear to you made me realized that these things should be further clarified. I'm working on these improvements. Thanks again @gui1117 !

josepot avatar Sep 26 '25 07:09 josepot

ok, I may be wrong but I worry not sending the transaction extension as a mean to let the Implementer fill it may not be fine-grained enough and potentially ambiguous.

For example this is the main transaction extension we use in PoP: https://github.com/paritytech/polkadot-sdk/blob/ad4ae97793083c2b08369fe7b0e63331e7753a4c/substrate/frame/people/src/extension.rs#L41-L80

And the pipeline is (VerifySignature, AsPerson, ... traditional extensions like CheckNonce etc..) You typically want to either have:

  • (VerifySignature(None), AsPerson(Some(AsPersonalAliasWithProof(proof, ring_index, context)), ..) so you authenticate as an alias using a ring-vrf proof.
  • (VerifySignature(None), AsPerson(Some(AsPersonalIdentityWithProof(proof)), ..) so you authenticate as a person using a signature.
  • (VerifySignature(Some(usual account signature), AsPerson(Some(AsPersonalAliasWithAccount(nonce)), ..) to authenticate as an alias but using the on-chain "proxy" for the alias.
  • (VerifySignature(Some(usual account signature), AsPerson(Some(AsPersonalIdentityWithAccount(nonce)), ..)` to authenticate as a person but using the on-chain "proxy" for the person.

So if VerifySignature is VerifySignature(None) and if AsPerson is omitted then maybe the implementer can indeed deduct that AsPerson is meant to be used, but even then both AsPerson(Some(AsPersonalAliasWithProof(proof, ring_index, context)), ..) or AsPerson(Some(AsPersonalIdentityWithProof(proof)), ..) are valid choices. And the Caller wants to give the value for the context. (ring_index could be deducted, but sending it as well could be efficient).

If AsPerson(Some(AsPersonalIdentityWithAccount(nonce)), ..) is used and VerifySignature is omitted then indeed the implementer can deduct to use VerifySignature, like a typical transaction, which is already a huge improvement over signPayload.

Maybe the complex flow like with AsPerson is not the goal for this spec. But potentially we could have VerifySignature(None), AsPerson(Some(AsPersonalAliasWithProof(hole)) and some full-feature Implementers could know how to fill this whole and would do it.

Also another note but not an issue: The order of hole-filling matters because signature earlier in the pipeline usually sign the whole subsequent pipeline which sometimes contain signature. In the case we have (A, B, C) and both A and B contains signature, if an implement support A only then it needs B to defined before creating the signature in A. But this is not an issue just a note.

gui1117 avatar Sep 26 '25 08:09 gui1117

ok, I may be wrong but I worry not sending the transaction extension as a mean to let the Implementer fill it may not be fine-grained enough and potentially ambiguous.

For example this is the main transaction extension we use in PoP: https://github.com/paritytech/polkadot-sdk/blob/ad4ae97793083c2b08369fe7b0e63331e7753a4c/substrate/frame/people/src/extension.rs#L41-L80

I should have included a “Goals” and “Non-Goals” section to make this explicit. You’re raising a very valid point, and it’s not something we’ve overlooked. We did consider this when designing the spec. However, we believe it’s better addressed through separate, complementary specifications that focus on what -for the lack of a better term- I will refer to as: “Signer Discoverability.”

Let me explain:

You may have noticed that this spec is intentionally loose in one key area: the signer field.

/**
 * Signer selection hint. Allows the implementer to identify which private key / scheme to use.
 * - Use a wallet-defined handle (e.g., address/SS58, account name, etc.). This identifier
 *   was previously made available to the consumer.
 * - Set `null` to let the implementer pick the signer (or if the signer is implied).
 */
signer: string | null;

Note the phrase: “This identifier was previously made available to the consumer.”

The point is that this spec does not define how different signing interfaces become known to the consumer. We expect that to be handled by other specs: ones that define both the types of signers that can exist and how they should be surfaced or discovered.

For example, a signer might be:

  • a simple account,
  • a multi-sig,
  • a pure proxy,
  • a proxy controlled by a multi-sig,
  • a person or alias, and so on.

When the consumer is made aware of the available "signer"s, it’s important that they can understand what kind of signer they’re interacting with. For that to work, we’ll need specs that describe these signer types in a composable, consistent way.

In short: this is indeed an important consideration, but we think it’s better to first define how to interact with implementers when creating a transaction, and then tackle how different signer types are discovered and represented.

However, this is a very good point, b/c different signers are going to use different kinds of extensions, for sure!

josepot avatar Sep 26 '25 12:09 josepot

OK I understand the goal and scope now, and it looks very good.

This abstract other the creation of transaction, allowing dapps to only send their intention: most of the time just the call and CheckGenesis. to be dispatched with a signed origin. Thus the Implementer can use proxies, multisig or other mean that result in the user-wanted signed origin.

I think this proposal may not entirely support offline Implementer for some other origin. For signed origin this spec is enough because VerifySignature only contains the signature, the offline Implementer can be given the whole pipeline and return the transaction that includes the VerifySignature. But for Alias origins the caller would need to pass the ring_index, the context to sign for, and also the other member of the ring to be able to generate the ring-vrf signature. But this is out of scope of this API. signPayload doesn't support this anyway, probably a dedicated API will need to be defined so offline Implementer could sign for an Alias. On the other hand createTransaction supports signing for alias for online Implementers. Caller would send, to an Implementer signing for an Alias, the intention: Call + CheckGenesis.

All in all in the future maybe dapps should only request the intention to dispatch a call with an origin (None or Signed or Alias) in a chain. Everything else seems good to abstract away.

gui1117 avatar Sep 29 '25 07:09 gui1117

@josepot Thank you for the detailed proposal and clear rationale. This is an important step toward improving compatibility and flexibility across the ecosystem. We've added this to our task list and will begin planning implementation shortly.

ap211unitech avatar Sep 29 '25 12:09 ap211unitech

@josepot Thank you for the detailed proposal and clear rationale. This is an important step toward improving compatibility and flexibility across the ecosystem. We've added this to our task list and will begin planning implementation shortly.

That's great to hear. However, which part are you planning to tackle:

  • The feature-detection, plus consuming the new createTransaction method if available from the library.
  • The necessary changes on the extension to make the new createTransaction API available to DApps.

Or both?

josepot avatar Sep 29 '25 12:09 josepot

For now, we will focus on the API changes only. We will be happy to receive any contributions on the extension side.

ap211unitech avatar Sep 29 '25 13:09 ap211unitech

Hi everyone, We are slowly starting implementation of createTransaction method for Signer in @polkadot/api. We created this design document to clearly define the problem, scope of work, files that likely need changes etc. However, TS interface is almost similar to what @josepot provided (with one minor change). If anyone has suggestions or feedback, feel free to share!

https://hackmd.io/@Pjmly7wGRUayQbM_OPJDzA/SJwOGWWpex

ap211unitech avatar Oct 14 '25 05:10 ap211unitech

Thanks for the update, and for starting to work on it! 💪

However, TS interface is almost similar to what @josepot provided (with one minor change)

Is the small change you mentioned the addition of the genesisHash property inside the context?

If so, that field is actually redundant: the genesis hash should be inferred from the extensions. I’d recommend removing it to keep a single source of truth; otherwise, we risk introducing undocumented behavior when the genesisHash doesn’t match what’s in the extensions.

josepot avatar Oct 14 '25 07:10 josepot

Hi @josepot — thank you for your time and effort pushing this proposal. It clearly addresses blockers for the success of product developers, especially around transaction extensions, which are a core feature of Polkadot.

One long-standing issue we face is the inability for browser wallets to expose alternative signing schemes for message or transaction signing. Today, browser wallets mostly expose signPayload using sr25519, which is non-deterministic. This makes it impossible to request deterministic signatures (e.g., ed25519).

For that reason, have you considered introducing a complementary createMessage (or similar) API that allows wallets to expose additional signing schemes—e.g., ed25519, ECDSA?

BigTava avatar Nov 17 '25 14:11 BigTava

One long-standing issue we face is the inability for browser wallets to expose alternative signing schemes for message or transaction signing.

The createTransaction interface, allows creating transactions using any signing scheme which is supported by the target chain. As it was already explained in this forum post. This interface it's simply a generalization over the PAPI signer, which supports all signing schemes.

Would the proposed createTransaction interface allow implementers to expose additional signing schemes—e.g., ed25519—for messages or transactions?

The createTransaction interface is purposely decoupled from the signing scheme. Please, read the previous comments, this one in particular.

The point being that the consumer of createTransaction doesn't care which scheme it's being used.

(Also, have you considered a createOperation naming instead, since not all operations involve creating transactions?)

No. This RFC serves one purpose, and one purpose only: creating transactions.

josepot avatar Nov 17 '25 20:11 josepot