Skip to content

Commit a7fae95

Browse files
authored
Merge pull request #55 from paritytech/valentunn-rfc20
Create 0020-create-transaction.md
2 parents b317eed + 2fbcab6 commit a7fae95

5 files changed

Lines changed: 225 additions & 72 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# RFC-0020: Remove `context` from `create_transaction` and mirror in Accounts Protocol
2+
3+
| | |
4+
| --------------- | ----------------------------------------------------------- |
5+
| **RFC Number** | 20 |
6+
| **Start Date** | 2026-05-11 |
7+
| **Description** | Formalize and fill the gaps for `create_transaction` calls. |
8+
| **Authors** | Valentin Sergeev |
9+
10+
## Summary
11+
12+
Tighten the contract of `host_create_transaction` and `host_create_transaction_with_legacy_account`:
13+
14+
1. Drop the `context` field from `TxPayload`.
15+
2. Parametrize `TxPayload` by signer type, folding the standalone `account_id` parameter into the payload.
16+
3. Add an explicit `genesis_hash` field at the payload root so chain identification does not piggy-back on `CheckGenesis` extension decoding.
17+
4. Define the Accounts Protocol message pairs the Host uses to delegate signing to the Account Holder.
18+
19+
## Motivation
20+
21+
**`context` does not belong on the wire.** `TxPayloadContext` (`metadata`, `token_symbol`, `token_decimals`, `best_block_height`) came from the Polkadot-API offline-signer proposal ([polkadot-js/api#6213](https://github.com/polkadot-js/api/issues/6213)), where the dApp is the only online participant and must hand the signer everything it needs. Our signer (Host or Account Holder) is always online: both hold a live chain connection and can derive every one of these fields themselves from the payload's `genesis_hash`. Keeping `context` is harmful on three counts:
22+
23+
- **Security.** A product shipping its own `metadata` blob can influence how the signer interprets the call. The signer is the security boundary; context must come from the chain, not the caller.
24+
- **Bytes.** Runtime metadata is hundreds of kilobytes, paid on every signing request and every AP round-trip when AutoSigning is not granted.
25+
- **Redundancy.** Token symbol/decimals come from chain spec; best block comes from the signer's own follow. Nothing in `context` is unique to the product.
26+
27+
**`signer: Option<str>` is overloaded.** Today the field means a different thing per call: `host_create_transaction` ignores it and uses a separate `account_id: ProductAccountId` parameter; `host_create_transaction_with_legacy_account` populates it as a hex-encoded `AccountId`. Stringification of an already-typed identifier, a dead field on one variant, and no compile-time guarantee the right kind of signer was supplied. Parametrizing the payload type lets each call site state its signer type precisely and removes the duplicated `account_id` parameter.
28+
29+
**No version envelope on the host API.** Action enums (`host_create_transaction_request(Versioned<...>)`) already carry a version at the message layer, so a second `VersionedTxPayload` envelope inside the payload is redundant. The version envelope only remains on the Accounts Protocol side, where the flat `SsoMessageContent` enum gives the payload no other place to carry its version.
30+
31+
**Explicit `genesis_hash` at the payload root.** Without it the signer has to locate the `CheckGenesis` extension and read its `additional_signed` to learn which chain the transaction targets — but decoding any extension at all already requires the chain's runtime metadata, which means you need the chain *before* you can decode. The current workaround relies on `CheckGenesis`'s SCALE encoding happening to be a raw 32-byte H256 the signer can lift out without a real decode pass; that is brittle and conflates two responsibilities. Carrying `genesis_hash` as a top-level field breaks the cycle: chain identification happens first, extension decoding happens against the correctly-selected runtime, and no extension is privileged over the others.
32+
33+
## Stakeholders
34+
35+
- **Product developers** — construct slimmer payloads; no metadata bundling.
36+
- **Host developers** — derive any needed runtime context from the chain instead of the payload.
37+
- **Account Holder developers (Mobile App)** — implement the new AP message pairs end to end, including deriving signing context.
38+
39+
## Explanation
40+
41+
### TrUAPI
42+
43+
`TxPayloadContext` is removed. `TxPayload` becomes generic over `Signer`; `signer` becomes required and typed. The `V1` suffix is dropped — versioning lives on the action enum.
44+
45+
Before:
46+
47+
```rust
48+
struct TxPayloadContext {
49+
metadata: Vec<u8>,
50+
token_symbol: str,
51+
token_decimals: u32,
52+
best_block_height: u32
53+
}
54+
55+
struct TxPayloadV1 {
56+
signer: Option<str>,
57+
call_data: Vec<u8>,
58+
extensions: Vec<TxPayloadExtensionV1>,
59+
tx_ext_version: u8,
60+
context: TxPayloadContext
61+
}
62+
63+
fn host_create_transaction(
64+
account_id: ProductAccountId,
65+
payload: VersionedTxPayload
66+
) -> Result<Vec<u8>, CreateTransactionErr>;
67+
68+
fn host_create_transaction_with_legacy_account(
69+
payload: VersionedTxPayload
70+
) -> Result<Vec<u8>, CreateTransactionErr>;
71+
```
72+
73+
After:
74+
75+
```rust
76+
struct TxPayload<Signer> {
77+
signer: Signer,
78+
genesis_hash: GenesisHash,
79+
call_data: Vec<u8>,
80+
extensions: Vec<TxPayloadExtension>,
81+
tx_ext_version: u8
82+
}
83+
84+
struct TxPayloadExtension {
85+
id: str,
86+
extra: Vec<u8>,
87+
additional_signed: Vec<u8>
88+
}
89+
90+
fn host_create_transaction(
91+
payload: TxPayload<ProductAccountId>
92+
) -> Result<Vec<u8>, CreateTransactionErr>;
93+
94+
fn host_create_transaction_with_legacy_account(
95+
payload: TxPayload<AccountId>
96+
) -> Result<Vec<u8>, CreateTransactionErr>;
97+
```
98+
99+
`host_create_transaction` has no production consumers, so the shape changes in place.
100+
101+
The codec on the wire (JAM codec) has no native generics — `TxPayload<Signer>` is type-level shorthand for one concrete encoding per call site, not a generic struct on the wire.
102+
103+
The `CheckGenesis` extension is still expected to appear in `extensions` since the chain's `InheritedImplication` includes it; the root `genesis_hash` and `CheckGenesis.additional_signed` will carry the same value. Signers MAY cross-check the two and reject a payload whose root `genesis_hash` disagrees with the `CheckGenesis` extension, but the chain-identification path uses the root field only.
104+
105+
### Accounts Protocol
106+
107+
Today `SsoMessageContent` has no `create_transaction` mirror. Add one pair, covering only the product-account variant — `host_create_transaction_with_legacy_account` is handled entirely by the Host (which already knows the user's imported legacy accounts) and is never forwarded to the Account Holder, so no AP mirror is needed for it.
108+
109+
```rust
110+
enum SsoMessageContent {
111+
// ... existing variants unchanged ...
112+
113+
/// Mirrors host_create_transaction.
114+
CreateTransactionRequest {
115+
payload: VersionedTxPayload<ProductAccountId>,
116+
},
117+
CreateTransactionResponse {
118+
responding_to: SsoSessionRequestId,
119+
signed_transaction: BSResult<SignedTransaction, String>,
120+
}
121+
}
122+
123+
enum VersionedTxPayload<Signer> {
124+
V1(TxPayload<Signer>)
125+
}
126+
127+
type SignedTransaction = Vec<u8>;
128+
```
129+
130+
The Accounts Protocol retains `VersionedTxPayload` because, unlike host API actions, AP messages are not individually versioned per call — `SsoMessageContent` is one flat enum, so the version envelope has to live on the payload itself for the AP to evolve `TxPayload` independently of the rest of the message set.
131+
132+
On receipt, the Account Holder reads `payload.signer: ProductAccountId`, picks the chain identified by `payload.genesis_hash`, derives signing context against that runtime, presents the transaction, and on approval returns the encoded signed transaction. The Host maps the response back to `Result<Vec<u8>, CreateTransactionErr>`.
133+
134+
## Drawbacks
135+
136+
- **AH must fetch metadata for any chain a product transacts on.** Already true in practice — the AH needs metadata for its own native flows.
137+
- **No product-supplied `best_block_height`.** Products that want to pin mortality to a specific observed block must encode it inside `extensions` (the supported path anyway).
138+
139+
## Alternatives
140+
141+
- **Make `context` optional / drop only `metadata`.** Doesn't address the security concern (products can still ship a bogus blob the signer might trust).
142+
- **Introduce a `V2` payload.** Unnecessary — `TxPayload` has no production consumers; the shape changes in place.
143+
- **Keep `signer: Option<str>` + separate `account_id`.** Dead field on one variant, stringification on the other, no type guarantee at the call site.
144+
- **`enum Signer { Product, Legacy }` instead of a generic.** Collapses the two variants back into one and forces a runtime tag check on a statically-dispatched flow.
145+
- **Recover the chain from `CheckGenesis.additional_signed` instead of an explicit field.** Works only because that extension's SCALE encoding happens to be identity; couples chain identification to a single extension's wire shape and forces signers to lift one extension's payload before the rest can even be decoded.
146+
147+
## Prior art
148+
149+
- Polkadot-API offline signer proposal — [polkadot-js/api#6213](https://github.com/polkadot-js/api/issues/6213) — origin of `TxPayloadContext`; assumes an offline signer that does not match this topology.
150+
- RFC-0010 (W3S Allowance) — established the pattern of co-documenting a TrUAPI call with its Accounts Protocol companion.

rust/crates/truapi/src/api/signing.rs

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,14 @@ pub trait Signing: Send + Sync {
2121
///
2222
/// ```ts
2323
/// const result = await truapi.signing.createTransaction({
24-
/// productAccountId: {
24+
/// signer: {
2525
/// dotNsIdentifier: "truapi-playground.dot",
2626
/// derivationIndex: 0,
2727
/// },
28-
/// payload: {
29-
/// tag: "V1",
30-
/// value: {
31-
/// callData: "0x0000",
32-
/// extensions: [],
33-
/// txExtVersion: 0,
34-
/// context: {
35-
/// metadata: "0x",
36-
/// tokenSymbol: "DOT",
37-
/// tokenDecimals: 10,
38-
/// bestBlockHeight: 0,
39-
/// },
40-
/// },
41-
/// },
28+
/// genesisHash: "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3",
29+
/// callData: "0x0000",
30+
/// extensions: [],
31+
/// txExtVersion: 0,
4232
/// });
4333
/// result.match(
4434
/// (value) => console.log(value),
@@ -54,24 +44,15 @@ pub trait Signing: Send + Sync {
5444
Err(CallError::unavailable())
5545
}
5646

57-
/// Construct a signed transaction for a non-product account.
47+
/// Construct a signed transaction for a non-product (legacy) account.
5848
///
5949
/// ```ts
6050
/// const result = await truapi.signing.createTransactionWithLegacyAccount({
61-
/// payload: {
62-
/// tag: "V1",
63-
/// value: {
64-
/// callData: "0x0000",
65-
/// extensions: [],
66-
/// txExtVersion: 0,
67-
/// context: {
68-
/// metadata: "0x",
69-
/// tokenSymbol: "DOT",
70-
/// tokenDecimals: 10,
71-
/// bestBlockHeight: 0,
72-
/// },
73-
/// },
74-
/// },
51+
/// signer: "0x0000000000000000000000000000000000000000000000000000000000000000",
52+
/// genesisHash: "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3",
53+
/// callData: "0x0000",
54+
/// extensions: [],
55+
/// txExtVersion: 0,
7556
/// });
7657
/// result.match(
7758
/// (value) => console.log(value),

rust/crates/truapi/src/v01/signing.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use parity_scale_codec::{Decode, Encode};
22

33
use super::ProductAccountId;
44

5+
/// Full Substrate extrinsic signing payload with all fields needed for signature
6+
/// generation.
57
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
68
pub struct HostSignPayloadRequest {
79
/// Product account that will sign this payload.
@@ -38,18 +40,22 @@ pub struct HostSignPayloadRequest {
3840
pub with_signed_transaction: Option<bool>,
3941
}
4042

43+
/// Raw data to sign -- either binary bytes or a string message.
4144
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
4245
pub enum RawPayload {
46+
/// Raw binary data to sign.
4347
Bytes {
4448
/// Raw binary payload bytes.
4549
bytes: Vec<u8>,
4650
},
51+
/// String message to sign.
4752
Payload {
4853
/// String payload to sign.
4954
payload: String,
5055
},
5156
}
5257

58+
/// A raw signing request pairing an account with the payload to sign.
5359
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
5460
pub struct HostSignRawRequest {
5561
/// Product account that will sign this payload.
@@ -58,6 +64,7 @@ pub struct HostSignRawRequest {
5864
pub payload: RawPayload,
5965
}
6066

67+
/// Result of a signing operation.
6168
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
6269
pub struct HostSignPayloadResponse {
6370
/// The cryptographic signature.
@@ -66,14 +73,21 @@ pub struct HostSignPayloadResponse {
6673
pub signed_transaction: Option<Vec<u8>>,
6774
}
6875

76+
/// Signing operation error.
6977
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
7078
pub enum HostSignPayloadError {
79+
/// Payload could not be deserialized.
7180
FailedToDecode,
81+
/// User rejected signing.
7282
Rejected,
83+
/// Not authenticated.
7384
PermissionDenied,
85+
/// Catch-all.
7486
Unknown { reason: String },
7587
}
7688

89+
/// Sign raw bytes with a non-product (legacy) account. The signer field
90+
/// identifies which legacy account to use.
7791
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
7892
pub struct HostSignRawWithLegacyAccountRequest {
7993
/// Signer address (SS58 or hex) of the legacy account.
@@ -82,6 +96,9 @@ pub struct HostSignRawWithLegacyAccountRequest {
8296
pub payload: RawPayload,
8397
}
8498

99+
/// Sign a Substrate extrinsic payload with a non-product (legacy) account.
100+
/// Contains the same fields as [`HostSignPayloadRequest`] minus `address`
101+
/// (replaced by `signer`).
85102
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
86103
pub struct HostSignPayloadWithLegacyAccountRequest {
87104
/// Signer address (SS58 or hex) of the legacy account.
@@ -90,18 +107,14 @@ pub struct HostSignPayloadWithLegacyAccountRequest {
90107
pub payload: HostSignPayloadRequest,
91108
}
92109

110+
/// Response containing a created transaction.
93111
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
94112
pub struct HostCreateTransactionResponse {
95113
/// SCALE-encoded signed transaction.
96114
pub transaction: Vec<u8>,
97115
}
98116

99-
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
100-
pub struct HostCreateTransactionWithLegacyAccountRequest {
101-
/// Versioned transaction payload to sign.
102-
pub payload: super::VersionedTxPayload,
103-
}
104-
117+
/// Response containing a transaction created with a non-product account.
105118
#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
106119
pub struct HostCreateTransactionWithLegacyAccountResponse {
107120
/// SCALE-encoded signed transaction.

0 commit comments

Comments
 (0)