mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
Add internal/substrate package containing the minimal Polkadot wire
primitives the bridge needs to sign + broadcast balances.transfer_keep_alive
extrinsics:
- SCALE codec primitives (compact ints, fixed-width LE ints, byte slices
bare + length-prefixed) — enough to encode ExtrinsicPayloadV4 + a v4
signed extrinsic. Not a general reflection-driven encoder.
- AccountId32 derivation from a compressed secp256k1 pubkey via
blake2_256 — matches sp_io::hashing::blake2_256 on the runtime side.
- SS58 base58check encoder + decoder with single-byte network prefixes
(Polkadot=0, Kusama=2, generic=42). Verified against the canonical
Alice fixture in both Polkadot mainnet ("15oF4u...") and substrate
generic ("5Grwva...") forms.
- ExtrinsicPayloadV4 builder: SCALE(call) || era || nonce || tip ||
spec_version || tx_version || genesis_hash || block_hash, with the
>256-byte → blake2_256 hashing rule.
- v4 signed-extrinsic assembler: 0x84 version byte || MultiAddress::Id
signer (33B) || MultiSignature(scheme, sig) || era || nonce || tip ||
call, length-prefixed compact-style.
The substrate primitives are leaf code with no network I/O. Test vectors
are pinned against the Polkadot JS SDK and substrate runtime references.
320 lines
12 KiB
Go
320 lines
12 KiB
Go
package substrate
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Call — section + method index + args
|
|
// =============================================================================
|
|
//
|
|
// A substrate Call is a 2-byte CallIndex (pallet section, method) followed
|
|
// by the SCALE-encoded args. Resolving the (section, method) tuple
|
|
// generally requires a Metadata query against the chain, but for
|
|
// well-known pallets the indices are stable across runtime upgrades —
|
|
// pallet authors avoid changing them once published.
|
|
//
|
|
// For Polkadot mainnet's balances pallet (per the Polkadot Wiki +
|
|
// canonical runtime sources), the well-known indices used here:
|
|
//
|
|
// balances pallet section index: 5 (some runtimes use 6/4 — caller passes the index)
|
|
// method index transfer_keep_alive: 3
|
|
//
|
|
// Bridge config supplies the indices explicitly per network so a
|
|
// runtime-upgrade that bumps the section can be handled by re-emitting
|
|
// the YAML, not by code change.
|
|
|
|
// CallIndex pairs a pallet (section) index with a method index inside
|
|
// that pallet. SCALE-encoded as `section_byte || method_byte`.
|
|
type CallIndex struct {
|
|
Section uint8
|
|
Method uint8
|
|
}
|
|
|
|
// Encode returns the 2-byte SCALE encoding (section || method).
|
|
func (c CallIndex) Encode() []byte { return []byte{c.Section, c.Method} }
|
|
|
|
// EncodeBalancesTransferKeepAlive returns the SCALE-encoded Call bytes
|
|
// for `balances.transfer_keep_alive(dest: MultiAddress::Id(AccountId32), value: Compact<Balance>)`.
|
|
//
|
|
// dest is the recipient AccountId32. valuePlanck is the amount in
|
|
// the chain's smallest unit (planck for Polkadot, 1 DOT = 10^10 planck).
|
|
// callIdx pins the runtime-version-specific (section, method) tuple.
|
|
//
|
|
// Layout:
|
|
//
|
|
// section_byte || method_byte — call header (2B)
|
|
// MultiAddress::Id tag (0x00) || account_id (32B) — dest (33B)
|
|
// compact-encoded value — Compact<Balance>
|
|
func EncodeBalancesTransferKeepAlive(
|
|
callIdx CallIndex, dest [32]byte, valuePlanck *big.Int,
|
|
) []byte {
|
|
out := make([]byte, 0, 2+33+9)
|
|
out = append(out, callIdx.Encode()...)
|
|
// dest = MultiAddress::Id(AccountId32). Enum discriminant 0x00.
|
|
out = append(out, 0x00)
|
|
out = append(out, dest[:]...)
|
|
// value = Compact<Balance>. Always compact-encoded — even if it
|
|
// fits in u64, the runtime call type is Compact<u128>.
|
|
out = append(out, EncodeCompactBig(valuePlanck)...)
|
|
return out
|
|
}
|
|
|
|
// =============================================================================
|
|
// Era — extrinsic mortality
|
|
// =============================================================================
|
|
//
|
|
// Era controls how long an extrinsic is valid for inclusion. Immortal
|
|
// (single 0x00 byte) is the simplest and what we use — once signed,
|
|
// the extrinsic remains valid until reorg drops it. Mortal eras are
|
|
// 2 bytes encoding period + phase but require knowing the latest
|
|
// block hash to anchor the phase.
|
|
|
|
// EraImmortal is the canonical immortal-era SCALE encoding.
|
|
var EraImmortal = []byte{0x00}
|
|
|
|
// =============================================================================
|
|
// ExtrinsicPayloadV4 — what the signer signs
|
|
// =============================================================================
|
|
//
|
|
// The substrate signing payload is a fixed sequence of fields:
|
|
//
|
|
// bytes_bare(method) — the SCALE-encoded Call, no length prefix
|
|
// era — 1 or 2 bytes (immortal = 0x00)
|
|
// compact(nonce) — Compact<u32>
|
|
// compact(tip) — Compact<u128>
|
|
// u32_le(spec_version) — chain runtime spec version
|
|
// u32_le(transaction_version) — chain runtime transaction version
|
|
// h256(genesis_hash) — 32 bytes
|
|
// h256(block_hash) — 32 bytes; equals genesis_hash for immortal eras
|
|
//
|
|
// If the resulting payload exceeds 256 bytes, substrate signs the
|
|
// blake2_256 hash of the payload INSTEAD of the raw payload. This is
|
|
// a security/efficiency split — fixed-size messages on the wire for
|
|
// large transactions, exact messages for short ones.
|
|
|
|
// PayloadOptions wires up the per-chain parameters needed to build
|
|
// the signing payload. spec_version + transaction_version come from
|
|
// `state_getRuntimeVersion`; genesis_hash from `chain_getBlockHash(0)`.
|
|
type PayloadOptions struct {
|
|
// Nonce is the signer's transaction count. Query via
|
|
// `system_accountNextIndex`.
|
|
Nonce uint32
|
|
// Tip is the fee tip in the chain's smallest unit. 0 = no tip,
|
|
// standard validator behaviour. SCALE-encoded as Compact<u128>.
|
|
Tip *big.Int
|
|
// SpecVersion is the runtime spec version. From
|
|
// `state_getRuntimeVersion`.
|
|
SpecVersion uint32
|
|
// TransactionVersion is the transaction format version. Also from
|
|
// state_getRuntimeVersion. Important — different runtimes ship
|
|
// distinct values, and signing-payload checks reject mismatches.
|
|
TransactionVersion uint32
|
|
// GenesisHash is the 32-byte chain genesis block hash. From
|
|
// `chain_getBlockHash(0)`.
|
|
GenesisHash [32]byte
|
|
// BlockHash is the block at which the era anchors. For immortal
|
|
// extrinsics, set this to GenesisHash. Required because the runtime
|
|
// rejects payloads where the block hash doesn't match the era window.
|
|
BlockHash [32]byte
|
|
}
|
|
|
|
// BuildSigningPayload assembles the bytes the signer must produce a
|
|
// signature over. If the payload exceeds 256 bytes, the returned
|
|
// `payloadOrHash` is blake2_256(payload); otherwise it's the raw
|
|
// payload bytes. `rawPayload` is always the un-hashed concatenation —
|
|
// callers may want it for debug logs / tests.
|
|
func BuildSigningPayload(callBytes []byte, opts PayloadOptions) (payloadOrHash, rawPayload []byte) {
|
|
if opts.Tip == nil {
|
|
opts.Tip = big.NewInt(0)
|
|
}
|
|
var buf bytes.Buffer
|
|
buf.Write(EncodeBytesBare(callBytes))
|
|
buf.Write(EraImmortal)
|
|
buf.Write(EncodeCompactU64(uint64(opts.Nonce)))
|
|
buf.Write(EncodeCompactBig(opts.Tip))
|
|
buf.Write(EncodeU32LE(opts.SpecVersion))
|
|
buf.Write(EncodeU32LE(opts.TransactionVersion))
|
|
buf.Write(opts.GenesisHash[:])
|
|
buf.Write(opts.BlockHash[:])
|
|
rawPayload = buf.Bytes()
|
|
if len(rawPayload) > 256 {
|
|
h := Blake2_256(rawPayload)
|
|
return h[:], rawPayload
|
|
}
|
|
return rawPayload, rawPayload
|
|
}
|
|
|
|
// =============================================================================
|
|
// SignedExtrinsic assembly — v4 wire shape
|
|
// =============================================================================
|
|
//
|
|
// A signed extrinsic on the wire is a SCALE-encoded byte string with
|
|
// a compact length prefix:
|
|
//
|
|
// compact_length || extrinsic_body
|
|
//
|
|
// The extrinsic_body is itself the concatenation of:
|
|
//
|
|
// version_byte — 0x84 = (0x80 signed-bit | 0x04 V4)
|
|
// multi_address(signer) — 0x00 || account_id (33 B for ::Id)
|
|
// multi_signature(scheme,sig) — 0x02 || sig (66 B for ECDSA r||s||v)
|
|
// era — 1 or 2 bytes (immortal = 0x00)
|
|
// compact(nonce) — Compact<u32>
|
|
// compact(tip) — Compact<u128>
|
|
// method — SCALE-encoded Call (raw bytes)
|
|
//
|
|
// The signer field carries the *signer's* identity, not the chain's
|
|
// runtime metadata — so the bridge can construct it client-side
|
|
// without an on-chain lookup.
|
|
|
|
// MultiSignatureScheme tags which signature scheme the bytes that
|
|
// follow it use. The values are SCALE enum discriminants matching the
|
|
// substrate `MultiSignature` enum in `sp_runtime::MultiSignature`.
|
|
type MultiSignatureScheme uint8
|
|
|
|
const (
|
|
// MultiSigEd25519 — bytes are a 64-byte Ed25519 signature.
|
|
MultiSigEd25519 MultiSignatureScheme = 0
|
|
// MultiSigSr25519 — bytes are a 64-byte Sr25519 signature.
|
|
MultiSigSr25519 MultiSignatureScheme = 1
|
|
// MultiSigEcdsa — bytes are a 65-byte ECDSA (r || s || v) signature
|
|
// over secp256k1.
|
|
MultiSigEcdsa MultiSignatureScheme = 2
|
|
)
|
|
|
|
// signedExtrinsicVersion is the canonical v4 signed prefix:
|
|
// the high bit (0x80) marks "signed", low nibble holds the version.
|
|
const signedExtrinsicVersion byte = 0x84
|
|
|
|
// AssembleSignedExtrinsic builds the wire-ready bytes for a v4 signed
|
|
// extrinsic.
|
|
//
|
|
// signerAccountID — 32-byte AccountId32 of the signer
|
|
// sigScheme — MultiSigEcdsa for our path
|
|
// signature — 65 bytes for ECDSA (r || s || v)
|
|
// callBytes — SCALE-encoded Call (from EncodeBalances...)
|
|
// era,nonce,tip — same values used to build the signing payload
|
|
//
|
|
// Returns the SCALE-prefixed (compact length || body) bytes ready to
|
|
// pass to `author_submitExtrinsic` as 0x-prefixed hex.
|
|
func AssembleSignedExtrinsic(
|
|
signerAccountID [32]byte,
|
|
sigScheme MultiSignatureScheme,
|
|
signature []byte,
|
|
callBytes []byte,
|
|
nonce uint32,
|
|
tip *big.Int,
|
|
) ([]byte, error) {
|
|
if err := validateSignatureSize(sigScheme, signature); err != nil {
|
|
return nil, err
|
|
}
|
|
if tip == nil {
|
|
tip = big.NewInt(0)
|
|
}
|
|
var body bytes.Buffer
|
|
// 1. version byte
|
|
body.WriteByte(signedExtrinsicVersion)
|
|
// 2. signer — MultiAddress::Id(AccountId32). Tag 0x00 + 32 B.
|
|
body.WriteByte(0x00)
|
|
body.Write(signerAccountID[:])
|
|
// 3. signature — MultiSignature::Scheme(<sig>). Tag = scheme byte.
|
|
body.WriteByte(byte(sigScheme))
|
|
body.Write(signature)
|
|
// 4. era — immortal
|
|
body.Write(EraImmortal)
|
|
// 5. nonce — Compact<u32>
|
|
body.Write(EncodeCompactU64(uint64(nonce)))
|
|
// 6. tip — Compact<u128>
|
|
body.Write(EncodeCompactBig(tip))
|
|
// 7. method — raw SCALE-encoded Call
|
|
body.Write(callBytes)
|
|
|
|
// Length-prefix the whole body — this is what gets hex-encoded
|
|
// onto the wire.
|
|
out := append(EncodeCompactU64(uint64(body.Len())), body.Bytes()...)
|
|
return out, nil
|
|
}
|
|
|
|
// validateSignatureSize refuses a signature length that doesn't match
|
|
// the declared scheme. Surfaces wire-format bugs early.
|
|
func validateSignatureSize(scheme MultiSignatureScheme, sig []byte) error {
|
|
switch scheme {
|
|
case MultiSigEd25519, MultiSigSr25519:
|
|
if len(sig) != 64 {
|
|
return fmt.Errorf("substrate: %d sig must be 64 bytes, got %d", scheme, len(sig))
|
|
}
|
|
case MultiSigEcdsa:
|
|
if len(sig) != 65 {
|
|
return fmt.Errorf("substrate: ECDSA sig must be 65 bytes (r||s||v), got %d", len(sig))
|
|
}
|
|
default:
|
|
return fmt.Errorf("substrate: unknown signature scheme %d", scheme)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Extrinsic hash
|
|
// =============================================================================
|
|
//
|
|
// The canonical extrinsic hash substrate's RPC + block explorers
|
|
// reference is blake2_256 of the encoded body (NOT the length-prefixed
|
|
// wire form). When `author_submitExtrinsic` returns a hash, this is
|
|
// what it returns.
|
|
|
|
// ExtrinsicHash computes the canonical 0x-prefixed extrinsic hash
|
|
// from the wire-ready bytes (output of AssembleSignedExtrinsic).
|
|
func ExtrinsicHash(wireBytes []byte) string {
|
|
// Strip the leading compact length prefix; hash only the body.
|
|
body := stripCompactPrefix(wireBytes)
|
|
h := Blake2_256(body)
|
|
return "0x" + hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// stripCompactPrefix peels off the leading compact length prefix
|
|
// produced by AssembleSignedExtrinsic. Robust to any compact mode the
|
|
// caller might land in. Returns the body bytes (or the original input
|
|
// if no plausible prefix is detected — wire bugs surface elsewhere).
|
|
func stripCompactPrefix(b []byte) []byte {
|
|
if len(b) == 0 {
|
|
return b
|
|
}
|
|
mode := b[0] & 0b11
|
|
switch mode {
|
|
case 0:
|
|
return b[1:]
|
|
case 1:
|
|
return b[2:]
|
|
case 2:
|
|
return b[4:]
|
|
case 3:
|
|
numBytes := int(b[0]>>2) + 4
|
|
if 1+numBytes > len(b) {
|
|
return b
|
|
}
|
|
return b[1+numBytes:]
|
|
}
|
|
return b
|
|
}
|
|
|
|
// =============================================================================
|
|
// Utility — hex encoding of the wire bytes
|
|
// =============================================================================
|
|
|
|
// HexEncode returns "0x"+hex(b). Convenience for the broadcast layer
|
|
// where the substrate RPC `author_submitExtrinsic` takes a single
|
|
// 0x-prefixed hex string parameter.
|
|
func HexEncode(b []byte) string { return "0x" + hex.EncodeToString(b) }
|
|
|
|
// HexDecode parses an optionally-0x-prefixed hex string. Surfaces a
|
|
// clear error on malformed input.
|
|
func HexDecode(s string) ([]byte, error) {
|
|
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
|
|
return hex.DecodeString(s)
|
|
}
|