paritytech / paritytech/polkadot-cli
RFC: XCM file format for authoring cross-chain programs
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 10
- Forks
- 2
- Avg merge
- 12h 35m
- Merged PRs (30d)
- 4
Description
Problem
Building XCM programs for dot tx requires inline JSON with deeply nested {type, value} discriminated unions. This is error-prone, unreadable, and impossible to maintain.
Real example from scripts/setup-pusd.sh -- a single reserve transfer:
{"type":"V5","value":[{"type":"SetFeesMode","value":{"jit_withdraw":true}},{"type":"WithdrawAsset","value":[{"id":{"parents":0,"interior":{"type":"X2","value":[{"type":"PalletInstance","value":50},{"type":"GeneralIndex","value":3}]}},"fun":{"type":"Fungible","value":100000000000}}]},{"type":"InitiateTransfer","value":{"destination":{"parents":1,"interior":{"type":"X1","value":[{"type":"Parachain","value":1004}]}},"remote_fees":{"type":"ReserveDeposit","value":{"type":"Definite","value":[{"id":{"parents":0,"interior":{"type":"X2","value":[{"type":"PalletInstance","value":50},{"type":"GeneralIndex","value":3}]}},"fun":{"type":"Fungible","value":1000000000}}]}},"preserve_origin":false,"assets":[{"type":"ReserveDeposit","value":{"type":"Wild","value":{"type":"All"}}}],"remote_xcm":[{"type":"SetFeesMode","value":{"jit_withdraw":true}},{"type":"DepositAsset","value":{"assets":{"type":"Wild","value":{"type":"All"}},"beneficiary":{"parents":0,"interior":{"type":"X1","value":[{"type":"AccountId32","value":{"id":"0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"}}]}}}}]}}]}
XCM is essentially a programming language (XCM docs) with 51 instructions in V5, nested locations, asset types, and sub-programs. It deserves a proper file-based authoring experience.
Proposal
Add support for .xcm.yaml / .xcm.json files that can be passed to dot tx --file or a new dot xcm command.
Scope
v1 (this issue):
- Read XCM programs from a file with nicer syntax than raw JSON
- Variables/parameters (optional, nice-to-have)
- File extension:
.xcm.yamlor.xcm.json
Future work (not this issue):
- REPL with autocomplete from chain metadata (
dot xcm --repl) - Intent-level abstractions ("teleport 100 DOT to Alice on AssetHub")
- Linting/validation against chain metadata
- Dry-run simulation
dot xcmas a richer command that checks receiver-side arrival/errors
Ecosystem context
No XCM DSL or file format exists today. The ecosystem has:
- Raw JSON inline (current approach)
- ParaSpell SDK -- high-level fluent builder (JS/TS only, intent-level)
- polkadot-sdk #4736 -- Rust builder proposal, design phase
- No REPL, no
.xcmformat, no linter
Format comparison
Four options compared below using the same three example programs.
Reference: example programs in raw JSON
Small: simple teleport (3 instructions)
{
"type": "V5",
"value": [
{
"type": "WithdrawAsset",
"value": [
{
"id": { "parents": 1, "interior": { "type": "Here" } },
"fun": { "type": "Fungible", "value": 1000000000000 }
}
]
},
{
"type": "BuyExecution",
"value": {
"fees": {
"id": { "parents": 1, "interior": { "type": "Here" } },
"fun": { "type": "Fungible", "value": 1000000000000 }
},
"weight_limit": { "type": "Unlimited" }
}
},
{
"type": "DepositAsset",
"value": {
"assets": { "type": "Wild", "value": { "type": "All" } },
"beneficiary": {
"parents": 0,
"interior": {
"type": "X1",
"value": [
{
"type": "AccountId32",
"value": { "id": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" }
}
]
}
}
}
}
]
}
Medium: reserve transfer with fees (from setup-pusd.sh)
{
"type": "V5",
"value": [
{ "type": "SetFeesMode", "value": { "jit_withdraw": true } },
{
"type": "WithdrawAsset",
"value": [
{
"id": {
"parents": 0,
"interior": {
"type": "X2",
"value": [
{ "type": "PalletInstance", "value": 50 },
{ "type": "GeneralIndex", "value": 3 }
]
}
},
"fun": { "type": "Fungible", "value": 100000000000 }
}
]
},
{
"type": "InitiateTransfer",
"value": {
"destination": {
"parents": 1,
"interior": {
"type": "X1",
"value": [{ "type": "Parachain", "value": 1004 }]
}
},
"remote_fees": {
"type": "ReserveDeposit",
"value": {
"type": "Definite",
"value": [
{
"id": {
"parents": 0,
"interior": {
"type": "X2",
"value": [
{ "type": "PalletInstance", "value": 50 },
{ "type": "GeneralIndex", "value": 3 }
]
}
},
"fun": { "type": "Fungible", "value": 1000000000 }
}
]
}
},
"preserve_origin": false,
"assets": [
{
"type": "ReserveDeposit",
"value": { "type": "Wild", "value": { "type": "All" } }
}
],
"remote_xcm": [
{ "type": "SetFeesMode", "value": { "jit_withdraw": true } },
{
"type": "DepositAsset",
"value": {
"assets": { "type": "Wild", "value": { "type": "All" } },
"beneficiary": {
"parents": 0,
"interior": {
"type": "X1",
"value": [
{
"type": "AccountId32",
"value": {
"id": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
}
}
]
}
}
}
}
]
}
}
]
}
Format 1: Pure YAML
Enums as single-key maps (VariantName: value). Void variants as bare strings. Locations as nested objects.
Small: simple teleport
version: V5
instructions:
- WithdrawAsset:
- id: { parents: 1, interior: Here }
fun:
Fungible: 1000000000000
- BuyExecution:
fees:
id: { parents: 1, interior: Here }
fun:
Fungible: 1000000000000
weight_limit: Unlimited
- DepositAsset:
assets:
Wild: All
beneficiary:
parents: 0
interior:
X1:
- AccountId32:
id: "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
Medium: reserve transfer
version: V5
instructions:
- SetFeesMode:
jit_withdraw: true
- WithdrawAsset:
- id:
parents: 0
interior:
X2:
- PalletInstance: 50
- GeneralIndex: 3
fun:
Fungible: 100000000000
- InitiateTransfer:
destination:
parents: 1
interior:
X1:
- Parachain: 1004
remote_fees:
ReserveDeposit:
Definite:
- id:
parents: 0
interior:
X2:
- PalletInstance: 50
- GeneralIndex: 3
fun:
Fungible: 1000000000
preserve_origin: false
assets:
- ReserveDeposit:
Wild: All
remote_xcm:
- SetFeesMode:
jit_withdraw: true
- DepositAsset:
assets:
Wild: All
beneficiary:
parents: 0
interior:
X1:
- AccountId32:
id: "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
Pros:
- Standard format, well-known, mature parsers
- Single-key map pattern (
Fungible: 1000) is natural for enums - Comments with
# - YAML anchors (
&name/*name) provide native variable reuse - Good editor support, syntax highlighting out of the box
Cons:
- Locations still verbose (
{parents: 0, interior: {X2: [{PalletInstance: 50}, ...]}}) - Significant whitespace is error-prone; stray indent changes meaning
- Deep nesting (5+ levels in medium example) is hard to track visually
- YAML implicit typing gotchas:
0x00may parse as 0,yes/noas booleans - Needs schema-aware post-processing: single-key maps must be converted to
{type, value}objects, but the parser must distinguish enum variants from struct fields -- requires chain metadata - New dependency (
yamlnpm package)
Parse pipeline: YAML.parse() -> recursive walk converting {VariantName: value} maps to {type, value} objects (metadata-aware) -> feed into existing normalizeValue()
Format 2: YAML + shorthand sugar
Same as pure YAML but with inline shorthand for locations, enums, and numeric underscores.
Small: simple teleport
version: V5
vars:
alice: "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
instructions:
- WithdrawAsset:
- id: ..
fun: Fungible(1_000_000_000_000)
- BuyExecution:
fees:
id: ..
fun: Fungible(1_000_000_000_000)
weight_limit: Unlimited
- DepositAsset:
assets: Wild(All)
beneficiary: ./AccountId32($alice)
Medium: reserve transfer
version: V5
vars:
pusd: ./PalletInstance(50)/GeneralIndex(3)
alice: "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
instructions:
- SetFeesMode:
jit_withdraw: true
- WithdrawAsset:
- id: $pusd
fun: Fungible(100_000_000_000)
- InitiateTransfer:
destination: ../Parachain(1004)
remote_fees:
ReserveDeposit:
Definite:
- id: $pusd
fun: Fungible(1_000_000_000)
preserve_origin: false
assets:
- ReserveDeposit(Wild(All))
remote_xcm:
- SetFeesMode:
jit_withdraw: true
- DepositAsset:
assets: Wild(All)
beneficiary: ./AccountId32($alice)
Location shorthand:
| Shorthand | Expands to |
|---|---|
. |
{parents: 0, interior: Here} |
.. |
{parents: 1, interior: Here} |
../.. |
{parents: 2, interior: Here} |
./Parachain(1000) |
{parents: 0, interior: {X1: [Parachain(1000)]}} |
../Parachain(1000)/PalletInstance(50) |
{parents: 1, interior: {X2: [Parachain(1000), PalletInstance(50)]}} |
Enum sugar: Fungible(1000) -> {type: "Fungible", value: 1000}, Wild(All) -> {type: "Wild", value: {type: "All"}}
Pros (over pure YAML):
- Locations compress dramatically:
../Parachain(1004)vs 4 lines of nested YAML varsblock with$namereferences prevents copy-paste of long hex strings and repeated locations- Numeric underscores:
1_000_000_000_000for readability - Enum sugar:
Fungible(1000)instead ofFungible: 1000-- marginal but consistent with Rust syntax
Cons (over pure YAML):
- Two "languages" to learn: YAML structure + inline shorthand syntax
- Inline expressions like
ReserveDeposit(Wild(All))with nested parens get fragile - Need a string parser for the shorthand (regex + small recursive descent)
- Harder to validate: YAML parser sees strings, shorthand errors surface later
Parse pipeline: YAML.parse() -> substitute $vars -> walk tree expanding shorthand strings (locations, enums, underscores) -> convert remaining YAML enum maps to {type, value} -> feed into normalizeValue()
Format 3: Pure JSON5/JSONC
Standard {type, value} structure with comments and trailing commas. No shorthand.
Small: simple teleport
// Simple teleport: withdraw DOT, buy execution, deposit to Alice
{
"type": "V5",
"value": [
// Withdraw 1 DOT from relay
{ "type": "WithdrawAsset", "value": [
{
"id": { "parents": 1, "interior": { "type": "Here" } },
"fun": { "type": "Fungible", "value": 1000000000000 }
}
]},
{ "type": "BuyExecution", "value": {
"fees": {
"id": { "parents": 1, "interior": { "type": "Here" } },
"fun": { "type": "Fungible", "value": 1000000000000 }
},
"weight_limit": { "type": "Unlimited" }
}},
{ "type": "DepositAsset", "value": {
"assets": { "type": "Wild", "value": { "type": "All" } },
"beneficiary": {
"parents": 0,
"interior": {
"type": "X1",
"value": [{
"type": "AccountId32",
"value": { "id": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d" }
}]
}
}
}}
]
}
Medium: reserve transfer
{
"type": "V5",
"value": [
{ "type": "SetFeesMode", "value": { "jit_withdraw": true } },
{ "type": "WithdrawAsset", "value": [
{
"id": {
"parents": 0,
"interior": { "type": "X2", "value": [
{ "type": "PalletInstance", "value": 50 },
{ "type": "GeneralIndex", "value": 3 }
]}
},
"fun": { "type": "Fungible", "value": 100000000000 }
}
]},
{ "type": "InitiateTransfer", "value": {
"destination": {
"parents": 1,
"interior": { "type": "X1", "value": [
{ "type": "Parachain", "value": 1004 }
]}
},
"remote_fees": { "type": "ReserveDeposit", "value": {
"type": "Definite", "value": [
{
"id": {
"parents": 0,
"interior": { "type": "X2", "value": [
{ "type": "PalletInstance", "value": 50 },
{ "type": "GeneralIndex", "value": 3 }
]}
},
"fun": { "type": "Fungible", "value": 1000000000 }
}
]
}},
"preserve_origin": false,
"assets": [
{ "type": "ReserveDeposit", "value": { "type": "Wild", "value": { "type": "All" } } }
],
"remote_xcm": [
{ "type": "SetFeesMode", "value": { "jit_withdraw": true } },
{ "type": "DepositAsset", "value": {
"assets": { "type": "Wild", "value": { "type": "All" } },
"beneficiary": {
"parents": 0,
"interior": { "type": "X1", "value": [
{ "type": "AccountId32", "value": {
"id": "0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d"
}}
]}
}
}}
]
}}
]
}
Pros:
- Zero learning curve -- it is the JSON the CLI already accepts, plus comments
- Near-zero parser work: strip comments + trailing commas, then
JSON.parse() - Full compatibility with existing
normalizeValue()pipeline - Existing JSONC tooling, syntax highlighting
- Can be generated/consumed by any JSON library
Cons:
- Still very verbose --
{type, value}wrappers dominate visual space - No variables/reuse
- No numeric underscores
- Locations are just as painful as inline CLI args
- Barely an improvement over the current inline approach (just adds comments + formatting)
Parse pipeline: Strip comments -> JSON.parse() -> feed directly into normalizeValue()
Format 4: JSON5/JSONC + location shorthand
JSON5 with location strings expanded. Same {type, value} for enums but compact paths for locations.
Small: simple teleport
{
"type": "V5",
"value": [
{ "type": "WithdrawAsset", "value": [
{ "id": "..", "fun": { "type": "Fungible", "value": 1000000000000 } }
]},
{ "type": "BuyExecution", "value": {
"fees": { "id": "..", "fun": { "type": "Fungible", "value": 1000000000000 } },
"weight_limit": { "type": "Unlimited" }
}},
{ "type": "DepositAsset", "value": {
"assets": { "type": "Wild", "value": { "type": "All" } },
"beneficiary": "./AccountId32(0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d)"
}}
]
}
Medium: reserve transfer
{
"type": "V5",
"value": [
{ "type": "SetFeesMode", "value": { "jit_withdraw": true } },
{ "type": "WithdrawAsset", "value": [
{
"id": "./PalletInstance(50)/GeneralIndex(3)",
"fun": { "type": "Fungible", "value": 100000000000 }
}
]},
{ "type": "InitiateTransfer", "value": {
"destination": "../Parachain(1004)",
"remote_fees": { "type": "ReserveDeposit", "value": {
"type": "Definite", "value": [
{
"id": "./PalletInstance(50)/GeneralIndex(3)",
"fun": { "type": "Fungible", "value": 1000000000 }
}
]
}},
"preserve_origin": false,
"assets": [
{ "type": "ReserveDeposit", "value": { "type": "Wild", "value": { "type": "All" } } }
],
"remote_xcm": [
{ "type": "SetFeesMode", "value": { "jit_withdraw": true } },
{ "type": "DepositAsset", "value": {
"assets": { "type": "Wild", "value": { "type": "All" } },
"beneficiary": "./AccountId32(0xd43593c715fdd31c61141abd04a99fd6822c8558854ccde39a5684e7a56da27d)"
}}
]
}}
]
}
Pros:
- Location shorthand provides significant compression
- Still JSON -- minimal learning curve
- Parser: JSON5 parse + metadata-aware tree walk to expand location strings
Cons:
- Enums still verbose (
{type, value}everywhere) - No variables
- No numeric underscores
- Half-measure: locations are nice, everything else is still painful
Parse pipeline: JSON5.parse() -> walk tree, expand location strings where metadata says the field is a Location type -> feed into normalizeValue()
Evaluation matrix
| Criterion | Pure YAML | YAML + sugar | Pure JSON5 | JSON5 + locations |
|---|---|---|---|---|
| Location readability | Poor | Excellent | Poor | Good |
| Enum readability | Good | Very good | Poor | Poor |
| Deep nesting | OK | Good | OK | OK |
| Variables/reuse | YAML anchors | vars/$ |
None | None |
| Comments | Yes | Yes | Yes | Yes |
| Numeric underscores | No | Yes (strings) | No | No |
| Parser complexity | Medium | Medium-high | Very low | Low |
| Learning curve | Low | Low-medium | Very low | Very low |
| Editor tooling | Excellent | Good | Excellent | Excellent |
| Error messages | Good (lib) | Good + custom | Good (lib) | Good (lib) |
| Metadata awareness needed | Yes (enum maps) | Yes (enum maps + shorthand) | No | Yes (location fields) |
| Lines of code (est.) | ~200 | ~400 | ~30 | ~150 |
Integration sketch
CLI surface
# New flag on dot tx
dot tx --file transfer.xcm.yaml --from alice --chain assethub-preview
# Or new command (future, richer features)
dot xcm run transfer.xcm.yaml --from alice --chain assethub-preview
Parse pipeline (any format)
file content
-> format-specific parser (YAML/JSON5)
-> variable substitution (if supported)
-> shorthand expansion (if supported)
-> convert to {type, value} JSON
-> existing normalizeValue() in tx.ts
-> polkadot-api SCALE encoder
-> submit via dot tx machinery
Key integration point
The XCM file parser's output must match what parseTypedArg() in src/commands/tx.ts currently produces from inline JSON args. Specifically, the normalizeValue() function (line 479) handles:
{type, value}discriminated unions- Single-element array unwrapping (XCM X1 junctions)
- Recursive struct/array/tuple normalization
Open questions
- Should variables support expressions (e.g. arithmetic on amounts) or just simple substitution?
- Should the file include the pallet call (e.g.
PolkadotXcm.execute) or just the XCM program body? - How to handle the
weight_limit/max_weightparameter thatPolkadotXcm.executerequires alongside the XCM message? - Should multiple XCM programs be composable in one file (e.g. for batch operations)?
- For YAML format: how to handle YAML's implicit type coercion (hex strings, booleans)?
References
- XCM Format Spec (polkadot-fellows)
- XCM Docs (Parity)
- XCM Instructions -- Polkadot Wiki
- ParaSpell XCM SDK
- polkadot-sdk #4736 -- XCM builder proposal
- Current CLI handling:
src/commands/tx.ts(normalizeValue,parseTypedArg)
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at the existing dot tx --file entry point and the normalizeValue() pipeline, using scripts/setup-pusd.sh as the real-world example. Resolve the file format and scope choices in this RFC, then define parsing and validation behavior for .xcm.yaml or .xcm.json; done means an XCM program can be loaded from a file instead of inline JSON.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100