feat: accept pre-encoded extension values, validated by round trip

A dapp hands a wallet an `era` it encoded itself, as opaque bytes. There is no
way to render those as a variant without knowing the era algorithm, which is
exactly the kind of knowledge this crate refuses to hold — so they are accepted
as `Supplied::Raw`.

Not on trust, though. Raw bytes are decoded against the type the runtime
declares and re-encoded, and anything that does not come back identical is
refused: a short read, trailing bytes, a non-canonical compact. Appending them
unchecked would mean signing a payload whose shape nobody verified, and the only
report of that is `BadProof` from a node — which is also what a wrong key looks
like.

Also makes a single-field struct transparent whether or not its field is named,
so `CheckMetadataHash { mode }` takes `"Disabled"` the way `AccountId32([u8;32])`
takes its hex. Both wrappers are the runtime's choice, and the registry is what
says they are there.

Refs #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
2026-09-15 14:45:25 +03:00
co-authored by Claude Opus 5
parent 3a1611a92e
commit f1c51661df
6 changed files with 216 additions and 23 deletions
+58 -5
View File
@@ -13,6 +13,23 @@ use scale_value::{Composite, Primitive, Value, ValueDef};
use crate::runtime::{CodecError, Runtime};
/// A value for one half of one signed extension.
#[derive(Debug, Clone)]
pub enum Supplied {
/// Interpreted against the type the runtime declares — see
/// [`Runtime::json_to_value`].
Json(serde_json::Value),
/// Already SCALE-encoded by whoever is asking for the signature.
///
/// A dapp hands the wallet an `era` as opaque bytes, having encoded it
/// itself, and there is no way to render those as JSON without knowing the
/// era algorithm — which is exactly the kind of knowledge this crate refuses
/// to hold. So they are accepted, but **not on trust**: [`Runtime::encode_half`]
/// decodes them against the declared type and re-encodes them, and anything
/// that does not round-trip is rejected rather than signed.
Raw(Vec<u8>),
}
/// What the caller knows about one signed extension.
///
/// Both halves are optional because most extensions need neither: a zero-sized
@@ -21,8 +38,8 @@ use crate::runtime::{CodecError, Runtime};
/// is not an error; *omitting* one for a non-zero-sized type is.
#[derive(Debug, Clone, Default)]
pub struct ExtensionValue {
pub extra: Option<serde_json::Value>,
pub additional: Option<serde_json::Value>,
pub extra: Option<Supplied>,
pub additional: Option<Supplied>,
}
/// The two byte strings a signed extrinsic needs from its extensions.
@@ -150,7 +167,7 @@ impl Runtime {
fn encode_half(
&self,
ty: u32,
supplied: Option<&serde_json::Value>,
supplied: Option<&Supplied>,
identifier: &str,
out: &mut Vec<u8>,
) -> Result<(), CodecError> {
@@ -160,8 +177,44 @@ impl Runtime {
return Ok(());
}
let value = supplied.ok_or_else(|| CodecError::MissingExtension(identifier.to_string()))?;
let converted = self.json_to_value(value, ty)?;
let converted = match supplied.ok_or_else(|| {
CodecError::MissingExtension(identifier.to_string())
})? {
Supplied::Json(value) => self.json_to_value(value, ty)?,
// Round-tripped rather than appended. Bytes that decode against the
// declared type and re-encode to themselves are the *only* bytes the
// runtime could have meant; anything else — a short read, trailing
// bytes, a non-canonical compact — is a disagreement about the format
// that would otherwise be signed and only surface as `BadProof`.
Supplied::Raw(bytes) => {
let mut cursor = &bytes[..];
let value = self
.decode_checked(ty, &mut cursor)
.map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))?;
if !cursor.is_empty() {
return Err(CodecError::Encode(format!(
"{identifier}: {} trailing bytes",
cursor.len()
)));
}
let mut check = Vec::new();
scale_value::scale::encode_as_type(&value, ty, self.types(), &mut check)
.map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))?;
if check != *bytes {
return Err(CodecError::Encode(format!(
"{identifier}: supplied bytes do not round-trip against the runtime's type"
)));
}
out.extend_from_slice(bytes);
return Ok(());
}
};
scale_value::scale::encode_as_type(&converted, ty, self.types(), out)
.map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))