Cross-Chain Bridge Guide (XLS-38d)
This guide explains how to use XRP Ledger Cross-Chain Bridges with the XrplCSharp SDK. Cross-chain bridges enable transferring value (XRP or IOU tokens) between two XRPL chains (a locking chain and an issuing chain).
Table of Contents
- Overview
- Key Concepts
- Bridge Types
- Transaction Types
- Step-by-Step: XRP-XRP Bridge
- Step-by-Step: IOU-IOU Bridge
- Witness Server and Attestations
- Ledger Objects
- Common Errors
- Best Practices
Overview
A cross-chain bridge connects two XRPL chains:
- Locking Chain — the chain where value is locked (held in escrow by a door account)
- Issuing Chain — the chain where equivalent value is issued (minted by a door account)
When a user transfers value from the locking chain to the issuing chain, the original value is locked on the locking chain, and a wrapped equivalent is issued on the issuing chain. The reverse process burns the wrapped value and unlocks the original.
Architecture
Locking Chain Issuing Chain
┌──────────────────┐ ┌──────────────────┐
│ │ │ │
│ User ──► Door │ Attestations │ Door ──► User │
│ (lock value) │ ◄──────────────► │ (issue value) │
│ │ Witness Server │ │
└──────────────────┘ └──────────────────┘
Key Concepts
Door Accounts
Each bridge has two door accounts — one on each chain. The door account is the custodian that holds locked value (locking side) or issues/burns wrapped value (issuing side).
Bridge Definition (XChainBridgeModel)
A bridge is uniquely identified by four fields that must be exactly identical across all transactions referencing the same bridge:
using Xrpl.Models.Common;
using static Xrpl.Models.Common.Common;
var bridge = new XChainBridgeModel
{
LockingChainDoor = "rLockingDoorAddress",
LockingChainIssue = new IssuedCurrency { Currency = "XRP" },
IssuingChainDoor = "rIssuingDoorAddress",
IssuingChainIssue = new IssuedCurrency { Currency = "XRP" },
};
Critical: Any mismatch in the bridge definition between transactions will cause failures. The bridge object must be bit-for-bit identical in every transaction that references it.
Witness Servers
Witness servers monitor both chains and provide attestations — cryptographic proofs that a transaction occurred on one chain, enabling the corresponding action on the other chain. A bridge requires one or more witness servers configured as signers on the door accounts.
Claim IDs
A Claim ID is a unique identifier allocated before a cross-chain transfer. It tracks the transfer and ensures attestations are correctly associated.
Signature Reward
The SignatureReward is an XRP amount paid to witness servers for providing attestations. It is always denominated in XRP, regardless of the bridge type (XRP or IOU).
Bridge Types
XRP-XRP Bridge
Transfers native XRP between chains.
Rules:
LockingChainIssueandIssuingChainIssuemust both be{"currency": "XRP"}IssuingChainDoormust be the genesis account on the issuing chain (rHb9CJAWyB4rj91VRWn96DkukG4bwdtyThfor standalone/testnet)
var bridge = new XChainBridgeModel
{
LockingChainDoor = walletDoor.ClassicAddress,
LockingChainIssue = new IssuedCurrency { Currency = "XRP" },
IssuingChainDoor = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
IssuingChainIssue = new IssuedCurrency { Currency = "XRP" },
};
IOU-IOU Bridge
Transfers issued tokens (IOU) between chains.
Rules:
LockingChainIssueandIssuingChainIssuespecify the token withcurrencyandissuer- On the locking side, door and issuer can be different accounts
- On the issuing side,
IssuingChainDoormust equalIssuingChainIssue.issuer— the door account IS the token issuer - The locking door needs a TrustLine to the locking issuer
- The locking issuer must have
DefaultRippleenabled if third-party transfers are needed (e.g., XChainCommit)
var bridge = new XChainBridgeModel
{
LockingChainDoor = walletLockingDoor.ClassicAddress,
LockingChainIssue = new IssuedCurrency
{
Currency = "USD",
Issuer = walletLockingIssuer.ClassicAddress
},
IssuingChainDoor = walletIssuingDoor.ClassicAddress,
IssuingChainIssue = new IssuedCurrency
{
Currency = "USD",
Issuer = walletIssuingDoor.ClassicAddress // MUST equal IssuingChainDoor
},
};
Transaction Types
| Transaction | Purpose | Who Submits |
|---|---|---|
XChainCreateBridge |
Create a new bridge | Door account |
XChainModifyBridge |
Update SignatureReward or MinAccountCreateAmount | Door account |
XChainCreateClaimID |
Allocate a claim ID for a transfer | Any account |
XChainCommit |
Lock value on the source chain | User |
XChainClaim |
Claim value on the destination chain | User (with attestations) |
XChainAccountCreateCommit |
Create a new account on the destination chain | User |
XChainAddClaimAttestation |
Submit witness attestation for a commit | Witness server |
XChainAddAccountCreateAttestation |
Submit witness attestation for account creation | Witness server |
Step-by-Step: XRP-XRP Bridge
1. Create the Bridge
The door account submits XChainCreateBridge to register the bridge on the ledger:
using Xrpl.Models.Transactions;
using Xrpl.Models.Common;
using Xrpl.Sugar;
XChainCreateBridge createBridge = new XChainCreateBridge
{
Account = walletDoor.ClassicAddress,
XChainBridge = bridge,
SignatureReward = new Currency { Value = "100", CurrencyCode = "XRP" }, // 100 drops
MinAccountCreateAmount = new Currency { Value = "10000000", CurrencyCode = "XRP" }, // 10 XRP
};
createBridge = await client.Autofill(createBridge);
TransactionSummary result = await client.SubmitAndWait(createBridge, walletDoor, true);
SignatureReward— XRP drops paid to witnesses per attestationMinAccountCreateAmount— minimum XRP forXChainAccountCreateCommit(optional)
2. Create a Claim ID
Before transferring value, the user must allocate a claim ID:
XChainCreateClaimID createClaimId = new XChainCreateClaimID
{
Account = walletUser.ClassicAddress,
XChainBridge = bridge,
SignatureReward = new Currency { Value = "100", CurrencyCode = "XRP" },
OtherChainSource = walletUser.ClassicAddress, // source account on the other chain
};
createClaimId = await client.Autofill(createClaimId);
TransactionSummary result = await client.SubmitAndWait(createClaimId, walletUser, true);
3. Commit Value (Lock on Source Chain)
The user commits XRP to the bridge. This locks the value on the locking chain:
XChainCommit commit = new XChainCommit
{
Account = walletUser.ClassicAddress,
XChainBridge = bridge,
XChainClaimID = "1", // the claim ID from step 2
Amount = new Currency { Value = "1000000", CurrencyCode = "XRP" }, // 1 XRP
OtherChainDestination = destinationAddress, // optional: destination on the other chain
};
commit = await client.Autofill(commit);
TransactionSummary result = await client.SubmitAndWait(commit, walletUser, true);
4. Witness Attestation
Witness servers observe the commit on the locking chain and submit attestations on the issuing chain:
XChainAddClaimAttestation attestation = new XChainAddClaimAttestation
{
Account = witnessAccount.ClassicAddress,
XChainBridge = bridge,
XChainClaimID = "1",
Amount = new Currency { Value = "1000000", CurrencyCode = "XRP" },
OtherChainSource = walletUser.ClassicAddress,
AttestationSignerAccount = witnessAccount.ClassicAddress,
AttestationRewardAccount = witnessAccount.ClassicAddress,
PublicKey = witnessPublicKeyHex,
Signature = attestationSignatureHex,
WasLockingChainSend = 1, // 1 = locking chain, 0 = issuing chain
Destination = destinationAddress,
};
attestation = await client.Autofill(attestation);
TransactionSummary result = await client.SubmitAndWait(attestation, witnessAccount, true);
5. Claim Value (Receive on Destination Chain)
Once sufficient attestations are collected, the user claims value on the issuing chain:
XChainClaim claim = new XChainClaim
{
Account = walletUser.ClassicAddress,
XChainBridge = bridge,
XChainClaimID = "1",
Destination = walletUser.ClassicAddress,
Amount = new Currency { Value = "1000000", CurrencyCode = "XRP" },
};
claim = await client.Autofill(claim);
TransactionSummary result = await client.SubmitAndWait(claim, walletUser, true);
Note: If the commit included
OtherChainDestinationand sufficient attestations are received, the value may be automatically delivered without an explicitXChainClaim.
6. Modify Bridge (Optional)
The door account can update the bridge parameters:
XChainModifyBridge modify = new XChainModifyBridge
{
Account = walletDoor.ClassicAddress,
XChainBridge = bridge,
SignatureReward = new Currency { Value = "200", CurrencyCode = "XRP" },
};
modify = await client.Autofill(modify);
TransactionSummary result = await client.SubmitAndWait(modify, walletDoor, true);
7. Create Account on Destination Chain (Optional)
To create a new account on the destination chain via the bridge:
XChainAccountCreateCommit accountCreate = new XChainAccountCreateCommit
{
Account = walletUser.ClassicAddress,
XChainBridge = bridge,
Destination = newAccountAddress,
Amount = new Currency { Value = "20000000", CurrencyCode = "XRP" }, // 20 XRP
SignatureReward = new Currency { Value = "100", CurrencyCode = "XRP" },
};
accountCreate = await client.Autofill(accountCreate);
TransactionSummary result = await client.SubmitAndWait(accountCreate, walletUser, true);
The bridge must have MinAccountCreateAmount set, and the Amount must be >= MinAccountCreateAmount.
Step-by-Step: IOU-IOU Bridge
IOU bridges require additional setup compared to XRP bridges.
Prerequisites
1. Enable DefaultRipple on the Locking Issuer
The issuer must allow rippling between third-party accounts:
using Xrpl.Models.Transactions;
AccountSet enableRipple = new AccountSet
{
Account = walletLockingIssuer.ClassicAddress,
SetFlag = AccountSetAsfFlags.asfDefaultRipple,
};
enableRipple = await client.Autofill(enableRipple);
await client.SubmitAndWait(enableRipple, walletLockingIssuer, true);
Important:
DefaultRipplemust be enabled before creating TrustLines. TrustLines inherit the NoRipple state from the issuer'sDefaultRippleflag at creation time.
2. Create TrustLines
The locking door needs a TrustLine to the locking issuer:
TrustSet trustSet = new TrustSet
{
Account = walletLockingDoor.ClassicAddress,
LimitAmount = new Currency
{
CurrencyCode = "USD",
Issuer = walletLockingIssuer.ClassicAddress,
Value = "10000000",
}
};
trustSet = await client.Autofill(trustSet);
await client.SubmitAndWait(trustSet, walletLockingDoor, true);
If users will commit IOU tokens, they also need TrustLines:
TrustSet userTrust = new TrustSet
{
Account = walletUser.ClassicAddress,
LimitAmount = new Currency
{
CurrencyCode = "USD",
Issuer = walletLockingIssuer.ClassicAddress,
Value = "10000000",
}
};
userTrust = await client.Autofill(userTrust);
await client.SubmitAndWait(userTrust, walletUser, true);
Note: The issuing door does not need a TrustLine to itself — it IS the token issuer on the issuing chain.
3. Issue Tokens to Users
Before users can commit IOU tokens, they need a balance:
Payment issueTokens = new Payment
{
Account = walletLockingIssuer.ClassicAddress,
Destination = walletUser.ClassicAddress,
Amount = new Currency
{
CurrencyCode = "USD",
Issuer = walletLockingIssuer.ClassicAddress,
Value = "1000",
},
};
issueTokens = await client.Autofill(issueTokens);
await client.SubmitAndWait(issueTokens, walletLockingIssuer, true);
Create the IOU Bridge
XChainBridgeModel bridge = new XChainBridgeModel
{
LockingChainDoor = walletLockingDoor.ClassicAddress,
LockingChainIssue = new IssuedCurrency
{
Currency = "USD",
Issuer = walletLockingIssuer.ClassicAddress
},
IssuingChainDoor = walletIssuingDoor.ClassicAddress,
IssuingChainIssue = new IssuedCurrency
{
Currency = "USD",
Issuer = walletIssuingDoor.ClassicAddress // MUST equal IssuingChainDoor
},
};
XChainCreateBridge createBridge = new XChainCreateBridge
{
Account = walletLockingDoor.ClassicAddress,
XChainBridge = bridge,
SignatureReward = new Currency { Value = "100", CurrencyCode = "XRP" },
};
createBridge = await client.Autofill(createBridge);
await client.SubmitAndWait(createBridge, walletLockingDoor, true);
Commit IOU Tokens
The flow is similar to XRP, but the Amount is an IOU object:
XChainCommit commit = new XChainCommit
{
Account = walletUser.ClassicAddress,
XChainBridge = bridge,
XChainClaimID = "1",
Amount = new Currency
{
CurrencyCode = "USD",
Issuer = walletLockingIssuer.ClassicAddress,
Value = "100",
},
OtherChainDestination = destinationAddress,
};
commit = await client.Autofill(commit);
await client.SubmitAndWait(commit, walletUser, true);
Witness Server and Attestations
Witness servers are essential for cross-chain bridges. They:
- Monitor transactions on both chains
- Verify that commits/account creates occurred
- Submit attestation transactions on the other chain
Signer List Setup
Door accounts must configure a SignerList that includes the witness server accounts. The quorum determines how many attestations are required.
Attestation Transactions
| Transaction | Attests to |
|---|---|
XChainAddClaimAttestation |
An XChainCommit on the other chain |
XChainAddAccountCreateAttestation |
An XChainAccountCreateCommit on the other chain |
Key Fields
| Field | Description |
|---|---|
AttestationSignerAccount |
The witness account (must be on the door's signer list) |
AttestationRewardAccount |
Account that receives the signature reward share |
PublicKey |
Hex-encoded public key of the witness |
Signature |
Hex-encoded attestation signature |
WasLockingChainSend |
1 if the attested event was on the locking chain, 0 if on the issuing chain |
Ledger Objects
Bridges create the following ledger objects:
| Object | Description | Created By |
|---|---|---|
Bridge |
The bridge definition, owned by the door account | XChainCreateBridge |
XChainOwnedClaimID |
A claim ID for tracking a transfer | XChainCreateClaimID |
XChainOwnedCreateAccountClaimID |
Tracks an account creation transfer | XChainAccountCreateCommit |
Querying Bridge State
Use account_objects to retrieve bridge objects owned by a door account:
using Xrpl.Models.Methods;
var request = new AccountObjectsRequest(walletDoor.ClassicAddress);
var response = await client.AccountObjects(request);
foreach (var obj in response.AccountObjectList)
{
Console.WriteLine($"Type: {obj.LedgerEntryType}");
}
Common Errors
| Error Code | Cause | Solution |
|---|---|---|
temXCHAIN_BRIDGE_BAD_ISSUES |
Invalid bridge definition | Verify all four bridge fields. For XRP bridges: IssuingChainDoor must be genesis. For IOU bridges: IssuingChainDoor must equal IssuingChainIssue.issuer |
tecXCHAIN_NO_CLAIM_ID |
Claim ID does not exist | Create a claim ID before committing |
tecNO_PERMISSION |
Account is not the bridge door | Only the door account can create/modify bridges |
terNO_RIPPLE |
Rippling not enabled between accounts | Enable DefaultRipple on the IOU issuer before creating TrustLines |
tecUNFUNDED |
Insufficient balance | Ensure the committing account has sufficient funds |
tecXCHAIN_BAD_CLAIM_ID |
Wrong claim ID | Verify the claim ID matches an existing one |
temXCHAIN_BRIDGE_BAD_ISSUES Checklist
This is the most common error. Verify:
- XRP bridge:
IssuingChainDoor= genesis account (rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh) - XRP bridge: Both
LockingChainIssueandIssuingChainIssue={"currency": "XRP"}(no issuer field) - IOU bridge:
IssuingChainDoor==IssuingChainIssue.Issuer - IOU bridge: Both issue fields have
currencyANDissuer - All bridges: The
XChainBridgeobject is exactly identical across all transactions
terNO_RIPPLE Checklist
- Call
AccountSetwithSetFlag = AccountSetAsfFlags.asfDefaultRippleon the issuer - Do this before creating TrustLines (TrustLines inherit the flag at creation time)
- Verify TrustLines do not have
NoRippleset explicitly
Best Practices
Store the bridge definition once — create the
XChainBridgeModelobject once and reuse it across all transactions. Any difference in the bridge fields will cause failures.Use constants for door addresses — especially the genesis account for XRP bridges:
const string GenesisAccount = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh";Enable DefaultRipple early — for IOU bridges, enable it on the issuer before any TrustLine creation.
SignatureReward is always XRP — regardless of whether the bridge transfers XRP or IOU tokens.
MinAccountCreateAmount — set this only on XRP bridges where cross-chain account creation is needed.
Validate results — always check
TransactionResultfortesSUCCESS:if (result.Meta?.TransactionResult != "tesSUCCESS") throw new Exception($"Transaction failed: {result.Meta?.TransactionResult}");Witness server security — in production, use a multi-signature signer list with a quorum > 1. Never rely on a single witness.
Amount format:
- XRP:
new Currency { Value = "1000000", CurrencyCode = "XRP" }(value in drops) - IOU:
new Currency { CurrencyCode = "USD", Issuer = "rAddress", Value = "100" }(decimal value)
- XRP:
Testing on standalone — XChainBridge amendment must be enabled. Use
featureRPC command to unveto it if needed:{ "command": "feature", "feature": "XChainBridge", "vetoed": false }