From 9b61789c62404b60f05ce3812361996546d199f8 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 6 Jul 2026 16:31:35 +0200 Subject: [PATCH 01/17] feat: XRPL NTT Accountant --- node/pkg/accountant/ntt_config.go | 3 + node/pkg/accountant/ntt_test.go | 38 +++ node/pkg/watchers/xrpl/README.md | 65 ++++- node/pkg/watchers/xrpl/parse.go | 251 ++++++++++++++++++++ node/pkg/watchers/xrpl/registration_test.go | 203 ++++++++++++++++ 5 files changed, 556 insertions(+), 4 deletions(-) create mode 100644 node/pkg/watchers/xrpl/registration_test.go diff --git a/node/pkg/accountant/ntt_config.go b/node/pkg/accountant/ntt_config.go index 998e845e3a4..a125ebb4d25 100644 --- a/node/pkg/accountant/ntt_config.go +++ b/node/pkg/accountant/ntt_config.go @@ -48,6 +48,9 @@ func nttGetEmitters(env common.Environment) (validEmitters, validEmitters, error {chainId: vaa.ChainIDArbitrumSepolia, addr: "000000000000000000000000fA42603152E4f133F5F3DA610CDa91dF5821d8bc"}, {chainId: vaa.ChainIDBaseSepolia, addr: "000000000000000000000000149987472333cD48ac6D28293A338a1EEa6Be7EE"}, {chainId: vaa.ChainIDOptimismSepolia, addr: "000000000000000000000000eCF0496DE01e9Aa4ADB50ae56dB550f52003bdB7"}, + // XRPL NTT custody emitters — one per (custody account, token), keyed on keccak256("ntt" + manager32 + sourceToken32). Log-only until the XREG registration pipeline is live. + {chainId: vaa.ChainIDXRPL, addr: "264a5b2d2d0d0722a05f3ebe0403aee658729456cb50b8c5d524036bd0c06aaa", logOnly: true}, // rpQdxE7TGjyhFWQeVrnxYo4j8dqwj9BTLH (XRP) + {chainId: vaa.ChainIDXRPL, addr: "31155b0ad6a8e59f4411d0276a915b90f36de48163a5ddc1886b59e4525cac24", logOnly: true}, // rN6CtQsYcAFBRciP9ktiPRv2R6FZ5sk5Fm (IOU "TS1") } arEmitterConfig = sdk.KnownTestnetAutomaticRelayerEmitters } else { diff --git a/node/pkg/accountant/ntt_test.go b/node/pkg/accountant/ntt_test.go index acff30a76af..85e6da6dd0d 100644 --- a/node/pkg/accountant/ntt_test.go +++ b/node/pkg/accountant/ntt_test.go @@ -38,6 +38,44 @@ func TestNttParsePayloadNoTransferPrefix(t *testing.T) { assert.False(t, nttIsPayloadNTT(payload)) } +// xrplXrpCustodyEmitter is the testnet NTT transceiver emitter for the XRP +// custody account rpQdxE7TGjyhFWQeVrnxYo4j8dqwj9BTLH — +// keccak256("ntt" + manager32 + zeros32). Registered in nttGetEmitters(TestNet). +const xrplXrpCustodyEmitter = "264a5b2d2d0d0722a05f3ebe0403aee658729456cb50b8c5d524036bd0c06aaa" + +// TestNttXrplEmitterRegisteredTestnet verifies the XRPL custody emitters are +// present in the testnet config and that an XRPL NTT transfer from one is +// recognized as a (log-only) NTT message. +func TestNttXrplEmitterRegisteredTestnet(t *testing.T) { + directEmitters, _, err := nttGetEmitters(common.TestNet) + require.NoError(t, err) + + emitterAddr, err := vaa.StringToAddress(xrplXrpCustodyEmitter) + require.NoError(t, err) + + // Registered, and log-only (enforce flag false) for the initial rollout. + enforce, ok := directEmitters[emitterKey{emitterChainId: vaa.ChainIDXRPL, emitterAddr: emitterAddr}] + require.True(t, ok, "XRPL XRP custody emitter should be registered on testnet") + assert.False(t, enforce, "XRPL emitters should be log-only initially") + + // An XRPL NTT transfer from this emitter is covered as a direct NTT message. + payload, err := hex.DecodeString(goodPayload) + require.NoError(t, err) + + msg := &common.MessagePublication{ + TxID: hashToTxID("0x06f541f5ecfc43407c31587aa6ac3a689e8960f36dc23c332db5510dfc6a4063"), + Timestamp: time.Unix(int64(1654543099), 0), + Sequence: uint64(123456), + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitterAddr, + Payload: payload, + } + + isNTT, enforceFlag := nttIsMsgDirectNTT(msg, directEmitters) + assert.True(t, isNTT, "XRPL NTT transfer should be recognized") + assert.False(t, enforceFlag, "should be log-only") +} + func TestNttParseMsgSuccess(t *testing.T) { emitterAddr, err := vaa.StringToAddress("000000000000000000000000000000000000000000000000656e64706f696e74") require.NoError(t, err) diff --git a/node/pkg/watchers/xrpl/README.md b/node/pkg/watchers/xrpl/README.md index 6ceefd9dd2b..63416f80621 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -100,13 +100,70 @@ uint32 nonce []byte payload ``` -### Initialize Transceiver +### Registration (Initialize Transceiver & Peer Registration) -> 🚧 TODO +Because XRPL has no smart contracts, the `WormholeTransceiverInfo` (hub init) and +`WormholeTransceiverRegistration` (peer) messages cannot be emitted natively. +Instead, the Solana Sequencer emits an `XrplRegistration` (prefix `XREG`) message +when a custody account is initialized (`initialize`) or a peer is registered +(`register_peer`), and the executor relays it back to XRPL as a Payment to the +Core Account. The watcher then re-keys and synthesizes the canonical transceiver +message under the correct NTT transceiver emitter. -### Transceiver (Peer) Registration +Upon a Payment to the Core Account, the guardian watcher MUST + +- Verify that the transaction is `validated` +- Verify that the transaction result is `tesSUCCESS` +- Verify that the transaction type is `Payment` +- Verify that the **first** memo (index 0) has a MemoFormat of + `application/x-ntt-registration`, hex-encoded +- Verify that the MemoData begins with the `XREG` prefix + +The MemoData carries the raw `XREG` bytes: + +```go +[4]byte prefix = "XREG" +uint8 kind // 0 = Hub, 1 = Peer +[20]byte manager // XRPL custody account id +[]byte token_id // wire form: XRP 1B / IOU 41B / MPT 25B +// Hub tail (kind=0): +uint8 token_decimals +// Peer tail (kind=1): +uint16 peer_chain // big-endian +[32]byte peer_address +``` + +The watcher derives the NTT transceiver emitter +`keccak256("ntt" + manager32 + token32)` — the **same** emitter used for +`NativeTokenTransfer` messages from this manager+token pair — and emits the VAA +under it, so the accountant keys hub/peer state on the same emitter it sees +transfers from. + +#### Initialize Transceiver (kind = Hub) + +Synthesize a `WormholeTransceiverInfo` payload (70 bytes): + +```go +[4]byte prefix = 0x9c23bd3b +[32]byte manager_address // manager32 (left-padded XRPL account) +uint8 manager_mode = 1 // locking +[32]byte token_address // token32 derived from token_id +uint8 token_decimals +``` + +#### Transceiver (Peer) Registration (kind = Peer) + +Synthesize a `WormholeTransceiverRegistration` payload (38 bytes): + +```go +[4]byte prefix = 0x18fc67c2 +uint16 chain_id // big-endian, the peer chain +[32]byte transceiver_address // the peer address +``` -> 🚧 TODO +An operator submits the resulting VAAs to the NTT global accountant via +`submit_vaas` to establish the hub/peer registration before transfers are +enforced. ### NativeTokenTransfer TransceiverMessage diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 1fc5ad41b6e..2809aac82dc 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -82,6 +82,100 @@ const ( // tesSUCCESS is the XRPL transaction result code for successful transactions const tesSUCCESS = "tesSUCCESS" +// regMemoFormat is the hex-encoded MemoFormat for XREG registration publishes: "application/x-ntt-registration" +// On a successful initialize / register_peer, the sequencer posts an XREG payload that the executor relays back +// to XRPL as a core-publish whose MemoData is the raw XREG bytes and MemoFormat is regMemoFormat. The watcher +// synthesizes the canonical NTT transceiver registration message the ntt-global-accountant expects +// (WormholeTransceiverInfo for a hub, WormholeTransceiverRegistration for a peer), emitted under the NTT +// transceiver emitter keccak256("ntt" + manager + sourceToken) — the same emitter XRPL NTT transfers use, so +// the accountant keys hub/peer state consistently. +const regMemoFormat = "6170706C69636174696F6E2F782D6E74742D726567697374726174696F6E" + +// xregPrefix is the 4-byte prefix for XRPL registration payloads +var xregPrefix = [4]byte{'X', 'R', 'E', 'G'} + +// Prefixes for canonical NTT transceiver messages (from the NTT spec / ntt-messages) +var transceiverInfoPrefix = [4]byte{0x9c, 0x23, 0xbd, 0x3b} // WormholeTransceiverInfo +var transceiverRegPrefix = [4]byte{0x18, 0xfc, 0x67, 0xc2} // WormholeTransceiverRegistration + +// XREG constants +const ( + xregKindHub = 0x00 // hub sub-type (byte after prefix), mirrors sequencer XREG_KIND_HUB + xregKindPeer = 0x01 // peer sub-type (byte after prefix), mirrors sequencer XREG_KIND_PEER + xregTokenXRP = 0x00 // wire token_id opening byte for XRP, mirrors XrplTokenId + xregTokenIOU = 0x01 // wire token_id opening byte for issued currencies + xregTokenMPT = 0x02 // wire token_id opening byte for MPT + nttModeLocking = 0x00 // manager mode for XRPL custody accounts (locking; accountant only accepts locking) +) + +// regSourceTokenFromWire converts an XREG wire token_id (XrplTokenId form) into +// the 32-byte source_token used by calculateEmitterAddress. It MUST produce the +// identical bytes to the transfer path's sourceToken derivation so that the +// synthesized registration emitter matches transfers: +// +// XRP: 32 zeros +// IOU: 0x01 || keccak256(currency[20] || issuer[20])[1:] +// MPT: 0x02 || 7 zeros || mpt_issuance_id[24] +// +// Returns the source_token, the bytes consumed from `wire`, or an error. +func regSourceTokenFromWire(wire []byte) ([32]byte, int, error) { + var sourceToken [32]byte + if len(wire) < 1 { + return sourceToken, 0, fmt.Errorf("xreg token_id empty") + } + switch wire[0] { + case xregTokenXRP: + return sourceToken, 1, nil // all zeros + case xregTokenIOU: + // 1 + currency(20) + issuer(20) = 41 bytes + if len(wire) < 41 { + return sourceToken, 0, fmt.Errorf("xreg IOU token_id too short: %d", len(wire)) + } + // currency[20] || issuer[20] are already the normalized/decoded forms + // the transfer path hashes (currencycodec.NormalizedLen == 20, issuer is + // the 20-byte account ID), so hash them directly. + hash := ethcrypto.Keccak256(wire[1:41]) + sourceToken[0] = tokenTypeIssued + copy(sourceToken[1:], hash[1:]) + return sourceToken, 41, nil + case xregTokenMPT: + // 1 + mpt_issuance_id(24) = 25 bytes + if len(wire) < 25 { + return sourceToken, 0, fmt.Errorf("xreg MPT token_id too short: %d", len(wire)) + } + sourceToken[0] = tokenTypeMPT + copy(sourceToken[8:], wire[1:25]) // 1 prefix + 7 padding = offset 8 + return sourceToken, 25, nil + default: + return sourceToken, 0, fmt.Errorf("xreg unknown token type: 0x%02x", wire[0]) + } +} + +// buildTransceiverInfoPayload builds a WormholeTransceiverInfo payload: +// +// prefix(4=0x9c23bd3b) + manager_address(32) + manager_mode(1) + +// token_address(32) + token_decimals(1) = 70 bytes +func buildTransceiverInfoPayload(manager32, token32 [32]byte, decimals uint8) []byte { + out := make([]byte, 0, 70) + out = append(out, transceiverInfoPrefix[:]...) + out = append(out, manager32[:]...) + out = append(out, nttModeLocking) + out = append(out, token32[:]...) + out = append(out, decimals) + return out +} + +// buildTransceiverRegistrationPayload builds a WormholeTransceiverRegistration: +// +// prefix(4=0x18fc67c2) + chain_id(2 BE) + transceiver_address(32) = 38 bytes +func buildTransceiverRegistrationPayload(peerChain uint16, peerAddress [32]byte) []byte { + out := make([]byte, 0, 38) + out = append(out, transceiverRegPrefix[:]...) + out = binary.BigEndian.AppendUint16(out, peerChain) + out = append(out, peerAddress[:]...) + return out +} + // txResponseV2 pairs a TxResponse with the close_time_iso field from API v2. // These are decoded separately because GetResult uses mapstructure which does // not support embedded-struct squashing with TagName:"json". @@ -344,6 +438,13 @@ func (p *Parser) parseTransaction(tx GenericTx) (*common.MessagePublication, err return msg, err } + // Registration (XREG) publishes go to the core account but use a distinct + // MemoFormat, so they must be checked before the generic core parser. + msg, err = p.parseRegistrationTransaction(tx) + if msg != nil || err != nil { + return msg, err + } + msg, err = p.parseCoreTransaction(tx) if msg != nil || err != nil { return msg, err @@ -352,6 +453,156 @@ func (p *Parser) parseTransaction(tx GenericTx) (*common.MessagePublication, err return p.parseNttTransaction(tx) } +// parseRegistrationTransaction parses an XREG registration publish (payment to +// the core account with regMemoFormat) and synthesizes the canonical NTT +// transceiver registration message under the NTT transceiver emitter. +// +// Returns (nil, nil) if this is not a registration publish. +func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePublication, error) { + if p.coreAccount == "" { + return nil, nil + } + + destination, err := p.extractDestination(tx.Transaction) + if err != nil { + return nil, err + } + if destination != p.coreAccount { + return nil, nil + } + + // Read the registration memo (memo[0], regMemoFormat). nil => not an XREG. + regData, err := p.parseRegistrationMemoData(tx.Transaction) + if err != nil { + return nil, err + } + if regData == nil { + return nil, nil + } + + if err = validateTransactionResult(tx); err != nil { + return nil, err + } + if err = validateTransactionType(tx.Transaction); err != nil { + return nil, err + } + + txHash, sequence, err := p.extractTxHashAndSequence(tx) + if err != nil { + return nil, err + } + + // Synthesize the canonical transceiver payload + re-keyed emitter. + payload, emitter, err := buildRegistrationMessage(regData) + if err != nil { + return nil, fmt.Errorf("failed to build registration message: %w", err) + } + + return &common.MessagePublication{ + TxID: txHash, + Timestamp: tx.Timestamp, + Nonce: 0, + Sequence: sequence, + EmitterChain: vaa.ChainIDXRPL, + EmitterAddress: emitter, + Payload: payload, + ConsistencyLevel: 0, + IsReobservation: false, + Unreliable: false, + }, nil +} + +// parseRegistrationMemoData extracts the raw XREG bytes from memo[0] when its +// MemoFormat is regMemoFormat. Returns (nil, nil) if not a registration memo. +func (p *Parser) parseRegistrationMemoData(tx transaction.FlatTransaction) ([]byte, error) { + memosRaw, ok := tx["Memos"] + if !ok { + return nil, nil + } + memos, ok := memosRaw.([]any) + if !ok || len(memos) == 0 { + return nil, nil + } + // Only the first memo (index 0), consistent with the NTT/core parsers. + memoWrapper, ok := memos[0].(map[string]any) + if !ok { + return nil, nil + } + memoRaw, ok := memoWrapper["Memo"] + if !ok { + return nil, nil + } + memo, ok := memoRaw.(map[string]any) + if !ok { + return nil, nil + } + memoFormatStr, ok := memo["MemoFormat"].(string) + if !ok || memoFormatStr != regMemoFormat { + return nil, nil + } + memoDataStr, ok := memo["MemoData"].(string) + if !ok { + return nil, nil + } + data, err := hex.DecodeString(memoDataStr) + if err != nil { + return nil, fmt.Errorf("failed to decode registration MemoData: %w", err) + } + if len(data) < 4 || !bytes.Equal(data[:4], xregPrefix[:]) { + return nil, nil + } + return data, nil +} + +// buildRegistrationMessage parses the XREG bytes and returns the canonical +// transceiver payload plus the NTT transceiver emitter to publish under. +// +// XREG layout: prefix(4) + kind(1) + manager(20) + token_id(wire 1-41) + tail +// +// Hub tail: token_decimals(1) +// Peer tail: peer_chain(2 BE) + peer_address(32) +func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { + var emitter vaa.Address + const headerLen = 4 + 1 + 20 + if len(data) < headerLen+1 { + return nil, emitter, fmt.Errorf("xreg payload too short: %d", len(data)) + } + kind := data[4] + var manager20 [20]byte + copy(manager20[:], data[5:25]) + + // manager32 = left-padded 20-byte account id (same as transfer sourceNTTManager). + var manager32 [32]byte + copy(manager32[12:], manager20[:]) + + sourceToken, consumed, err := regSourceTokenFromWire(data[25:]) + if err != nil { + return nil, emitter, err + } + tail := data[25+consumed:] + + emitter = (&Parser{}).calculateEmitterAddress(manager32, sourceToken) + + switch kind { + case xregKindHub: + if len(tail) < 1 { + return nil, emitter, fmt.Errorf("xreg hub missing decimals") + } + decimals := tail[0] + return buildTransceiverInfoPayload(manager32, sourceToken, decimals), emitter, nil + case xregKindPeer: + if len(tail) < 2+32 { + return nil, emitter, fmt.Errorf("xreg peer tail too short: %d", len(tail)) + } + peerChain := binary.BigEndian.Uint16(tail[0:2]) + var peerAddr [32]byte + copy(peerAddr[:], tail[2:34]) + return buildTransceiverRegistrationPayload(peerChain, peerAddr), emitter, nil + default: + return nil, emitter, fmt.Errorf("xreg unknown kind: 0x%02x", kind) + } +} + // parseCoreTransaction parses a generic Wormhole message (payment to the core account). // Returns (nil, nil) if the payment is not to the core account or has no core memo. func (p *Parser) parseCoreTransaction(tx GenericTx) (*common.MessagePublication, error) { diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go new file mode 100644 index 00000000000..cb696f84f21 --- /dev/null +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -0,0 +1,203 @@ +package xrpl + +import ( + "encoding/binary" + "encoding/hex" + "testing" + + streamtypes "github.com/Peersyst/xrpl-go/xrpl/queries/subscription/types" + "github.com/Peersyst/xrpl-go/xrpl/transaction" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/wormhole-foundation/wormhole/sdk/vaa" +) + +// testRegCoreAccount is the core (GMP) account that registration publishes target. +const testRegCoreAccount = "rN7n3473SaZBCG4dFL83w7a1RXtXtbk2D9" + +// testRegManager20 is a sample XRPL custody account id (20 bytes). +func testRegManager20() [20]byte { + var m [20]byte + for i := range m { + m[i] = byte(i + 1) + } + return m +} + +// buildXregHubXRP builds an XREG Hub payload for an XRP custody (matching the +// sequencer's payloads.rs serialization): prefix + kind(0) + manager(20) + +// token_id(XRP=0x00) + decimals(1). +func buildXregHubXRP(manager [20]byte, decimals uint8) []byte { + out := append([]byte{}, xregPrefix[:]...) + out = append(out, xregKindHub) + out = append(out, manager[:]...) + out = append(out, xregTokenXRP) // token_id wire + out = append(out, decimals) + return out +} + +// buildXregHubIOU builds an XREG Hub for an IOU custody: +// prefix + kind(0) + manager(20) + token_id(0x01 + currency20 + issuer20) + decimals. +func buildXregHubIOU(manager [20]byte, currency, issuer [20]byte, decimals uint8) []byte { + out := append([]byte{}, xregPrefix[:]...) + out = append(out, xregKindHub) + out = append(out, manager[:]...) + out = append(out, xregTokenIOU) + out = append(out, currency[:]...) + out = append(out, issuer[:]...) + out = append(out, decimals) + return out +} + +// buildXregPeerXRP builds an XREG Peer for an XRP custody: +// prefix + kind(1) + manager(20) + token_id(XRP) + peer_chain(2) + peer_address(32). +func buildXregPeerXRP(manager [20]byte, peerChain uint16, peerAddr [32]byte) []byte { + out := append([]byte{}, xregPrefix[:]...) + out = append(out, xregKindPeer) + out = append(out, manager[:]...) + out = append(out, xregTokenXRP) + out = binary.BigEndian.AppendUint16(out, peerChain) + out = append(out, peerAddr[:]...) + return out +} + +func regTxStream(memoData []byte) *streamtypes.TransactionStream { + return &streamtypes.TransactionStream{ + Hash: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890", + LedgerIndex: 12345, + CloseTimeISO: "2024-06-15T14:30:00Z", + Validated: true, + Transaction: transaction.FlatTransaction{ + "TransactionType": "Payment", + "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "Destination": testRegCoreAccount, + "meta": map[string]any{ + "TransactionResult": "tesSUCCESS", + }, + "Memos": []any{ + map[string]any{ + "Memo": map[string]any{ + "MemoFormat": regMemoFormat, + "MemoData": hex.EncodeToString(memoData), + }, + }, + }, + }, + Meta: transaction.TxObjMeta{ + TransactionIndex: 7, + TransactionResult: "tesSUCCESS", + }, + } +} + +// managerToEmitter mirrors the transfer-path manager32 derivation (20-byte +// account id left-padded into 32 bytes). +func manager32From20(m [20]byte) [32]byte { + var out [32]byte + copy(out[12:], m[:]) + return out +} + +func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { + p := NewParser(testRegCoreAccount, nil, nil) + manager := testRegManager20() + + msg, err := p.ParseTransactionStream(regTxStream(buildXregHubXRP(manager, 6))) + require.NoError(t, err) + require.NotNil(t, msg, "registration publish should produce a message") + + assert.Equal(t, vaa.ChainIDXRPL, msg.EmitterChain) + + // The synthesized emitter MUST equal the transfer-path emitter for the same + // (manager, XRP sourceToken=zeros) — this is the core correctness guarantee. + var zeroToken [32]byte + expected := p.calculateEmitterAddress(manager32From20(manager), zeroToken) + assert.Equal(t, expected, msg.EmitterAddress, "hub emitter must match transfer emitter") + + // Payload is a canonical WormholeTransceiverInfo: prefix(4) + manager(32) + + // mode(1) + token(32) + decimals(1) = 70 bytes. + require.Equal(t, 70, len(msg.Payload)) + assert.Equal(t, transceiverInfoPrefix[:], msg.Payload[0:4]) + assert.Equal(t, manager32From20(manager), [32]byte(msg.Payload[4:36])) + assert.Equal(t, uint8(nttModeLocking), msg.Payload[36]) + assert.Equal(t, zeroToken, [32]byte(msg.Payload[37:69])) + assert.Equal(t, uint8(6), msg.Payload[69]) +} + +func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { + p := NewParser(testRegCoreAccount, nil, nil) + manager := testRegManager20() + + // Normalized currency (20) + issuer account id (20), as carried in XREG. + var currency, issuer [20]byte + copy(currency[12:15], []byte("FOO")) + for i := range issuer { + issuer[i] = byte(0xA0 + i) + } + + msg, err := p.ParseTransactionStream(regTxStream(buildXregHubIOU(manager, currency, issuer, 9))) + require.NoError(t, err) + require.NotNil(t, msg) + + // Build the expected IOU sourceToken the SAME way the transfer path does: + // 0x01 || keccak256(currency20 || issuer20)[1:]. + sourceToken, consumed, err := regSourceTokenFromWire(append([]byte{xregTokenIOU}, append(currency[:], issuer[:]...)...)) + require.NoError(t, err) + require.Equal(t, 41, consumed) + + expected := p.calculateEmitterAddress(manager32From20(manager), sourceToken) + assert.Equal(t, expected, msg.EmitterAddress) + + // Canonical info payload carries the same sourceToken + decimals=9. + require.Equal(t, 70, len(msg.Payload)) + assert.Equal(t, sourceToken, [32]byte(msg.Payload[37:69])) + assert.Equal(t, uint8(9), msg.Payload[69]) +} + +func TestRegistration_PeerXRP(t *testing.T) { + p := NewParser(testRegCoreAccount, nil, nil) + manager := testRegManager20() + + var peerAddr [32]byte + for i := range peerAddr { + peerAddr[i] = byte(0x10 + i) + } + const peerChain = uint16(6) // Avalanche + + msg, err := p.ParseTransactionStream(regTxStream(buildXregPeerXRP(manager, peerChain, peerAddr))) + require.NoError(t, err) + require.NotNil(t, msg) + + // Emitter is keyed on the SAME (manager, XRP token) as the hub/transfers. + var zeroToken [32]byte + expected := p.calculateEmitterAddress(manager32From20(manager), zeroToken) + assert.Equal(t, expected, msg.EmitterAddress) + + // Payload is a canonical WormholeTransceiverRegistration: + // prefix(4) + chain_id(2) + transceiver_address(32) = 38 bytes. + require.Equal(t, 38, len(msg.Payload)) + assert.Equal(t, transceiverRegPrefix[:], msg.Payload[0:4]) + assert.Equal(t, peerChain, binary.BigEndian.Uint16(msg.Payload[4:6])) + assert.Equal(t, peerAddr, [32]byte(msg.Payload[6:38])) +} + +func TestRegistration_WrongMemoFormat_NotRegistration(t *testing.T) { + p := NewParser(testRegCoreAccount, nil, nil) + tx := regTxStream(buildXregHubXRP(testRegManager20(), 6)) + // A valid core publish (version 1 + nonce + payload) under coreMemoFormat must + // NOT be parsed as a registration: parseRegistrationTransaction returns nil and + // the generic core parser handles it (emitter = sender, not the keccak emitter). + coreMemo := append([]byte{0x01, 0, 0, 0, 0}, []byte("hello")...) + memos := tx.Transaction["Memos"].([]any) + memo := memos[0].(map[string]any)["Memo"].(map[string]any) + memo["MemoFormat"] = coreMemoFormat + memo["MemoData"] = hex.EncodeToString(coreMemo) + + // A valid core publish parses (as a generic core message), but must NOT be a + // transceiver-info payload — proving the registration parser declined it. + msg, err := p.ParseTransactionStream(tx) + require.NoError(t, err) + require.NotNil(t, msg) + assert.NotEqual(t, transceiverInfoPrefix[:], msg.Payload[0:4], + "non-XREG memo must not synthesize a transceiver-info payload") +} From 888770541ac68b2556535f3a0d91f7edc72f13b9 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 6 Jul 2026 18:55:40 +0200 Subject: [PATCH 02/17] chore: authenticate XREG registration sender against managed accounts --- node/pkg/watchers/xrpl/parse.go | 36 ++++++++++ node/pkg/watchers/xrpl/registration_test.go | 79 ++++++++++++++++++--- 2 files changed, 105 insertions(+), 10 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 2809aac82dc..7ee53b899b6 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -480,6 +480,18 @@ func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePubl return nil, nil } + senderID, err := p.registrationSenderAccountID(tx.Transaction) + if err != nil { + return nil, err + } + if senderID == nil { + return nil, nil + } + // manager occupies bytes [5:25] of the XREG payload (prefix4 + kind1 + manager20). + if len(regData) < 25 || !bytes.Equal(senderID, regData[5:25]) { + return nil, nil + } + if err = validateTransactionResult(tx); err != nil { return nil, err } @@ -554,6 +566,30 @@ func (p *Parser) parseRegistrationMemoData(tx transaction.FlatTransaction) ([]by return data, nil } +// registrationSenderAccountID returns the sender's 20-byte XRPL account ID when +// the sender is a managed (guardian-controlled) account, or nil when it is not +// managed — used to authenticate an XREG registration publish (see the SECURITY +// note in parseRegistrationTransaction). A missing/malformed Account field is an +// error; an unmanaged sender is a silent decline (nil, nil). +func (p *Parser) registrationSenderAccountID(tx transaction.FlatTransaction) ([]byte, error) { + accountRaw, ok := tx["Account"] + if !ok { + return nil, fmt.Errorf("transaction has no Account field") + } + account, ok := accountRaw.(string) + if !ok { + return nil, fmt.Errorf("transaction Account field is not a string") + } + if _, managed := p.managedAccounts[account]; !managed { + return nil, nil + } + _, accountID, err := addresscodec.DecodeClassicAddressToAccountID(account) + if err != nil { + return nil, fmt.Errorf("failed to decode sender address %s: %w", account, err) + } + return accountID, nil +} + // buildRegistrationMessage parses the XREG bytes and returns the canonical // transceiver payload plus the NTT transceiver emitter to publish under. // diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index cb696f84f21..2e24a619e21 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "testing" + addresscodec "github.com/Peersyst/xrpl-go/address-codec" streamtypes "github.com/Peersyst/xrpl-go/xrpl/queries/subscription/types" "github.com/Peersyst/xrpl-go/xrpl/transaction" "github.com/stretchr/testify/assert" @@ -24,6 +25,22 @@ func testRegManager20() [20]byte { return m } +// managerAddress derives the classic r-address for a 20-byte account id — the +// XRPL sender that a genuine XREG publish for that manager is signed by. +func managerAddress(t *testing.T, m [20]byte) string { + t.Helper() + addr, err := addresscodec.EncodeAccountIDToClassicAddress(m[:]) + require.NoError(t, err) + return addr +} + +// newRegParser builds a Parser whose managed set contains `manager`, i.e. the +// XREG sender is trusted. This is the setup a genuine registration publish uses. +func newRegParser(t *testing.T, manager [20]byte) *Parser { + t.Helper() + return NewParser(testRegCoreAccount, []string{managerAddress(t, manager)}, nil) +} + // buildXregHubXRP builds an XREG Hub payload for an XRP custody (matching the // sequencer's payloads.rs serialization): prefix + kind(0) + manager(20) + // token_id(XRP=0x00) + decimals(1). @@ -61,7 +78,9 @@ func buildXregPeerXRP(manager [20]byte, peerChain uint16, peerAddr [32]byte) []b return out } -func regTxStream(memoData []byte) *streamtypes.TransactionStream { +// regTxStream builds a registration publish signed by `sender` (the XRPL +// Account field) carrying the given XREG memo, targeting the core account. +func regTxStream(sender string, memoData []byte) *streamtypes.TransactionStream { return &streamtypes.TransactionStream{ Hash: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890", LedgerIndex: 12345, @@ -69,7 +88,7 @@ func regTxStream(memoData []byte) *streamtypes.TransactionStream { Validated: true, Transaction: transaction.FlatTransaction{ "TransactionType": "Payment", - "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "Account": sender, "Destination": testRegCoreAccount, "meta": map[string]any{ "TransactionResult": "tesSUCCESS", @@ -99,10 +118,10 @@ func manager32From20(m [20]byte) [32]byte { } func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { - p := NewParser(testRegCoreAccount, nil, nil) manager := testRegManager20() + p := newRegParser(t, manager) - msg, err := p.ParseTransactionStream(regTxStream(buildXregHubXRP(manager, 6))) + msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager, 6))) require.NoError(t, err) require.NotNil(t, msg, "registration publish should produce a message") @@ -125,8 +144,8 @@ func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { } func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { - p := NewParser(testRegCoreAccount, nil, nil) manager := testRegManager20() + p := newRegParser(t, manager) // Normalized currency (20) + issuer account id (20), as carried in XREG. var currency, issuer [20]byte @@ -135,7 +154,7 @@ func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { issuer[i] = byte(0xA0 + i) } - msg, err := p.ParseTransactionStream(regTxStream(buildXregHubIOU(manager, currency, issuer, 9))) + msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubIOU(manager, currency, issuer, 9))) require.NoError(t, err) require.NotNil(t, msg) @@ -155,8 +174,8 @@ func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { } func TestRegistration_PeerXRP(t *testing.T) { - p := NewParser(testRegCoreAccount, nil, nil) manager := testRegManager20() + p := newRegParser(t, manager) var peerAddr [32]byte for i := range peerAddr { @@ -164,7 +183,7 @@ func TestRegistration_PeerXRP(t *testing.T) { } const peerChain = uint16(6) // Avalanche - msg, err := p.ParseTransactionStream(regTxStream(buildXregPeerXRP(manager, peerChain, peerAddr))) + msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregPeerXRP(manager, peerChain, peerAddr))) require.NoError(t, err) require.NotNil(t, msg) @@ -182,8 +201,9 @@ func TestRegistration_PeerXRP(t *testing.T) { } func TestRegistration_WrongMemoFormat_NotRegistration(t *testing.T) { - p := NewParser(testRegCoreAccount, nil, nil) - tx := regTxStream(buildXregHubXRP(testRegManager20(), 6)) + manager := testRegManager20() + p := newRegParser(t, manager) + tx := regTxStream(managerAddress(t, manager), buildXregHubXRP(manager, 6)) // A valid core publish (version 1 + nonce + payload) under coreMemoFormat must // NOT be parsed as a registration: parseRegistrationTransaction returns nil and // the generic core parser handles it (emitter = sender, not the keccak emitter). @@ -201,3 +221,42 @@ func TestRegistration_WrongMemoFormat_NotRegistration(t *testing.T) { assert.NotEqual(t, transceiverInfoPrefix[:], msg.Payload[0:4], "non-XREG memo must not synthesize a transceiver-info payload") } + +// TestRegistration_UntrustedSender_Declined ensures a registration publish from +// an account that is NOT a managed (guardian-controlled) account is ignored. +// Otherwise anyone could forge a hub/peer registration by paying the core +// account with a crafted XREG memo. +func TestRegistration_UntrustedSender_Declined(t *testing.T) { + manager := testRegManager20() + // Parser trusts `manager`, but the publish is signed by a DIFFERENT account. + p := newRegParser(t, manager) + + var attacker [20]byte + for i := range attacker { + attacker[i] = byte(0xEE) + } + tx := regTxStream(managerAddress(t, attacker), buildXregHubXRP(manager, 6)) + + msg, err := p.ParseTransactionStream(tx) + require.NoError(t, err) + assert.Nil(t, msg, "registration from an untrusted sender must be declined") +} + +// TestRegistration_ManagerMismatch_Declined ensures the XREG manager field must +// match the (trusted) sender. A trusted account must not be able to register a +// hub/peer on behalf of a different manager account. +func TestRegistration_ManagerMismatch_Declined(t *testing.T) { + sender := testRegManager20() + + var otherManager [20]byte + for i := range otherManager { + otherManager[i] = byte(0x40 + i) + } + // Sender is trusted, but the memo names a different manager. + p := newRegParser(t, sender) + tx := regTxStream(managerAddress(t, sender), buildXregHubXRP(otherManager, 6)) + + msg, err := p.ParseTransactionStream(tx) + require.NoError(t, err) + assert.Nil(t, msg, "registration whose manager != sender must be declined") +} From 147a7db8d352d381d17cbfa8bd9a54b9fd0c2a12 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Mon, 6 Jul 2026 23:13:04 +0200 Subject: [PATCH 03/17] fix: tests --- node/pkg/watchers/xrpl/README.md | 2 + node/pkg/watchers/xrpl/parse.go | 43 ++++++++++++--------- node/pkg/watchers/xrpl/registration_test.go | 28 ++++++++------ 3 files changed, 43 insertions(+), 30 deletions(-) diff --git a/node/pkg/watchers/xrpl/README.md b/node/pkg/watchers/xrpl/README.md index 63416f80621..fe9bf5896f4 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -100,6 +100,8 @@ uint32 nonce []byte payload ``` + + ### Registration (Initialize Transceiver & Peer Registration) Because XRPL has no smart contracts, the `WormholeTransceiverInfo` (hub init) and diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 7ee53b899b6..2a70206ffd1 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -106,6 +106,16 @@ const ( xregTokenIOU = 0x01 // wire token_id opening byte for issued currencies xregTokenMPT = 0x02 // wire token_id opening byte for MPT nttModeLocking = 0x00 // manager mode for XRPL custody accounts (locking; accountant only accepts locking) + + xregManagerOffset = 5 // offset of the manager field: after prefix(4) + kind(1) + xregHeaderLen = 25 // prefix(4) + kind(1) + manager(20); token_id + tail follow + + xregTokenXRPWireLen = 1 // XRP wire token_id: type(1) + xregTokenIOUWireLen = 41 // IOU wire token_id: type(1) + currency(20) + issuer(20) + xregTokenMPTWireLen = 25 // MPT wire token_id: type(1) + mpt_issuance_id(24) + + transceiverInfoLen = 70 // WormholeTransceiverInfo: prefix(4) + manager(32) + mode(1) + token(32) + decimals(1) + transceiverRegLen = 38 // WormholeTransceiverRegistration: prefix(4) + chain_id(2) + transceiver(32) ) // regSourceTokenFromWire converts an XREG wire token_id (XrplTokenId form) into @@ -125,27 +135,25 @@ func regSourceTokenFromWire(wire []byte) ([32]byte, int, error) { } switch wire[0] { case xregTokenXRP: - return sourceToken, 1, nil // all zeros + return sourceToken, xregTokenXRPWireLen, nil // all zeros case xregTokenIOU: - // 1 + currency(20) + issuer(20) = 41 bytes - if len(wire) < 41 { + if len(wire) < xregTokenIOUWireLen { return sourceToken, 0, fmt.Errorf("xreg IOU token_id too short: %d", len(wire)) } // currency[20] || issuer[20] are already the normalized/decoded forms // the transfer path hashes (currencycodec.NormalizedLen == 20, issuer is // the 20-byte account ID), so hash them directly. - hash := ethcrypto.Keccak256(wire[1:41]) + hash := ethcrypto.Keccak256(wire[1:xregTokenIOUWireLen]) sourceToken[0] = tokenTypeIssued copy(sourceToken[1:], hash[1:]) - return sourceToken, 41, nil + return sourceToken, xregTokenIOUWireLen, nil case xregTokenMPT: - // 1 + mpt_issuance_id(24) = 25 bytes - if len(wire) < 25 { + if len(wire) < xregTokenMPTWireLen { return sourceToken, 0, fmt.Errorf("xreg MPT token_id too short: %d", len(wire)) } sourceToken[0] = tokenTypeMPT - copy(sourceToken[8:], wire[1:25]) // 1 prefix + 7 padding = offset 8 - return sourceToken, 25, nil + copy(sourceToken[8:], wire[1:xregTokenMPTWireLen]) // 1 prefix + 7 padding = offset 8 + return sourceToken, xregTokenMPTWireLen, nil default: return sourceToken, 0, fmt.Errorf("xreg unknown token type: 0x%02x", wire[0]) } @@ -156,7 +164,7 @@ func regSourceTokenFromWire(wire []byte) ([32]byte, int, error) { // prefix(4=0x9c23bd3b) + manager_address(32) + manager_mode(1) + // token_address(32) + token_decimals(1) = 70 bytes func buildTransceiverInfoPayload(manager32, token32 [32]byte, decimals uint8) []byte { - out := make([]byte, 0, 70) + out := make([]byte, 0, transceiverInfoLen) out = append(out, transceiverInfoPrefix[:]...) out = append(out, manager32[:]...) out = append(out, nttModeLocking) @@ -169,7 +177,7 @@ func buildTransceiverInfoPayload(manager32, token32 [32]byte, decimals uint8) [] // // prefix(4=0x18fc67c2) + chain_id(2 BE) + transceiver_address(32) = 38 bytes func buildTransceiverRegistrationPayload(peerChain uint16, peerAddress [32]byte) []byte { - out := make([]byte, 0, 38) + out := make([]byte, 0, transceiverRegLen) out = append(out, transceiverRegPrefix[:]...) out = binary.BigEndian.AppendUint16(out, peerChain) out = append(out, peerAddress[:]...) @@ -487,8 +495,8 @@ func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePubl if senderID == nil { return nil, nil } - // manager occupies bytes [5:25] of the XREG payload (prefix4 + kind1 + manager20). - if len(regData) < 25 || !bytes.Equal(senderID, regData[5:25]) { + // manager occupies bytes [xregManagerOffset:xregHeaderLen] of the XREG payload. + if len(regData) < xregHeaderLen || !bytes.Equal(senderID, regData[xregManagerOffset:xregHeaderLen]) { return nil, nil } @@ -599,23 +607,22 @@ func (p *Parser) registrationSenderAccountID(tx transaction.FlatTransaction) ([] // Peer tail: peer_chain(2 BE) + peer_address(32) func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { var emitter vaa.Address - const headerLen = 4 + 1 + 20 - if len(data) < headerLen+1 { + if len(data) < xregHeaderLen+1 { return nil, emitter, fmt.Errorf("xreg payload too short: %d", len(data)) } kind := data[4] var manager20 [20]byte - copy(manager20[:], data[5:25]) + copy(manager20[:], data[xregManagerOffset:xregHeaderLen]) // manager32 = left-padded 20-byte account id (same as transfer sourceNTTManager). var manager32 [32]byte copy(manager32[12:], manager20[:]) - sourceToken, consumed, err := regSourceTokenFromWire(data[25:]) + sourceToken, consumed, err := regSourceTokenFromWire(data[xregHeaderLen:]) if err != nil { return nil, emitter, err } - tail := data[25+consumed:] + tail := data[xregHeaderLen+consumed:] emitter = (&Parser{}).calculateEmitterAddress(manager32, sourceToken) diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index 2e24a619e21..20d4e30c4b5 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -44,12 +44,12 @@ func newRegParser(t *testing.T, manager [20]byte) *Parser { // buildXregHubXRP builds an XREG Hub payload for an XRP custody (matching the // sequencer's payloads.rs serialization): prefix + kind(0) + manager(20) + // token_id(XRP=0x00) + decimals(1). -func buildXregHubXRP(manager [20]byte, decimals uint8) []byte { +func buildXregHubXRP(manager [20]byte) []byte { out := append([]byte{}, xregPrefix[:]...) out = append(out, xregKindHub) out = append(out, manager[:]...) out = append(out, xregTokenXRP) // token_id wire - out = append(out, decimals) + out = append(out, xrpDecimals) // XRP decimals are fixed at 6 return out } @@ -121,7 +121,7 @@ func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { manager := testRegManager20() p := newRegParser(t, manager) - msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager, 6))) + msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager))) require.NoError(t, err) require.NotNil(t, msg, "registration publish should produce a message") @@ -135,12 +135,12 @@ func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { // Payload is a canonical WormholeTransceiverInfo: prefix(4) + manager(32) + // mode(1) + token(32) + decimals(1) = 70 bytes. - require.Equal(t, 70, len(msg.Payload)) + require.Equal(t, transceiverInfoLen, len(msg.Payload)) assert.Equal(t, transceiverInfoPrefix[:], msg.Payload[0:4]) assert.Equal(t, manager32From20(manager), [32]byte(msg.Payload[4:36])) assert.Equal(t, uint8(nttModeLocking), msg.Payload[36]) assert.Equal(t, zeroToken, [32]byte(msg.Payload[37:69])) - assert.Equal(t, uint8(6), msg.Payload[69]) + assert.Equal(t, uint8(xrpDecimals), msg.Payload[69]) } func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { @@ -168,7 +168,7 @@ func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { assert.Equal(t, expected, msg.EmitterAddress) // Canonical info payload carries the same sourceToken + decimals=9. - require.Equal(t, 70, len(msg.Payload)) + require.Equal(t, transceiverInfoLen, len(msg.Payload)) assert.Equal(t, sourceToken, [32]byte(msg.Payload[37:69])) assert.Equal(t, uint8(9), msg.Payload[69]) } @@ -194,7 +194,7 @@ func TestRegistration_PeerXRP(t *testing.T) { // Payload is a canonical WormholeTransceiverRegistration: // prefix(4) + chain_id(2) + transceiver_address(32) = 38 bytes. - require.Equal(t, 38, len(msg.Payload)) + require.Equal(t, transceiverRegLen, len(msg.Payload)) assert.Equal(t, transceiverRegPrefix[:], msg.Payload[0:4]) assert.Equal(t, peerChain, binary.BigEndian.Uint16(msg.Payload[4:6])) assert.Equal(t, peerAddr, [32]byte(msg.Payload[6:38])) @@ -203,13 +203,17 @@ func TestRegistration_PeerXRP(t *testing.T) { func TestRegistration_WrongMemoFormat_NotRegistration(t *testing.T) { manager := testRegManager20() p := newRegParser(t, manager) - tx := regTxStream(managerAddress(t, manager), buildXregHubXRP(manager, 6)) + tx := regTxStream(managerAddress(t, manager), buildXregHubXRP(manager)) // A valid core publish (version 1 + nonce + payload) under coreMemoFormat must // NOT be parsed as a registration: parseRegistrationTransaction returns nil and // the generic core parser handles it (emitter = sender, not the keccak emitter). coreMemo := append([]byte{0x01, 0, 0, 0, 0}, []byte("hello")...) - memos := tx.Transaction["Memos"].([]any) - memo := memos[0].(map[string]any)["Memo"].(map[string]any) + memos, ok := tx.Transaction["Memos"].([]any) + require.True(t, ok, "Memos should be a slice") + memoWrapper, ok := memos[0].(map[string]any) + require.True(t, ok, "memo[0] should be a map") + memo, ok := memoWrapper["Memo"].(map[string]any) + require.True(t, ok, "Memo should be a map") memo["MemoFormat"] = coreMemoFormat memo["MemoData"] = hex.EncodeToString(coreMemo) @@ -235,7 +239,7 @@ func TestRegistration_UntrustedSender_Declined(t *testing.T) { for i := range attacker { attacker[i] = byte(0xEE) } - tx := regTxStream(managerAddress(t, attacker), buildXregHubXRP(manager, 6)) + tx := regTxStream(managerAddress(t, attacker), buildXregHubXRP(manager)) msg, err := p.ParseTransactionStream(tx) require.NoError(t, err) @@ -254,7 +258,7 @@ func TestRegistration_ManagerMismatch_Declined(t *testing.T) { } // Sender is trusted, but the memo names a different manager. p := newRegParser(t, sender) - tx := regTxStream(managerAddress(t, sender), buildXregHubXRP(otherManager, 6)) + tx := regTxStream(managerAddress(t, sender), buildXregHubXRP(otherManager)) msg, err := p.ParseTransactionStream(tx) require.NoError(t, err) From afb363b6a0c52730eaf1bb647ea628a709635db6 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Tue, 7 Jul 2026 20:01:52 +0200 Subject: [PATCH 04/17] feat: NTT Manager mode locking auto-discovery for XRPL --- node/pkg/watchers/xrpl/README.md | 24 ++++++-- node/pkg/watchers/xrpl/parse.go | 65 +++++++++++++++++---- node/pkg/watchers/xrpl/registration_test.go | 55 ++++++++++++++++- 3 files changed, 127 insertions(+), 17 deletions(-) diff --git a/node/pkg/watchers/xrpl/README.md b/node/pkg/watchers/xrpl/README.md index fe9bf5896f4..99d66610454 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -104,8 +104,8 @@ uint32 nonce ### Registration (Initialize Transceiver & Peer Registration) -Because XRPL has no smart contracts, the `WormholeTransceiverInfo` (hub init) and -`WormholeTransceiverRegistration` (peer) messages cannot be emitted natively. +Because XRPL has no smart contracts, the [`WormholeTransceiverInfo`](https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md#initialize-transceiver) (hub init) and +[`WormholeTransceiverRegistration`](https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md#transceiver-peer-registration) (peer) messages cannot be emitted natively. Instead, the Solana Sequencer emits an `XrplRegistration` (prefix `XREG`) message when a custody account is initialized (`initialize`) or a peer is registered (`register_peer`), and the executor relays it back to XRPL as a Payment to the @@ -127,7 +127,7 @@ The MemoData carries the raw `XREG` bytes: [4]byte prefix = "XREG" uint8 kind // 0 = Hub, 1 = Peer [20]byte manager // XRPL custody account id -[]byte token_id // wire form: XRP 1B / IOU 41B / MPT 25B +[]byte token_id // wire form, per the sequencer token_id encoding (see below) // Hub tail (kind=0): uint8 token_decimals // Peer tail (kind=1): @@ -135,6 +135,14 @@ uint16 peer_chain // big-endian [32]byte peer_address ``` +The `token_id` uses the same encoding as the sequencer's XREL payload: + +| Token Type | Byte 0 | Bytes 1+ | Total | +| ---------- | ------ | --------------------------- | -------- | +| XRP | 0x00 | (none) | 1 byte | +| IOU | 0x01 | currency (20) + issuer (20) | 41 bytes | +| MPT | 0x02 | mpt_issuance_id (24) | 25 bytes | + The watcher derives the NTT transceiver emitter `keccak256("ntt" + manager32 + token32)` — the **same** emitter used for `NativeTokenTransfer` messages from this manager+token pair — and emits the VAA @@ -148,11 +156,19 @@ Synthesize a `WormholeTransceiverInfo` payload (70 bytes): ```go [4]byte prefix = 0x9c23bd3b [32]byte manager_address // manager32 (left-padded XRPL account) -uint8 manager_mode = 1 // locking +uint8 manager_mode // 0 = Locking, 1 = Burning (see below) [32]byte token_address // token32 derived from token_id uint8 token_decimals ``` +`manager_mode` is derived by the watcher from the XREG `manager` and `token_id`: +an XRPL custody account is in **Burning** mode when it is itself the token's +issuer (it can mint/burn), and **Locking** mode otherwise (it holds a token +issued by another account). The issuer is the last 20 bytes of an IOU `token_id`, +or the first 20 bytes of an MPT `mpt_issuance_id`; XRP has no issuer and is always +Locking. The `ntt-global-accountant` only accepts Locking hubs, but the init +message must attest the manager's true mode rather than assume Locking. + #### Transceiver (Peer) Registration (kind = Peer) Synthesize a `WormholeTransceiverRegistration` payload (38 bytes): diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 2a70206ffd1..27509f7e2d0 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -100,15 +100,22 @@ var transceiverRegPrefix = [4]byte{0x18, 0xfc, 0x67, 0xc2} // WormholeTransceiv // XREG constants const ( - xregKindHub = 0x00 // hub sub-type (byte after prefix), mirrors sequencer XREG_KIND_HUB - xregKindPeer = 0x01 // peer sub-type (byte after prefix), mirrors sequencer XREG_KIND_PEER - xregTokenXRP = 0x00 // wire token_id opening byte for XRP, mirrors XrplTokenId - xregTokenIOU = 0x01 // wire token_id opening byte for issued currencies - xregTokenMPT = 0x02 // wire token_id opening byte for MPT - nttModeLocking = 0x00 // manager mode for XRPL custody accounts (locking; accountant only accepts locking) + xregKindHub = 0x00 // hub sub-type (byte after prefix), mirrors sequencer XREG_KIND_HUB + xregKindPeer = 0x01 // peer sub-type (byte after prefix), mirrors sequencer XREG_KIND_PEER + xregTokenXRP = 0x00 // wire token_id opening byte for XRP, mirrors XrplTokenId + xregTokenIOU = 0x01 // wire token_id opening byte for issued currencies + xregTokenMPT = 0x02 // wire token_id opening byte for MPT + + // NTT manager modes (ntt-messages Mode enum). An XRPL custody account is in + // Burning mode when it is itself the token issuer (it can mint/burn), and + // Locking mode otherwise (it holds a token issued by another account). XRP + // has no issuer, so it is always Locking. + nttModeLocking = 0x00 + nttModeBurning = 0x01 xregManagerOffset = 5 // offset of the manager field: after prefix(4) + kind(1) xregHeaderLen = 25 // prefix(4) + kind(1) + manager(20); token_id + tail follow + xregPeerTailLen = 34 // peer tail: peer_chain(2) + peer_address(32) xregTokenXRPWireLen = 1 // XRP wire token_id: type(1) xregTokenIOUWireLen = 41 // IOU wire token_id: type(1) + currency(20) + issuer(20) @@ -159,15 +166,47 @@ func regSourceTokenFromWire(wire []byte) ([32]byte, int, error) { } } +// nttManagerModeFromWire determines whether the custody account (manager) runs +// in Locking or Burning mode for the token described by the XREG wire token_id. +// The manager is in Burning mode when it is itself the token's issuer. +// +// IOU: issuer is the last 20 bytes of the token_id (currency(20) then issuer(20)) +// MPT: mpt_issuance_id = Sequence(4) || IssuerAccountID(20) per the XRPL spec, +// so the issuer is the last 20 bytes of the 24-byte id. +func nttManagerModeFromWire(manager20 [20]byte, wire []byte) byte { + if len(wire) < 1 { + return nttModeLocking + } + var issuer []byte + switch wire[0] { + case xregTokenIOU: + if len(wire) < xregTokenIOUWireLen { + return nttModeLocking + } + issuer = wire[xregIouIssuerOffset:xregTokenIOUWireLen] + case xregTokenMPT: + if len(wire) < xregTokenMPTWireLen { + return nttModeLocking + } + issuer = wire[xregMptIssuerOffset:xregTokenMPTWireLen] + default: // XRP has no issuer → Locking + return nttModeLocking + } + if bytes.Equal(issuer, manager20[:]) { + return nttModeBurning + } + return nttModeLocking +} + // buildTransceiverInfoPayload builds a WormholeTransceiverInfo payload: // // prefix(4=0x9c23bd3b) + manager_address(32) + manager_mode(1) + // token_address(32) + token_decimals(1) = 70 bytes -func buildTransceiverInfoPayload(manager32, token32 [32]byte, decimals uint8) []byte { +func buildTransceiverInfoPayload(manager32, token32 [32]byte, mode, decimals uint8) []byte { out := make([]byte, 0, transceiverInfoLen) out = append(out, transceiverInfoPrefix[:]...) out = append(out, manager32[:]...) - out = append(out, nttModeLocking) + out = append(out, mode) out = append(out, token32[:]...) out = append(out, decimals) return out @@ -618,7 +657,8 @@ func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { var manager32 [32]byte copy(manager32[12:], manager20[:]) - sourceToken, consumed, err := regSourceTokenFromWire(data[xregHeaderLen:]) + tokenWire := data[xregHeaderLen:] + sourceToken, consumed, err := regSourceTokenFromWire(tokenWire) if err != nil { return nil, emitter, err } @@ -632,14 +672,15 @@ func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { return nil, emitter, fmt.Errorf("xreg hub missing decimals") } decimals := tail[0] - return buildTransceiverInfoPayload(manager32, sourceToken, decimals), emitter, nil + mode := nttManagerModeFromWire(manager20, tokenWire) + return buildTransceiverInfoPayload(manager32, sourceToken, mode, decimals), emitter, nil case xregKindPeer: - if len(tail) < 2+32 { + if len(tail) < xregPeerTailLen { return nil, emitter, fmt.Errorf("xreg peer tail too short: %d", len(tail)) } peerChain := binary.BigEndian.Uint16(tail[0:2]) var peerAddr [32]byte - copy(peerAddr[:], tail[2:34]) + copy(peerAddr[:], tail[2:xregPeerTailLen]) return buildTransceiverRegistrationPayload(peerChain, peerAddr), emitter, nil default: return nil, emitter, fmt.Errorf("xreg unknown kind: 0x%02x", kind) diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index 20d4e30c4b5..ad7b12f83ea 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -66,6 +66,18 @@ func buildXregHubIOU(manager [20]byte, currency, issuer [20]byte, decimals uint8 return out } +// buildXregHubMPT builds an XREG Hub for an MPT custody: +// prefix + kind(0) + manager(20) + token_id(0x02 + mpt_issuance_id24) + decimals. +func buildXregHubMPT(manager [20]byte, mptID [24]byte, decimals uint8) []byte { + out := append([]byte{}, xregPrefix[:]...) + out = append(out, xregKindHub) + out = append(out, manager[:]...) + out = append(out, xregTokenMPT) + out = append(out, mptID[:]...) + out = append(out, decimals) + return out +} + // buildXregPeerXRP builds an XREG Peer for an XRP custody: // prefix + kind(1) + manager(20) + token_id(XRP) + peer_chain(2) + peer_address(32). func buildXregPeerXRP(manager [20]byte, peerChain uint16, peerAddr [32]byte) []byte { @@ -162,17 +174,58 @@ func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { // 0x01 || keccak256(currency20 || issuer20)[1:]. sourceToken, consumed, err := regSourceTokenFromWire(append([]byte{xregTokenIOU}, append(currency[:], issuer[:]...)...)) require.NoError(t, err) - require.Equal(t, 41, consumed) + require.Equal(t, xregTokenIOUWireLen, consumed) expected := p.calculateEmitterAddress(manager32From20(manager), sourceToken) assert.Equal(t, expected, msg.EmitterAddress) + // issuer != manager, so this custody account is in Locking mode. + assert.Equal(t, uint8(nttModeLocking), msg.Payload[36], "issuer != manager => Locking") + // Canonical info payload carries the same sourceToken + decimals=9. require.Equal(t, transceiverInfoLen, len(msg.Payload)) assert.Equal(t, sourceToken, [32]byte(msg.Payload[37:69])) assert.Equal(t, uint8(9), msg.Payload[69]) } +// TestRegistration_HubIOU_BurningWhenManagerIsIssuer verifies the manager mode +// is Burning when the custody account is itself the token issuer. +func TestRegistration_HubIOU_BurningWhenManagerIsIssuer(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + + var currency [20]byte + copy(currency[12:15], []byte("FOO")) + // issuer == manager => Burning mode. + issuer := manager + + msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubIOU(manager, currency, issuer, 9))) + require.NoError(t, err) + require.NotNil(t, msg) + + require.Equal(t, transceiverInfoLen, len(msg.Payload)) + assert.Equal(t, uint8(nttModeBurning), msg.Payload[36], "manager == issuer => Burning") +} + +// TestRegistration_HubMPT_BurningWhenManagerIsIssuer verifies mode derivation for +// MPT, whose issuer is the first 20 bytes of the mpt_issuance_id. +func TestRegistration_HubMPT_BurningWhenManagerIsIssuer(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + + // mpt_issuance_id = Sequence(4) || Issuer(20) per XLS-0033; set issuer == manager. + var mptID [24]byte + mptID[3] = 0x07 // arbitrary sequence (first 4 bytes) + copy(mptID[4:24], manager[:]) // issuer is the last 20 bytes + + msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubMPT(manager, mptID, 6))) + require.NoError(t, err) + require.NotNil(t, msg) + + require.Equal(t, transceiverInfoLen, len(msg.Payload)) + assert.Equal(t, uint8(nttModeBurning), msg.Payload[36], "manager == MPT issuer => Burning") +} + func TestRegistration_PeerXRP(t *testing.T) { manager := testRegManager20() p := newRegParser(t, manager) From cb8ff1934f0114a0d80f3fd537024977a0ae88c4 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 9 Jul 2026 12:44:48 +0200 Subject: [PATCH 05/17] refactor: dedupe emitter/sender helpers in registration parsing --- node/pkg/watchers/xrpl/README.md | 10 +-- node/pkg/watchers/xrpl/parse.go | 67 +++++++++++++-------- node/pkg/watchers/xrpl/parse_test.go | 12 ++-- node/pkg/watchers/xrpl/registration_test.go | 6 +- 4 files changed, 54 insertions(+), 41 deletions(-) diff --git a/node/pkg/watchers/xrpl/README.md b/node/pkg/watchers/xrpl/README.md index 99d66610454..ec772d8cb5f 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -106,10 +106,12 @@ uint32 nonce Because XRPL has no smart contracts, the [`WormholeTransceiverInfo`](https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md#initialize-transceiver) (hub init) and [`WormholeTransceiverRegistration`](https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md#transceiver-peer-registration) (peer) messages cannot be emitted natively. -Instead, the Solana Sequencer emits an `XrplRegistration` (prefix `XREG`) message -when a custody account is initialized (`initialize`) or a peer is registered -(`register_peer`), and the executor relays it back to XRPL as a Payment to the -Core Account. The watcher then re-keys and synthesizes the canonical transceiver +Instead, when a custody account is initialized (`initialize`) or a peer is +registered (`register_peer`), the Solana Sequencer emits the registration as an +`XrplRelease` (XREL) carrying the `XrplRegistration` (XREG) bytes in its first +memo. The delegated manager set signs and submits that XREL as a multisig Payment +to the Core Account (there is no standalone XREG message and the executor is not +involved). The watcher then re-keys and synthesizes the canonical transceiver message under the correct NTT transceiver emitter. Upon a Payment to the Core Account, the guardian watcher MUST diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 27509f7e2d0..3a9ce9884d7 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -121,6 +121,10 @@ const ( xregTokenIOUWireLen = 41 // IOU wire token_id: type(1) + currency(20) + issuer(20) xregTokenMPTWireLen = 25 // MPT wire token_id: type(1) + mpt_issuance_id(24) + // Issuer offsets within an XREG wire token_id (used to derive manager mode). + xregIouIssuerOffset = 21 // IOU issuer starts after type(1) + currency(20) + xregMptIssuerOffset = 5 // MPT issuer = last 20 bytes of the 24-byte id: after type(1) + sequence(4) (MPTokenIssuanceID = Sequence(4) || Issuer(20)) + transceiverInfoLen = 70 // WormholeTransceiverInfo: prefix(4) + manager(32) + mode(1) + token(32) + decimals(1) transceiverRegLen = 38 // WormholeTransceiverRegistration: prefix(4) + chain_id(2) + transceiver(32) ) @@ -426,7 +430,7 @@ func (p *Parser) parseNttTransaction( } // Calculate emitter address: keccak256("ntt" + source_ntt_manager + source_token) - emitterAddress := p.calculateEmitterAddress(sourceNTTManager, tokenInfo.sourceToken) + emitterAddress := calculateEmitterAddress(sourceNTTManager, tokenInfo.sourceToken) // Build the NTT payload payload := p.buildNTTPayload( @@ -619,13 +623,9 @@ func (p *Parser) parseRegistrationMemoData(tx transaction.FlatTransaction) ([]by // note in parseRegistrationTransaction). A missing/malformed Account field is an // error; an unmanaged sender is a silent decline (nil, nil). func (p *Parser) registrationSenderAccountID(tx transaction.FlatTransaction) ([]byte, error) { - accountRaw, ok := tx["Account"] - if !ok { - return nil, fmt.Errorf("transaction has no Account field") - } - account, ok := accountRaw.(string) - if !ok { - return nil, fmt.Errorf("transaction Account field is not a string") + account, err := extractAccountString(tx) + if err != nil { + return nil, err } if _, managed := p.managedAccounts[account]; !managed { return nil, nil @@ -653,9 +653,13 @@ func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { var manager20 [20]byte copy(manager20[:], data[xregManagerOffset:xregHeaderLen]) - // manager32 = left-padded 20-byte account id (same as transfer sourceNTTManager). - var manager32 [32]byte - copy(manager32[12:], manager20[:]) + // manager32 = the 20-byte account id right-aligned in 32 bytes (same as the + // transfer path's sourceNTTManager). vaa.BytesToAddress does the left-pad. + manager32Addr, err := vaa.BytesToAddress(manager20[:]) + if err != nil { + return nil, emitter, fmt.Errorf("failed to pad manager: %w", err) + } + manager32 := [32]byte(manager32Addr) tokenWire := data[xregHeaderLen:] sourceToken, consumed, err := regSourceTokenFromWire(tokenWire) @@ -664,7 +668,7 @@ func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { } tail := data[xregHeaderLen+consumed:] - emitter = (&Parser{}).calculateEmitterAddress(manager32, sourceToken) + emitter = calculateEmitterAddress(manager32, sourceToken) switch kind { case xregKindHub: @@ -1111,19 +1115,28 @@ func validateTransactionType(tx transaction.FlatTransaction) error { return nil } -// extractSender extracts the sender address from the transaction Account field -// and converts it to a 32-byte format. -func (p *Parser) extractSender(tx transaction.FlatTransaction) ([32]byte, error) { - var sender [32]byte - +// extractAccountString returns the transaction's `Account` (sender) r-address. +// Shared by extractSender and registrationSenderAccountID. +func extractAccountString(tx transaction.FlatTransaction) (string, error) { accountRaw, ok := tx["Account"] if !ok { - return sender, fmt.Errorf("transaction has no Account field") + return "", fmt.Errorf("transaction has no Account field") } - account, ok := accountRaw.(string) if !ok { - return sender, fmt.Errorf("transaction Account field is not a string") + return "", fmt.Errorf("transaction Account field is not a string") + } + return account, nil +} + +// extractSender extracts the sender address from the transaction Account field +// and converts it to a 32-byte format. +func (p *Parser) extractSender(tx transaction.FlatTransaction) ([32]byte, error) { + var sender [32]byte + + account, err := extractAccountString(tx) + if err != nil { + return sender, err } emitter, err := p.addressToEmitter(account) @@ -1260,11 +1273,12 @@ func (p *Parser) addressToEmitter(address string) (vaa.Address, error) { return vaa.Address{}, fmt.Errorf("unexpected account ID length: got %d, want %d", len(accountID), addresscodec.AccountAddressLength) } - // Left-pad with zeros to create 32-byte emitter address - // vaa.Address is [32]byte, accountID is 20 bytes - // Place accountID in the last 20 bytes (indices 12-31) - var emitter vaa.Address - copy(emitter[32-addresscodec.AccountAddressLength:], accountID) + // Right-align the 20-byte account id in a 32-byte vaa.Address (left-padded + // with 12 zero bytes). Uses the SDK helper rather than hand-rolled copying. + emitter, err := vaa.BytesToAddress(accountID) + if err != nil { + return vaa.Address{}, fmt.Errorf("failed to pad account id: %w", err) + } return emitter, nil } @@ -1618,7 +1632,8 @@ func (p *Parser) scaleAmount(amount uint64, fromDecimals, toDecimals uint8) (uin // calculateEmitterAddress calculates the emitter address from source NTT manager and source token. // emitter = keccak256("ntt" + source_ntt_manager_address + source_token) -func (p *Parser) calculateEmitterAddress(sourceNTTManager, sourceToken [32]byte) vaa.Address { +// It is a pure function of its inputs (no Parser state), so it is a free function. +func calculateEmitterAddress(sourceNTTManager, sourceToken [32]byte) vaa.Address { const addrLen = len(sourceNTTManager) data := make([]byte, nttEmitterDomainLen+2*addrLen) copy(data[:nttEmitterDomainLen], "ntt") diff --git a/node/pkg/watchers/xrpl/parse_test.go b/node/pkg/watchers/xrpl/parse_test.go index 42ce0a92725..3299fde4c55 100644 --- a/node/pkg/watchers/xrpl/parse_test.go +++ b/node/pkg/watchers/xrpl/parse_test.go @@ -743,23 +743,19 @@ func TestScaleAmount_ToZeroDecimals(t *testing.T) { // ============================================================================= func TestCalculateEmitterAddress_Deterministic(t *testing.T) { - p := NewParser("", nil, nil) - var sourceNTTManager [32]byte var sourceToken [32]byte sourceNTTManager[31] = 0x01 sourceToken[0] = tokenTypeXRP // Call twice and verify same result - emitter1 := p.calculateEmitterAddress(sourceNTTManager, sourceToken) - emitter2 := p.calculateEmitterAddress(sourceNTTManager, sourceToken) + emitter1 := calculateEmitterAddress(sourceNTTManager, sourceToken) + emitter2 := calculateEmitterAddress(sourceNTTManager, sourceToken) assert.Equal(t, emitter1, emitter2) } func TestCalculateEmitterAddress_DifferentTokens(t *testing.T) { - p := NewParser("", nil, nil) - var sourceNTTManager [32]byte sourceNTTManager[31] = 0x01 @@ -770,8 +766,8 @@ func TestCalculateEmitterAddress_DifferentTokens(t *testing.T) { issuedToken[0] = tokenTypeIssued issuedToken[1] = 0xAB - emitter1 := p.calculateEmitterAddress(sourceNTTManager, xrpToken) - emitter2 := p.calculateEmitterAddress(sourceNTTManager, issuedToken) + emitter1 := calculateEmitterAddress(sourceNTTManager, xrpToken) + emitter2 := calculateEmitterAddress(sourceNTTManager, issuedToken) assert.NotEqual(t, emitter1, emitter2, "Different tokens should produce different emitters") } diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index ad7b12f83ea..ef5be0ca35c 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -142,7 +142,7 @@ func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { // The synthesized emitter MUST equal the transfer-path emitter for the same // (manager, XRP sourceToken=zeros) — this is the core correctness guarantee. var zeroToken [32]byte - expected := p.calculateEmitterAddress(manager32From20(manager), zeroToken) + expected := calculateEmitterAddress(manager32From20(manager), zeroToken) assert.Equal(t, expected, msg.EmitterAddress, "hub emitter must match transfer emitter") // Payload is a canonical WormholeTransceiverInfo: prefix(4) + manager(32) + @@ -176,7 +176,7 @@ func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { require.NoError(t, err) require.Equal(t, xregTokenIOUWireLen, consumed) - expected := p.calculateEmitterAddress(manager32From20(manager), sourceToken) + expected := calculateEmitterAddress(manager32From20(manager), sourceToken) assert.Equal(t, expected, msg.EmitterAddress) // issuer != manager, so this custody account is in Locking mode. @@ -242,7 +242,7 @@ func TestRegistration_PeerXRP(t *testing.T) { // Emitter is keyed on the SAME (manager, XRP token) as the hub/transfers. var zeroToken [32]byte - expected := p.calculateEmitterAddress(manager32From20(manager), zeroToken) + expected := calculateEmitterAddress(manager32From20(manager), zeroToken) assert.Equal(t, expected, msg.EmitterAddress) // Payload is a canonical WormholeTransceiverRegistration: From 3ee99ec493fd4d03d5e5db667bd9c210cfc42125 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 9 Jul 2026 13:12:19 +0200 Subject: [PATCH 06/17] fix: spellcheck ignore --- node/pkg/watchers/xrpl/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/pkg/watchers/xrpl/README.md b/node/pkg/watchers/xrpl/README.md index ec772d8cb5f..f25c92096a0 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -100,7 +100,7 @@ uint32 nonce []byte payload ``` - + ### Registration (Initialize Transceiver & Peer Registration) From c6a3a82da78ab2081b2f765ccb1fee921d35343c Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 15 Jul 2026 17:00:32 +0200 Subject: [PATCH 07/17] fix: emit registration + XACK for ticketed XREG in XREL payment --- node/pkg/watchers/xrpl/parse.go | 80 ++++++++++--- node/pkg/watchers/xrpl/parse_test.go | 65 +++++++--- node/pkg/watchers/xrpl/registration_test.go | 125 +++++++++++++++++--- node/pkg/watchers/xrpl/watcher.go | 26 ++-- 4 files changed, 229 insertions(+), 67 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 3a9ce9884d7..b4cc4f43fce 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -302,11 +302,14 @@ func NewParser(coreAccount string, managedAccounts []string, fetchMPTAssetScale // Transaction parsing entry points // ============================================================================= -// ParseTransactionStream converts an XRPL TransactionStream into a MessagePublication. +// ParseTransactionStream converts an XRPL TransactionStream into zero or more +// MessagePublications. A single transaction usually yields one message, but a +// registration publish (XREG-in-XREL) yields two: the synthesized registration +// AND the XACK that clears the ticket it consumed. // // SECURITY: This function does not verify that the transaction is included in a validated ledger. // Callers MUST check tx.Validated before calling this function. -func (p *Parser) ParseTransactionStream(tx *streamtypes.TransactionStream) (*common.MessagePublication, error) { +func (p *Parser) ParseTransactionStream(tx *streamtypes.TransactionStream) ([]*common.MessagePublication, error) { // Parse ledger close time timestamp, err := time.Parse(time.RFC3339, tx.CloseTimeISO) if err != nil { @@ -325,11 +328,13 @@ func (p *Parser) ParseTransactionStream(tx *streamtypes.TransactionStream) (*com }) } -// ParseTxResponse converts a TxResponse (from reobservation) into a MessagePublication. +// ParseTxResponse converts a TxResponse (from reobservation) into zero or more +// MessagePublications (see ParseTransactionStream for why there may be more than +// one). // // SECURITY: This function does not verify that the transaction is included in a validated ledger. // Callers MUST check tx.Validated before calling this function. -func (p *Parser) ParseTxResponse(tx *txResponseV2) (*common.MessagePublication, error) { +func (p *Parser) ParseTxResponse(tx *txResponseV2) ([]*common.MessagePublication, error) { timestamp, err := time.Parse(time.RFC3339, tx.CloseTimeISO) if err != nil { return nil, fmt.Errorf("failed to parse close_time_iso: %w", err) @@ -477,31 +482,68 @@ func (p *Parser) parseNttTransaction( // TicketCreates are claimed as XTCF rather than XACK), and the Core/NTT parsers // come last because they require a Destination field that TicketCreate // transactions do not have. -// Returns (nil, nil) if none matched. -func (p *Parser) parseTransaction(tx GenericTx) (*common.MessagePublication, error) { +// +// A registration (XREG-in-XREL) publish is special: it is relayed by the manager +// set as a ticketed Payment FROM the custody (managed) account, so it is ALSO an +// XACK-eligible transaction. It therefore yields BOTH messages — the synthesized +// registration (for the accountant) and the XACK (to clear the ticket the +// registration consumed on the sequencer). We check registration first (it is +// distinguished by its Destination == core account + registration MemoFormat), and +// when it matches we also emit the XACK. A non-registration tx flows through the +// usual single-parser dispatch. +// +// Returns an empty slice if none matched. +func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, error) { msg, err := p.parseTicketCreateTransaction(tx) - if msg != nil || err != nil { - return msg, err + if err != nil { + return nil, err + } + if msg != nil { + return []*common.MessagePublication{msg}, nil } - msg, err = p.parseXACKTransaction(tx) - if msg != nil || err != nil { - return msg, err + + regMsg, err := p.parseRegistrationTransaction(tx) + if err != nil { + return nil, err + } + if regMsg != nil { + msgs := []*common.MessagePublication{regMsg} + ackMsg, err := p.parseXACKTransaction(tx) + if err != nil { + return nil, err + } + if ackMsg != nil { + msgs = append(msgs, ackMsg) + } + return msgs, nil } - // Registration (XREG) publishes go to the core account but use a distinct - // MemoFormat, so they must be checked before the generic core parser. - msg, err = p.parseRegistrationTransaction(tx) - if msg != nil || err != nil { - return msg, err + msg, err = p.parseXACKTransaction(tx) + if err != nil { + return nil, err + } + if msg != nil { + return []*common.MessagePublication{msg}, nil } msg, err = p.parseCoreTransaction(tx) - if msg != nil || err != nil { - return msg, err + if err != nil { + return nil, err + } + if msg != nil { + return []*common.MessagePublication{msg}, nil + } + + msg, err = p.parseNttTransaction(tx) + if err != nil { + return nil, err + } + if msg != nil { + return []*common.MessagePublication{msg}, nil } - return p.parseNttTransaction(tx) + return nil, nil } // parseRegistrationTransaction parses an XREG registration publish (payment to diff --git a/node/pkg/watchers/xrpl/parse_test.go b/node/pkg/watchers/xrpl/parse_test.go index 3299fde4c55..7b7c3be02cf 100644 --- a/node/pkg/watchers/xrpl/parse_test.go +++ b/node/pkg/watchers/xrpl/parse_test.go @@ -17,8 +17,37 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wormhole-foundation/wormhole/sdk/vaa" + + "github.com/certusone/wormhole/node/pkg/common" ) +// firstOrNil collapses a (slice, error) parse result to a single message for the +// many single-message test cases. Returns nil when the slice is empty. Callers +// that expect multiple messages (registration) inspect the slice directly. +func firstOrNil(msgs []*common.MessagePublication, err error) (*common.MessagePublication, error) { + if err != nil { + return nil, err + } + if len(msgs) == 0 { + return nil, nil + } + return msgs[0], nil +} + +// parseStreamOne / parseRespOne preserve the pre-slice single-message signature +// for the single-message test cases. +func (p *Parser) parseStreamOne(tx *streamtypes.TransactionStream) (*common.MessagePublication, error) { + return firstOrNil(p.ParseTransactionStream(tx)) +} + +func (p *Parser) parseRespOne(tx *txResponseV2) (*common.MessagePublication, error) { + return firstOrNil(p.ParseTxResponse(tx)) +} + +func (p *Parser) parseOne(tx GenericTx) (*common.MessagePublication, error) { + return firstOrNil(p.parseTransaction(tx)) +} + // Sample NTT MemoData (hex-encoded, 72 bytes) // Format: prefix(4) + recipientNTTManager(32) + recipientAddress(32) + recipientChain(2) + fromDecimals(1) + toDecimals(1) // Prefix: 994E5454 (NTT) @@ -1357,7 +1386,7 @@ func TestParseTransactionStream_ValidTransaction(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -1408,7 +1437,7 @@ func TestParseTransactionStream_NoNTTMemo(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.NoError(t, err) assert.Nil(t, msg, "Should return nil for transaction without NTT memo") @@ -1507,7 +1536,7 @@ func TestParseTransactionStream_FailsOnNonSuccessResult(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.Error(t, err) assert.Nil(t, msg) assert.Contains(t, err.Error(), "tecUNFUNDED_PAYMENT") @@ -1585,7 +1614,7 @@ func TestParseTransactionStream_XRPPayment_FullFlow(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -1649,7 +1678,7 @@ func TestParseTransactionStream_TrustLinePayment_FullFlow(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -1729,7 +1758,7 @@ func TestParseTxResponse_ValidTransaction(t *testing.T) { CloseTimeISO: "2024-11-05T06:00:00Z", } - msg, err := p.ParseTxResponse(tx) + msg, err := p.parseRespOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -1770,7 +1799,7 @@ func TestParseTxResponse_NoNTTMemo(t *testing.T) { CloseTimeISO: "2024-11-05T06:00:00Z", } - msg, err := p.ParseTxResponse(tx) + msg, err := p.parseRespOne(tx) require.NoError(t, err) assert.Nil(t, msg, "Should return nil for transaction without NTT memo") @@ -2151,7 +2180,7 @@ func TestParseTransactionStream_MPTPayment_FullFlow(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -2539,7 +2568,7 @@ func TestParseTransactionStream_CoreMessage(t *testing.T) { }, } - msg, err := p.ParseTransactionStream(tx) + msg, err := p.parseStreamOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -2707,12 +2736,12 @@ func TestParseTicketCreateTransaction_DispatchedFromParseTransaction(t *testing. p := NewParser("", []string{testManagedAccount}, nil) tx := createTicketCreateTx(testManagedAccount, []float64{200, 201, 202}) - msg, err := p.parseTransaction(tx) + msgs, err := p.parseTransaction(tx) require.NoError(t, err) - require.NotNil(t, msg) + require.Len(t, msgs, 1) // Verify it was dispatched correctly (XTCF prefix) - assert.Equal(t, xtcfPrefix[:], msg.Payload[0:4]) + assert.Equal(t, xtcfPrefix[:], msgs[0].Payload[0:4]) } func TestParseTxResponse_TicketCreateTimestamp(t *testing.T) { @@ -2757,7 +2786,7 @@ func TestParseTxResponse_TicketCreateTimestamp(t *testing.T) { CloseTimeISO: "2024-11-05T06:00:00Z", } - msg, err := p.ParseTxResponse(tx) + msg, err := p.parseRespOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -2806,7 +2835,7 @@ func TestParseTxResponse_EmptyCloseTimeISO(t *testing.T) { CloseTimeISO: "", } - msg, err := p.ParseTxResponse(tx) + msg, err := p.parseRespOne(tx) require.Error(t, err) require.Nil(t, msg) assert.Contains(t, err.Error(), "failed to parse close_time_iso") @@ -3618,7 +3647,7 @@ func TestParseXACKTransaction_DispatchedFromParseTransaction(t *testing.T) { // A Release Payment with TicketSequence should produce XACK through parseTransaction tx := createXACKTx(testManagedAccount, "Payment", 42, "tesSUCCESS") - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -3631,7 +3660,7 @@ func TestParseXACKTransaction_FailedTicketCreateDispatch(t *testing.T) { // A failed TicketCreate with TicketSequence should fall through XTCF and produce XACK tx := createXACKTx(testManagedAccount, "TicketCreate", 50, "tecNO_PERMISSION") - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -3650,7 +3679,7 @@ func TestParseXACKTransaction_SuccessfulTicketCreateStillXTCF(t *testing.T) { // A successful TicketCreate should still be handled by XTCF, not XACK tx := createTicketCreateTx(testManagedAccount, []float64{200, 201, 202}) - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -3833,7 +3862,7 @@ func TestParseXACKTransaction_BurnDispatch(t *testing.T) { // An AccountSet with TicketSequence should produce XACK through parseTransaction tx := createXACKTx(testManagedAccount, "AccountSet", 77, "tesSUCCESS") - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index ef5be0ca35c..fd565f7ca6e 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -1,6 +1,7 @@ package xrpl import ( + "bytes" "encoding/binary" "encoding/hex" "testing" @@ -11,6 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wormhole-foundation/wormhole/sdk/vaa" + + "github.com/certusone/wormhole/node/pkg/common" ) // testRegCoreAccount is the core (GMP) account that registration publishes target. @@ -90,8 +93,16 @@ func buildXregPeerXRP(manager [20]byte, peerChain uint16, peerAddr [32]byte) []b return out } +// regTicketSequence is the ticket the manager-built registration Payment consumes. +const regTicketSequence = uint64(42) + // regTxStream builds a registration publish signed by `sender` (the XRPL // Account field) carrying the given XREG memo, targeting the core account. +// +// It mirrors the real manager-built XrplRelease: a ticketed Payment (it sets +// TicketSequence), so the watcher must recognize it as a registration AND ack the +// ticket. (The earlier version omitted TicketSequence, which masked the bug where +// parseXACKTransaction would consume the tx before registration parsing.) func regTxStream(sender string, memoData []byte) *streamtypes.TransactionStream { return &streamtypes.TransactionStream{ Hash: "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890", @@ -102,6 +113,7 @@ func regTxStream(sender string, memoData []byte) *streamtypes.TransactionStream "TransactionType": "Payment", "Account": sender, "Destination": testRegCoreAccount, + "TicketSequence": float64(regTicketSequence), "meta": map[string]any{ "TransactionResult": "tesSUCCESS", }, @@ -121,6 +133,29 @@ func regTxStream(sender string, memoData []byte) *streamtypes.TransactionStream } } +// findRegistration returns the synthesized registration message (a +// TransceiverInfo or TransceiverRegistration) from a parse result, or nil. +func findRegistration(msgs []*common.MessagePublication) *common.MessagePublication { + for _, m := range msgs { + if len(m.Payload) >= 4 && + (bytes.Equal(m.Payload[0:4], transceiverInfoPrefix[:]) || + bytes.Equal(m.Payload[0:4], transceiverRegPrefix[:])) { + return m + } + } + return nil +} + +// findXACK returns the XACK message from a parse result, or nil. +func findXACK(msgs []*common.MessagePublication) *common.MessagePublication { + for _, m := range msgs { + if len(m.Payload) >= 4 && bytes.Equal(m.Payload[0:4], xackPrefix[:]) { + return m + } + } + return nil +} + // managerToEmitter mirrors the transfer-path manager32 derivation (20-byte // account id left-padded into 32 bytes). func manager32From20(m [20]byte) [32]byte { @@ -133,9 +168,14 @@ func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { manager := testRegManager20() p := newRegParser(t, manager) - msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager))) + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager))) require.NoError(t, err) - require.NotNil(t, msg, "registration publish should produce a message") + + // The registration Payment is a ticketed Payment from a managed account, so it + // yields BOTH the registration and the XACK that clears its ticket. + msg := findRegistration(msgs) + require.NotNil(t, msg, "registration publish should produce a registration message") + assertRegistrationXACK(t, msgs) assert.Equal(t, vaa.ChainIDXRPL, msg.EmitterChain) @@ -155,6 +195,19 @@ func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { assert.Equal(t, uint8(xrpDecimals), msg.Payload[69]) } +// assertRegistrationXACK checks the parse result also contains the XACK that +// clears the ticket the registration Payment consumed: a Release (tx_type=0) +// success XACK echoing regTicketSequence. +func assertRegistrationXACK(t *testing.T, msgs []*common.MessagePublication) { + t.Helper() + xack := findXACK(msgs) + require.NotNil(t, xack, "registration Payment must also emit an XACK to clear its ticket") + require.Equal(t, xackPayloadLen, len(xack.Payload)) + assert.Equal(t, regTicketSequence, binary.BigEndian.Uint64(xack.Payload[4:12]), "XACK ticket") + assert.Equal(t, uint8(1), xack.Payload[12], "XACK success") + assert.Equal(t, uint8(xackTxTypeRelease), xack.Payload[13], "XACK tx_type Release") +} + func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { manager := testRegManager20() p := newRegParser(t, manager) @@ -166,9 +219,11 @@ func TestRegistration_HubIOU_EmitterMatchesTransferPath(t *testing.T) { issuer[i] = byte(0xA0 + i) } - msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubIOU(manager, currency, issuer, 9))) + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubIOU(manager, currency, issuer, 9))) require.NoError(t, err) + msg := findRegistration(msgs) require.NotNil(t, msg) + assertRegistrationXACK(t, msgs) // Build the expected IOU sourceToken the SAME way the transfer path does: // 0x01 || keccak256(currency20 || issuer20)[1:]. @@ -199,8 +254,9 @@ func TestRegistration_HubIOU_BurningWhenManagerIsIssuer(t *testing.T) { // issuer == manager => Burning mode. issuer := manager - msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubIOU(manager, currency, issuer, 9))) + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubIOU(manager, currency, issuer, 9))) require.NoError(t, err) + msg := findRegistration(msgs) require.NotNil(t, msg) require.Equal(t, transceiverInfoLen, len(msg.Payload)) @@ -218,8 +274,9 @@ func TestRegistration_HubMPT_BurningWhenManagerIsIssuer(t *testing.T) { mptID[3] = 0x07 // arbitrary sequence (first 4 bytes) copy(mptID[4:24], manager[:]) // issuer is the last 20 bytes - msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubMPT(manager, mptID, 6))) + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubMPT(manager, mptID, 6))) require.NoError(t, err) + msg := findRegistration(msgs) require.NotNil(t, msg) require.Equal(t, transceiverInfoLen, len(msg.Payload)) @@ -236,9 +293,11 @@ func TestRegistration_PeerXRP(t *testing.T) { } const peerChain = uint16(6) // Avalanche - msg, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregPeerXRP(manager, peerChain, peerAddr))) + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregPeerXRP(manager, peerChain, peerAddr))) require.NoError(t, err) + msg := findRegistration(msgs) require.NotNil(t, msg) + assertRegistrationXACK(t, msgs) // Emitter is keyed on the SAME (manager, XRP token) as the hub/transfers. var zeroToken [32]byte @@ -270,13 +329,13 @@ func TestRegistration_WrongMemoFormat_NotRegistration(t *testing.T) { memo["MemoFormat"] = coreMemoFormat memo["MemoData"] = hex.EncodeToString(coreMemo) - // A valid core publish parses (as a generic core message), but must NOT be a - // transceiver-info payload — proving the registration parser declined it. - msg, err := p.ParseTransactionStream(tx) + // The memo is no longer a registration memo, so no registration message must + // be produced. (The tx is a managed account's ticketed Payment, so it is acked + // as an XACK — that is expected and fine; we only assert registration absence.) + msgs, err := p.ParseTransactionStream(tx) require.NoError(t, err) - require.NotNil(t, msg) - assert.NotEqual(t, transceiverInfoPrefix[:], msg.Payload[0:4], - "non-XREG memo must not synthesize a transceiver-info payload") + assert.Nil(t, findRegistration(msgs), + "non-XREG memo must not synthesize a transceiver registration") } // TestRegistration_UntrustedSender_Declined ensures a registration publish from @@ -294,9 +353,12 @@ func TestRegistration_UntrustedSender_Declined(t *testing.T) { } tx := regTxStream(managerAddress(t, attacker), buildXregHubXRP(manager)) - msg, err := p.ParseTransactionStream(tx) + // The sender is NOT a managed account, so registration is declined AND the + // XACK path also declines (its first gate is managed membership) => no messages. + msgs, err := p.ParseTransactionStream(tx) require.NoError(t, err) - assert.Nil(t, msg, "registration from an untrusted sender must be declined") + assert.Nil(t, findRegistration(msgs), "registration from an untrusted sender must be declined") + assert.Empty(t, msgs, "an untrusted (unmanaged) sender must produce no messages") } // TestRegistration_ManagerMismatch_Declined ensures the XREG manager field must @@ -313,7 +375,38 @@ func TestRegistration_ManagerMismatch_Declined(t *testing.T) { p := newRegParser(t, sender) tx := regTxStream(managerAddress(t, sender), buildXregHubXRP(otherManager)) - msg, err := p.ParseTransactionStream(tx) + // Registration is declined (manager != sender), but the sender IS a managed + // account with a ticket, so the XACK is still emitted to clear that ticket. + msgs, err := p.ParseTransactionStream(tx) require.NoError(t, err) - assert.Nil(t, msg, "registration whose manager != sender must be declined") + assert.Nil(t, findRegistration(msgs), "registration whose manager != sender must be declined") + assert.NotNil(t, findXACK(msgs), "the managed sender's ticket must still be acked") +} + +// TestRegistration_TicketedPayment_YieldsBothRegistrationAndXACK is the +// regression test for the collision the reviewer found: the manager relays the +// registration as a ticketed XrplRelease Payment. Because that Payment is from a +// managed account with a TicketSequence, parseXACKTransaction would otherwise +// consume it before parseRegistrationTransaction runs — dropping the +// registration. The watcher must yield BOTH the registration (for the accountant) +// AND the XACK (to clear the ticket). +func TestRegistration_TicketedPayment_YieldsBothRegistrationAndXACK(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager))) + require.NoError(t, err) + require.Len(t, msgs, 2, "a ticketed registration Payment must yield exactly two messages") + + // Registration present (for the accountant). + reg := findRegistration(msgs) + require.NotNil(t, reg, "registration message must be present") + assert.Equal(t, transceiverInfoPrefix[:], reg.Payload[0:4]) + + // XACK present, clearing the ticket the registration consumed. + assertRegistrationXACK(t, msgs) + + // Ordering: the registration comes first, then the XACK. + assert.Equal(t, transceiverInfoPrefix[:], msgs[0].Payload[0:4], "registration first") + assert.Equal(t, xackPrefix[:], msgs[1].Payload[0:4], "XACK second") } diff --git a/node/pkg/watchers/xrpl/watcher.go b/node/pkg/watchers/xrpl/watcher.go index 9a772ddf254..2620e992494 100644 --- a/node/pkg/watchers/xrpl/watcher.go +++ b/node/pkg/watchers/xrpl/watcher.go @@ -245,7 +245,7 @@ func (w *Watcher) Run(ctx context.Context) error { logger.Info("Received reobservation request", zap.String("txHash", hex.EncodeToString(txHash))) - msg, err := w.fetchAndParseTransaction(txHash) + msgs, err := w.fetchAndParseTransaction(txHash) if err != nil { logger.Error("Failed to fetch transaction for reobservation", zap.String("txHash", hex.EncodeToString(txHash)), @@ -255,7 +255,7 @@ func (w *Watcher) Run(ctx context.Context) error { continue } - if msg != nil { + for _, msg := range msgs { msg.IsReobservation = true w.msgChan <- msg watchers.ReobservationsByChain.WithLabelValues("xrpl", "std").Inc() @@ -339,28 +339,26 @@ func (w *Watcher) processTransaction(tx *streamtypes.TransactionStream) error { return nil } - // Parse the transaction and extract Wormhole message - msg, err := w.parser.ParseTransactionStream(tx) + // Parse the transaction and extract Wormhole messages. A single transaction + // may yield more than one message (e.g. a registration publish yields the + // registration plus its XACK). + msgs, err := w.parser.ParseTransactionStream(tx) if err != nil { return fmt.Errorf("failed to parse transaction: %w", err) } - // msg is nil if transaction doesn't contain a Wormhole message - if msg == nil { - return nil + // Send each message to the processor. + for _, msg := range msgs { + w.msgChan <- msg + xrplMessagesConfirmed.Inc() + w.logger.Info("message observed", msg.ZapFields()...) } - // Send to processor - w.msgChan <- msg - xrplMessagesConfirmed.Inc() - - w.logger.Info("message observed", msg.ZapFields()...) - return nil } // fetchAndParseTransaction fetches a specific transaction by hash for reobservation. -func (w *Watcher) fetchAndParseTransaction(txHash []byte) (*common.MessagePublication, error) { +func (w *Watcher) fetchAndParseTransaction(txHash []byte) ([]*common.MessagePublication, error) { // Fetch transaction by hash txReq := &transactions.TxRequest{ Transaction: hex.EncodeToString(txHash), From 1493e9aef08ae81a824be321fadd9616b7851e9c Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 15 Jul 2026 17:12:43 +0200 Subject: [PATCH 08/17] fix: document parseRegistrationTransaction in the dispatch order --- node/pkg/watchers/xrpl/parse.go | 36 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index b4cc4f43fce..02cf5fd1264 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -471,26 +471,31 @@ func (p *Parser) parseNttTransaction( // produces an XTCF. A failed TicketCreate intentionally falls through here // (parseTicketCreateTransaction returns (nil, nil)) and is handled by // parseXACKTransaction below. -// 2. parseXACKTransaction — ticket-consuming transactions on a managed account +// 2. parseRegistrationTransaction — a ticketed Payment to the core account with +// the registration MemoFormat (an XREG-in-XREL publish, relayed by the manager +// set from the custody/managed account) produces the synthesized NTT +// transceiver registration. Because that Payment is also XACK-eligible (managed +// sender + TicketSequence), it additionally emits an XACK — see the note below. +// 3. parseXACKTransaction — ticket-consuming transactions on a managed account // (Release Payments, failed TicketCreate, Burn/AccountSet) produce an XACK. -// 3. parseCoreTransaction — payments to the core account with a Wormhole core +// 4. parseCoreTransaction — payments to the core account with a Wormhole core // memo produce a generic Wormhole message. -// 4. parseNttTransaction — payments to a managed account with an NTT memo +// 5. parseNttTransaction — payments to a managed account with an NTT memo // produce an NTT transfer message. // -// The order is load-bearing: TicketCreate must run before XACK (so successful -// TicketCreates are claimed as XTCF rather than XACK), and the Core/NTT parsers -// come last because they require a Destination field that TicketCreate -// transactions do not have. +// The order is load-bearing: +// - TicketCreate must run before XACK (so successful TicketCreates are claimed as +// XTCF rather than XACK). +// - Registration must run before XACK: a registration Payment is a ticketed +// Payment from a managed account, so the XACK parser would otherwise consume it +// and the registration would be lost. +// - Core/NTT come last because they require a Destination field that TicketCreate +// transactions do not have. // -// A registration (XREG-in-XREL) publish is special: it is relayed by the manager -// set as a ticketed Payment FROM the custody (managed) account, so it is ALSO an -// XACK-eligible transaction. It therefore yields BOTH messages — the synthesized -// registration (for the accountant) and the XACK (to clear the ticket the -// registration consumed on the sequencer). We check registration first (it is -// distinguished by its Destination == core account + registration MemoFormat), and -// when it matches we also emit the XACK. A non-registration tx flows through the -// usual single-parser dispatch. +// A registration publish is the one case that yields TWO messages: the synthesized +// registration (for the accountant) AND the XACK that clears the ticket the +// registration consumed on the sequencer. Every other transaction yields at most +// one message. // // Returns an empty slice if none matched. func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, error) { @@ -502,7 +507,6 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e return []*common.MessagePublication{msg}, nil } - regMsg, err := p.parseRegistrationTransaction(tx) if err != nil { return nil, err From 1907de9c1045fc8b83b2757e9c52c369b2caaaa9 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 15 Jul 2026 17:47:44 +0200 Subject: [PATCH 09/17] fix: ack the ticket on a failed registration Payment --- node/pkg/watchers/xrpl/parse.go | 9 +++++-- node/pkg/watchers/xrpl/registration_test.go | 28 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 02cf5fd1264..96edf90fe32 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -589,11 +589,16 @@ func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePubl return nil, nil } + // Decline (nil, nil) — rather than error — if the transaction did not succeed + // or is not a Payment. A FAILED registration Payment still consumed its ticket + // on XRPL, so it must fall through to parseXACKTransaction, which emits a + // failure XACK that clears the ticket on the sequencer. Returning + // an error here would abort the whole dispatch and leave the ticket stuck. if err = validateTransactionResult(tx); err != nil { - return nil, err + return nil, nil //nolint:nilerr // Intentional: failed registration Payment falls through to the XACK handler. } if err = validateTransactionType(tx.Transaction); err != nil { - return nil, err + return nil, nil //nolint:nilerr // Intentional: non-Payment falls through to the other parsers. } txHash, sequence, err := p.extractTxHashAndSequence(tx) diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index fd565f7ca6e..a7e927f891f 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -410,3 +410,31 @@ func TestRegistration_TicketedPayment_YieldsBothRegistrationAndXACK(t *testing.T assert.Equal(t, transceiverInfoPrefix[:], msgs[0].Payload[0:4], "registration first") assert.Equal(t, xackPrefix[:], msgs[1].Payload[0:4], "XACK second") } + +// TestRegistration_FailedPayment_YieldsFailureXACKNoRegistration covers the +// failure case: a FAILED registration Payment still consumed its ticket on XRPL, +// so it must NOT synthesize a registration but MUST fall through to a failure +// XACK (success=0) that clears the stuck ticket on the sequencer. Regression for +// the reviewer's "what if the XREG fails? / ticket stuck until BURN" concern. +func TestRegistration_FailedPayment_YieldsFailureXACKNoRegistration(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + + tx := regTxStream(managerAddress(t, manager), buildXregHubXRP(manager)) + // Mark the transaction as failed (ticket is still consumed on XRPL). + tx.Meta.TransactionResult = "tecUNFUNDED_PAYMENT" + meta, ok := tx.Transaction["meta"].(map[string]any) + require.True(t, ok) + meta["TransactionResult"] = "tecUNFUNDED_PAYMENT" + + msgs, err := p.ParseTransactionStream(tx) + require.NoError(t, err, "a failed registration Payment must not abort the dispatch") + + // No registration (it failed), but a failure XACK to clear the ticket. + assert.Nil(t, findRegistration(msgs), "a failed registration must not synthesize a registration") + xack := findXACK(msgs) + require.NotNil(t, xack, "a failed registration Payment must still emit an XACK to clear its ticket") + assert.Equal(t, regTicketSequence, binary.BigEndian.Uint64(xack.Payload[4:12]), "XACK ticket") + assert.Equal(t, uint8(0), xack.Payload[12], "XACK success=0 (failed)") + assert.Equal(t, uint8(xackTxTypeRelease), xack.Payload[13], "XACK tx_type Release") +} From 372528d90c6287d394b312291c810003e4b79f26 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 15 Jul 2026 17:59:53 +0200 Subject: [PATCH 10/17] refactor: move XREG wire constants to types.go --- node/pkg/watchers/xrpl/parse.go | 48 ++------------------------------- node/pkg/watchers/xrpl/types.go | 44 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 46 deletions(-) create mode 100644 node/pkg/watchers/xrpl/types.go diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 96edf90fe32..31612ce43be 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -82,52 +82,8 @@ const ( // tesSUCCESS is the XRPL transaction result code for successful transactions const tesSUCCESS = "tesSUCCESS" -// regMemoFormat is the hex-encoded MemoFormat for XREG registration publishes: "application/x-ntt-registration" -// On a successful initialize / register_peer, the sequencer posts an XREG payload that the executor relays back -// to XRPL as a core-publish whose MemoData is the raw XREG bytes and MemoFormat is regMemoFormat. The watcher -// synthesizes the canonical NTT transceiver registration message the ntt-global-accountant expects -// (WormholeTransceiverInfo for a hub, WormholeTransceiverRegistration for a peer), emitted under the NTT -// transceiver emitter keccak256("ntt" + manager + sourceToken) — the same emitter XRPL NTT transfers use, so -// the accountant keys hub/peer state consistently. -const regMemoFormat = "6170706C69636174696F6E2F782D6E74742D726567697374726174696F6E" - -// xregPrefix is the 4-byte prefix for XRPL registration payloads -var xregPrefix = [4]byte{'X', 'R', 'E', 'G'} - -// Prefixes for canonical NTT transceiver messages (from the NTT spec / ntt-messages) -var transceiverInfoPrefix = [4]byte{0x9c, 0x23, 0xbd, 0x3b} // WormholeTransceiverInfo -var transceiverRegPrefix = [4]byte{0x18, 0xfc, 0x67, 0xc2} // WormholeTransceiverRegistration - -// XREG constants -const ( - xregKindHub = 0x00 // hub sub-type (byte after prefix), mirrors sequencer XREG_KIND_HUB - xregKindPeer = 0x01 // peer sub-type (byte after prefix), mirrors sequencer XREG_KIND_PEER - xregTokenXRP = 0x00 // wire token_id opening byte for XRP, mirrors XrplTokenId - xregTokenIOU = 0x01 // wire token_id opening byte for issued currencies - xregTokenMPT = 0x02 // wire token_id opening byte for MPT - - // NTT manager modes (ntt-messages Mode enum). An XRPL custody account is in - // Burning mode when it is itself the token issuer (it can mint/burn), and - // Locking mode otherwise (it holds a token issued by another account). XRP - // has no issuer, so it is always Locking. - nttModeLocking = 0x00 - nttModeBurning = 0x01 - - xregManagerOffset = 5 // offset of the manager field: after prefix(4) + kind(1) - xregHeaderLen = 25 // prefix(4) + kind(1) + manager(20); token_id + tail follow - xregPeerTailLen = 34 // peer tail: peer_chain(2) + peer_address(32) - - xregTokenXRPWireLen = 1 // XRP wire token_id: type(1) - xregTokenIOUWireLen = 41 // IOU wire token_id: type(1) + currency(20) + issuer(20) - xregTokenMPTWireLen = 25 // MPT wire token_id: type(1) + mpt_issuance_id(24) - - // Issuer offsets within an XREG wire token_id (used to derive manager mode). - xregIouIssuerOffset = 21 // IOU issuer starts after type(1) + currency(20) - xregMptIssuerOffset = 5 // MPT issuer = last 20 bytes of the 24-byte id: after type(1) + sequence(4) (MPTokenIssuanceID = Sequence(4) || Issuer(20)) - - transceiverInfoLen = 70 // WormholeTransceiverInfo: prefix(4) + manager(32) + mode(1) + token(32) + decimals(1) - transceiverRegLen = 38 // WormholeTransceiverRegistration: prefix(4) + chain_id(2) + transceiver(32) -) +// The XRPL registration (XREG) wire/domain vocabulary — prefixes, discriminants, +// offsets, and lengths — lives in types.go. // regSourceTokenFromWire converts an XREG wire token_id (XrplTokenId form) into // the 32-byte source_token used by calculateEmitterAddress. It MUST produce the diff --git a/node/pkg/watchers/xrpl/types.go b/node/pkg/watchers/xrpl/types.go new file mode 100644 index 00000000000..f19cb457df7 --- /dev/null +++ b/node/pkg/watchers/xrpl/types.go @@ -0,0 +1,44 @@ +package xrpl + +// XRPL registration (XREG) wire/domain vocabulary used by the parser. + +// regMemoFormat is the hex-encoded MemoFormat for XREG registration publishes: "application/x-ntt-registration" +const regMemoFormat = "6170706C69636174696F6E2F782D6E74742D726567697374726174696F6E" + +// xregPrefix is the 4-byte prefix for XRPL registration payloads +var xregPrefix = [4]byte{'X', 'R', 'E', 'G'} + +// Prefixes for canonical NTT transceiver messages (from the NTT spec / ntt-messages) +var transceiverInfoPrefix = [4]byte{0x9c, 0x23, 0xbd, 0x3b} // WormholeTransceiverInfo +var transceiverRegPrefix = [4]byte{0x18, 0xfc, 0x67, 0xc2} // WormholeTransceiverRegistration + +// XREG constants +const ( + xregKindHub = 0x00 // hub sub-type (byte after prefix), mirrors sequencer XREG_KIND_HUB + xregKindPeer = 0x01 // peer sub-type (byte after prefix), mirrors sequencer XREG_KIND_PEER + xregTokenXRP = 0x00 // wire token_id opening byte for XRP, mirrors XrplTokenId + xregTokenIOU = 0x01 // wire token_id opening byte for issued currencies + xregTokenMPT = 0x02 // wire token_id opening byte for MPT + + // NTT manager modes (ntt-messages Mode enum). An XRPL custody account is in + // Burning mode when it is itself the token issuer (it can mint/burn), and + // Locking mode otherwise (it holds a token issued by another account). XRP + // has no issuer, so it is always Locking. + nttModeLocking = 0x00 + nttModeBurning = 0x01 + + xregManagerOffset = 5 // offset of the manager field: after prefix(4) + kind(1) + xregHeaderLen = 25 // prefix(4) + kind(1) + manager(20); token_id + tail follow + xregPeerTailLen = 34 // peer tail: peer_chain(2) + peer_address(32) + + xregTokenXRPWireLen = 1 // XRP wire token_id: type(1) + xregTokenIOUWireLen = 41 // IOU wire token_id: type(1) + currency(20) + issuer(20) + xregTokenMPTWireLen = 25 // MPT wire token_id: type(1) + mpt_issuance_id(24) + + // Issuer offsets within an XREG wire token_id (used to derive manager mode). + xregIouIssuerOffset = 21 // IOU issuer starts after type(1) + currency(20) + xregMptIssuerOffset = 5 // MPT issuer = last 20 bytes of the 24-byte id: after type(1) + sequence(4) (MPTokenIssuanceID = Sequence(4) || Issuer(20)) + + transceiverInfoLen = 70 // WormholeTransceiverInfo: prefix(4) + manager(32) + mode(1) + token(32) + decimals(1) + transceiverRegLen = 38 // WormholeTransceiverRegistration: prefix(4) + chain_id(2) + transceiver(32) +) From a93340087aca13fb0614b05903f4c7d837260210 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 15 Jul 2026 18:04:45 +0200 Subject: [PATCH 11/17] chore: add vaas/XREG/XREL to cspell-custom-words --- cspell-custom-words.txt | 3 +++ node/pkg/watchers/xrpl/README.md | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cspell-custom-words.txt b/cspell-custom-words.txt index 7cb57281011..00b440335b3 100644 --- a/cspell-custom-words.txt +++ b/cspell-custom-words.txt @@ -254,6 +254,7 @@ utest UTXO uusd uvarint +vaas varint varints vimdiff @@ -275,5 +276,7 @@ XFER xlayer xpla XPLA +XREG +XREL XRPL Zellic diff --git a/node/pkg/watchers/xrpl/README.md b/node/pkg/watchers/xrpl/README.md index f25c92096a0..b96aec9198d 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -100,8 +100,6 @@ uint32 nonce []byte payload ``` - - ### Registration (Initialize Transceiver & Peer Registration) Because XRPL has no smart contracts, the [`WormholeTransceiverInfo`](https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md#initialize-transceiver) (hub init) and From 8ffdab7a8ac26e4a6ced2a59d0154266064ea928 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Wed, 15 Jul 2026 18:09:17 +0200 Subject: [PATCH 12/17] fix: avoid err shadow in parseTransaction registration branch --- node/pkg/watchers/xrpl/parse.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 31612ce43be..64fdaafe89d 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -469,7 +469,8 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e } if regMsg != nil { msgs := []*common.MessagePublication{regMsg} - ackMsg, err := p.parseXACKTransaction(tx) + var ackMsg *common.MessagePublication + ackMsg, err = p.parseXACKTransaction(tx) if err != nil { return nil, err } From dcc2ec9f14a6a912670a75b53998a05981616af0 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 16 Jul 2026 22:39:57 +0200 Subject: [PATCH 13/17] fix: decline (not error) on missing Destination in registration parse --- node/pkg/watchers/xrpl/parse.go | 28 ++++++++------------- node/pkg/watchers/xrpl/parse_test.go | 10 +++++--- node/pkg/watchers/xrpl/registration_test.go | 17 +++++++++++++ 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 64fdaafe89d..70663e9a2ed 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -518,28 +518,19 @@ func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePubl } destination, err := p.extractDestination(tx.Transaction) - if err != nil { - return nil, err - } - if destination != p.coreAccount { - return nil, nil + if err != nil || destination != p.coreAccount { + return nil, nil //nolint:nilerr // Intentional: not a registration; fall through to the other parsers. } // Read the registration memo (memo[0], regMemoFormat). nil => not an XREG. regData, err := p.parseRegistrationMemoData(tx.Transaction) - if err != nil { - return nil, err - } - if regData == nil { - return nil, nil + if err != nil || regData == nil { + return nil, nil //nolint:nilerr // Intentional: not a (valid) registration; fall through to the XACK handler. } senderID, err := p.registrationSenderAccountID(tx.Transaction) - if err != nil { - return nil, err - } - if senderID == nil { - return nil, nil + if err != nil || senderID == nil { + return nil, nil //nolint:nilerr // Intentional: fall through to the XACK handler. } // manager occupies bytes [xregManagerOffset:xregHeaderLen] of the XREG payload. if len(regData) < xregHeaderLen || !bytes.Equal(senderID, regData[xregManagerOffset:xregHeaderLen]) { @@ -558,15 +549,18 @@ func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePubl return nil, nil //nolint:nilerr // Intentional: non-Payment falls through to the other parsers. } + // From here the tx is a confirmed registration that consumed a ticket. If the + // tx metadata or the payload can't be built, still decline (nil, nil) so the + // XACK handler runs and clears the ticket — never abort the dispatch. txHash, sequence, err := p.extractTxHashAndSequence(tx) if err != nil { - return nil, err + return nil, nil //nolint:nilerr // Intentional: fall through to the XACK handler so the ticket is acked. } // Synthesize the canonical transceiver payload + re-keyed emitter. payload, emitter, err := buildRegistrationMessage(regData) if err != nil { - return nil, fmt.Errorf("failed to build registration message: %w", err) + return nil, nil //nolint:nilerr // Intentional: fall through to the XACK handler so the ticket is acked. } return &common.MessagePublication{ diff --git a/node/pkg/watchers/xrpl/parse_test.go b/node/pkg/watchers/xrpl/parse_test.go index 7b7c3be02cf..bcc13ea235b 100644 --- a/node/pkg/watchers/xrpl/parse_test.go +++ b/node/pkg/watchers/xrpl/parse_test.go @@ -3643,7 +3643,7 @@ func TestParseXACKTransaction_TransactionIndexOverflow(t *testing.T) { } func TestParseXACKTransaction_DispatchedFromParseTransaction(t *testing.T) { - p := NewParser("", []string{testManagedAccount}, nil) + p := NewParser(testCoreAccount, []string{testManagedAccount}, nil) // A Release Payment with TicketSequence should produce XACK through parseTransaction tx := createXACKTx(testManagedAccount, "Payment", 42, "tesSUCCESS") @@ -3656,7 +3656,7 @@ func TestParseXACKTransaction_DispatchedFromParseTransaction(t *testing.T) { } func TestParseXACKTransaction_FailedTicketCreateDispatch(t *testing.T) { - p := NewParser("", []string{testManagedAccount}, nil) + p := NewParser(testCoreAccount, []string{testManagedAccount}, nil) // A failed TicketCreate with TicketSequence should fall through XTCF and produce XACK tx := createXACKTx(testManagedAccount, "TicketCreate", 50, "tecNO_PERMISSION") @@ -3675,7 +3675,9 @@ func TestParseXACKTransaction_FailedTicketCreateDispatch(t *testing.T) { } func TestParseXACKTransaction_SuccessfulTicketCreateStillXTCF(t *testing.T) { - p := NewParser("", []string{testManagedAccount}, nil) + // coreAccount set (realistic): a successful TicketCreate (no Destination) must + // still be claimed as XTCF, falling through the registration parser. + p := NewParser(testCoreAccount, []string{testManagedAccount}, nil) // A successful TicketCreate should still be handled by XTCF, not XACK tx := createTicketCreateTx(testManagedAccount, []float64{200, 201, 202}) @@ -3858,7 +3860,7 @@ func TestParseXACKTransaction_NonNoOpAccountSetSkipped(t *testing.T) { } func TestParseXACKTransaction_BurnDispatch(t *testing.T) { - p := NewParser("", []string{testManagedAccount}, nil) + p := NewParser(testCoreAccount, []string{testManagedAccount}, nil) // An AccountSet with TicketSequence should produce XACK through parseTransaction tx := createXACKTx(testManagedAccount, "AccountSet", 77, "tesSUCCESS") diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index a7e927f891f..2189bbb5a3a 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -438,3 +438,20 @@ func TestRegistration_FailedPayment_YieldsFailureXACKNoRegistration(t *testing.T assert.Equal(t, uint8(0), xack.Payload[12], "XACK success=0 (failed)") assert.Equal(t, uint8(xackTxTypeRelease), xack.Payload[13], "XACK tx_type Release") } + +func TestRegistration_MalformedMemo_StillYieldsXACK(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + badXreg := append([]byte{}, xregPrefix[:]...) + badXreg = append(badXreg, xregKindHub) + badXreg = append(badXreg, manager[:]...) + badXreg = append(badXreg, xregTokenXRP) // token_id, but no trailing decimals byte + tx := regTxStream(managerAddress(t, manager), badXreg) + msgs, err := p.ParseTransactionStream(tx) + require.NoError(t, err, "a malformed registration memo must not abort the dispatch") + assert.Nil(t, findRegistration(msgs), "malformed XREG must not synthesize a registration") + xack := findXACK(msgs) + require.NotNil(t, xack, "a ticketed managed Payment must still emit an XACK even if the XREG is malformed") + assert.Equal(t, regTicketSequence, binary.BigEndian.Uint64(xack.Payload[4:12]), "XACK ticket") + assert.Equal(t, uint8(xackTxTypeRelease), xack.Payload[13], "XACK tx_type Release") +} From 796725c33c9e48f9f8c5af9b4e376557d94d65fc Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Thu, 16 Jul 2026 22:57:12 +0200 Subject: [PATCH 14/17] fix: address review for parser logger, naming, comment cleanups --- node/pkg/watchers/xrpl/parse.go | 27 ++++++++++++++++++++++++--- node/pkg/watchers/xrpl/watcher.go | 11 ++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 70663e9a2ed..cfaea5760ff 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -22,6 +22,7 @@ import ( "github.com/certusone/wormhole/node/pkg/watchers/xrpl/currencycodec" ethcrypto "github.com/ethereum/go-ethereum/crypto" "github.com/wormhole-foundation/wormhole/sdk/vaa" + "go.uber.org/zap" ) // nttMemoFormat is the hex-encoded MemoFormat for NTT transfers: "application/x-ntt-transfer" @@ -224,6 +225,12 @@ type Parser struct { coreAccount string // Core Wormhole manager account — payments to this account are not NTT managedAccounts map[string]struct{} // Managed accounts (NTT accounts) — TicketCreate on these emits XTCF fetchMPTAssetScale MPTAssetScaleFetcher + logger *zap.Logger // optional; set by the watcher via withLogger. nil in most unit tests. +} + +func (p *Parser) withLogger(logger *zap.Logger) *Parser { + p.logger = logger + return p } // The stream and request return different transaction structs @@ -453,13 +460,14 @@ func (p *Parser) parseNttTransaction( // registration consumed on the sequencer. Every other transaction yields at most // one message. // -// Returns an empty slice if none matched. +// Returns nil if none matched. func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, error) { msg, err := p.parseTicketCreateTransaction(tx) if err != nil { return nil, err } if msg != nil { + p.logDetected(tx, "ticket_create_xtcf") return []*common.MessagePublication{msg}, nil } @@ -477,6 +485,7 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e if ackMsg != nil { msgs = append(msgs, ackMsg) } + p.logDetected(tx, "registration+xack") return msgs, nil } @@ -485,6 +494,7 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e return nil, err } if msg != nil { + p.logDetected(tx, "xack") return []*common.MessagePublication{msg}, nil } @@ -493,6 +503,7 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e return nil, err } if msg != nil { + p.logDetected(tx, "core") return []*common.MessagePublication{msg}, nil } @@ -501,12 +512,23 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e return nil, err } if msg != nil { + p.logDetected(tx, "ntt") return []*common.MessagePublication{msg}, nil } return nil, nil } +func (p *Parser) logDetected(tx GenericTx, kind string) { + if p.logger == nil { + return + } + p.logger.Debug("xrpl parse: detected message type", + zap.String("txHash", tx.Hash), + zap.String("kind", kind), + ) +} + // parseRegistrationTransaction parses an XREG registration publish (payment to // the core account with regMemoFormat) and synthesizes the canonical NTT // transceiver registration message under the NTT transceiver emitter. @@ -1276,7 +1298,7 @@ func (p *Parser) addressToEmitter(address string) (vaa.Address, error) { } // Right-align the 20-byte account id in a 32-byte vaa.Address (left-padded - // with 12 zero bytes). Uses the SDK helper rather than hand-rolled copying. + // with 12 zero bytes). emitter, err := vaa.BytesToAddress(accountID) if err != nil { return vaa.Address{}, fmt.Errorf("failed to pad account id: %w", err) @@ -1634,7 +1656,6 @@ func (p *Parser) scaleAmount(amount uint64, fromDecimals, toDecimals uint8) (uin // calculateEmitterAddress calculates the emitter address from source NTT manager and source token. // emitter = keccak256("ntt" + source_ntt_manager_address + source_token) -// It is a pure function of its inputs (no Parser state), so it is a free function. func calculateEmitterAddress(sourceNTTManager, sourceToken [32]byte) vaa.Address { const addrLen = len(sourceNTTManager) data := make([]byte, nttEmitterDomainLen+2*addrLen) diff --git a/node/pkg/watchers/xrpl/watcher.go b/node/pkg/watchers/xrpl/watcher.go index 2620e992494..c082c137a35 100644 --- a/node/pkg/watchers/xrpl/watcher.go +++ b/node/pkg/watchers/xrpl/watcher.go @@ -141,8 +141,8 @@ func (w *Watcher) Run(ctx context.Context) error { logger.Info("Connected to XRPL node", zap.String("rpc", w.rpc)) - // Initialize the parser with the watcher's fetchMPTAssetScale method - w.parser = NewParser(w.contract, w.nttAccounts, w.fetchMPTAssetScale) + // Initialize the parser with the watcher's fetchMPTAssetScale method and logger. + w.parser = NewParser(w.contract, w.nttAccounts, w.fetchMPTAssetScale).withLogger(w.logger) // Create the transaction channel once - handlers will write to this channel w.txChan = make(chan *streamtypes.TransactionStream, txChanBufferSize) @@ -245,7 +245,7 @@ func (w *Watcher) Run(ctx context.Context) error { logger.Info("Received reobservation request", zap.String("txHash", hex.EncodeToString(txHash))) - msgs, err := w.fetchAndParseTransaction(txHash) + msgs, err := w.fetchAndParseTransactionMessages(txHash) if err != nil { logger.Error("Failed to fetch transaction for reobservation", zap.String("txHash", hex.EncodeToString(txHash)), @@ -357,8 +357,9 @@ func (w *Watcher) processTransaction(tx *streamtypes.TransactionStream) error { return nil } -// fetchAndParseTransaction fetches a specific transaction by hash for reobservation. -func (w *Watcher) fetchAndParseTransaction(txHash []byte) ([]*common.MessagePublication, error) { +// fetchAndParseTransactionMessages fetches a specific transaction by hash for +// reobservation and returns all Wormhole messages it produces. +func (w *Watcher) fetchAndParseTransactionMessages(txHash []byte) ([]*common.MessagePublication, error) { // Fetch transaction by hash txReq := &transactions.TxRequest{ Transaction: hex.EncodeToString(txHash), From 98de883b47eb1abd7499da204878c5e935133c41 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Fri, 17 Jul 2026 16:22:49 +0200 Subject: [PATCH 15/17] fix: drop always-nil error return from parseRegistrationTransaction --- node/pkg/watchers/xrpl/parse.go | 47 +++++++++++++++------------------ 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index cfaea5760ff..a30744b7bb8 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -471,14 +471,10 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e return []*common.MessagePublication{msg}, nil } - regMsg, err := p.parseRegistrationTransaction(tx) - if err != nil { - return nil, err - } + regMsg := p.parseRegistrationTransaction(tx) if regMsg != nil { msgs := []*common.MessagePublication{regMsg} - var ackMsg *common.MessagePublication - ackMsg, err = p.parseXACKTransaction(tx) + ackMsg, err := p.parseXACKTransaction(tx) if err != nil { return nil, err } @@ -533,56 +529,57 @@ func (p *Parser) logDetected(tx GenericTx, kind string) { // the core account with regMemoFormat) and synthesizes the canonical NTT // transceiver registration message under the NTT transceiver emitter. // -// Returns (nil, nil) if this is not a registration publish. -func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePublication, error) { +// Returns nil if this is not a registration publish. Every non-match is a +// decline (nil) rather than an error: a failed/malformed registration Payment +// still consumed its ticket on XRPL and must fall through to the XACK handler, +// so aborting the dispatch with an error would leave the ticket stuck. +func (p *Parser) parseRegistrationTransaction(tx GenericTx) *common.MessagePublication { if p.coreAccount == "" { - return nil, nil + return nil } destination, err := p.extractDestination(tx.Transaction) if err != nil || destination != p.coreAccount { - return nil, nil //nolint:nilerr // Intentional: not a registration; fall through to the other parsers. + return nil // not a registration; fall through to the other parsers. } // Read the registration memo (memo[0], regMemoFormat). nil => not an XREG. regData, err := p.parseRegistrationMemoData(tx.Transaction) if err != nil || regData == nil { - return nil, nil //nolint:nilerr // Intentional: not a (valid) registration; fall through to the XACK handler. + return nil // not a (valid) registration; fall through to the XACK handler. } senderID, err := p.registrationSenderAccountID(tx.Transaction) if err != nil || senderID == nil { - return nil, nil //nolint:nilerr // Intentional: fall through to the XACK handler. + return nil // fall through to the XACK handler. } // manager occupies bytes [xregManagerOffset:xregHeaderLen] of the XREG payload. if len(regData) < xregHeaderLen || !bytes.Equal(senderID, regData[xregManagerOffset:xregHeaderLen]) { - return nil, nil + return nil } - // Decline (nil, nil) — rather than error — if the transaction did not succeed - // or is not a Payment. A FAILED registration Payment still consumed its ticket - // on XRPL, so it must fall through to parseXACKTransaction, which emits a - // failure XACK that clears the ticket on the sequencer. Returning - // an error here would abort the whole dispatch and leave the ticket stuck. + // A FAILED registration Payment or a non-Payment still consumed its ticket, so + // fall through to parseXACKTransaction (which emits the failure XACK) instead of + // aborting the dispatch. if err = validateTransactionResult(tx); err != nil { - return nil, nil //nolint:nilerr // Intentional: failed registration Payment falls through to the XACK handler. + return nil // failed registration Payment falls through to the XACK handler. } if err = validateTransactionType(tx.Transaction); err != nil { - return nil, nil //nolint:nilerr // Intentional: non-Payment falls through to the other parsers. + return nil // non-Payment falls through to the other parsers. } // From here the tx is a confirmed registration that consumed a ticket. If the - // tx metadata or the payload can't be built, still decline (nil, nil) so the - // XACK handler runs and clears the ticket — never abort the dispatch. + // tx metadata or the payload can't be built, still decline (nil) so the XACK + // handler runs and clears the ticket — never abort the dispatch. txHash, sequence, err := p.extractTxHashAndSequence(tx) if err != nil { - return nil, nil //nolint:nilerr // Intentional: fall through to the XACK handler so the ticket is acked. + return nil // fall through to the XACK handler so the ticket is acked. } // Synthesize the canonical transceiver payload + re-keyed emitter. payload, emitter, err := buildRegistrationMessage(regData) if err != nil { - return nil, nil //nolint:nilerr // Intentional: fall through to the XACK handler so the ticket is acked. + return nil // fall through to the XACK handler so the ticket is acked. } return &common.MessagePublication{ @@ -596,7 +593,7 @@ func (p *Parser) parseRegistrationTransaction(tx GenericTx) (*common.MessagePubl ConsistencyLevel: 0, IsReobservation: false, Unreliable: false, - }, nil + } } // parseRegistrationMemoData extracts the raw XREG bytes from memo[0] when its From 3c3a9bc044bdf424571d56fb4e3154a41d9f9046 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Fri, 17 Jul 2026 16:35:58 +0200 Subject: [PATCH 16/17] fix: ci linter --- node/pkg/watchers/xrpl/parse.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index a30744b7bb8..842227aecbe 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -474,7 +474,8 @@ func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, e regMsg := p.parseRegistrationTransaction(tx) if regMsg != nil { msgs := []*common.MessagePublication{regMsg} - ackMsg, err := p.parseXACKTransaction(tx) + var ackMsg *common.MessagePublication + ackMsg, err = p.parseXACKTransaction(tx) if err != nil { return nil, err } From 21806963b34cd20103122d7ba4ec3d365980c6e3 Mon Sep 17 00:00:00 2001 From: Douglas Galico Date: Fri, 17 Jul 2026 18:47:05 +0200 Subject: [PATCH 17/17] fix: strict XREG length validation, reject trailing bytes --- node/pkg/watchers/xrpl/parse.go | 8 +++--- node/pkg/watchers/xrpl/registration_test.go | 28 +++++++++++++++++++++ node/pkg/watchers/xrpl/types.go | 1 + 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/node/pkg/watchers/xrpl/parse.go b/node/pkg/watchers/xrpl/parse.go index 842227aecbe..de3f57f2a64 100644 --- a/node/pkg/watchers/xrpl/parse.go +++ b/node/pkg/watchers/xrpl/parse.go @@ -694,15 +694,15 @@ func buildRegistrationMessage(data []byte) ([]byte, vaa.Address, error) { switch kind { case xregKindHub: - if len(tail) < 1 { - return nil, emitter, fmt.Errorf("xreg hub missing decimals") + if len(tail) != xregHubTailLen { + return nil, emitter, fmt.Errorf("xreg hub tail length %d, want %d", len(tail), xregHubTailLen) } decimals := tail[0] mode := nttManagerModeFromWire(manager20, tokenWire) return buildTransceiverInfoPayload(manager32, sourceToken, mode, decimals), emitter, nil case xregKindPeer: - if len(tail) < xregPeerTailLen { - return nil, emitter, fmt.Errorf("xreg peer tail too short: %d", len(tail)) + if len(tail) != xregPeerTailLen { + return nil, emitter, fmt.Errorf("xreg peer tail length %d, want %d", len(tail), xregPeerTailLen) } peerChain := binary.BigEndian.Uint16(tail[0:2]) var peerAddr [32]byte diff --git a/node/pkg/watchers/xrpl/registration_test.go b/node/pkg/watchers/xrpl/registration_test.go index 2189bbb5a3a..7765ab71a5c 100644 --- a/node/pkg/watchers/xrpl/registration_test.go +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -455,3 +455,31 @@ func TestRegistration_MalformedMemo_StillYieldsXACK(t *testing.T) { assert.Equal(t, regTicketSequence, binary.BigEndian.Uint64(xack.Payload[4:12]), "XACK ticket") assert.Equal(t, uint8(xackTxTypeRelease), xack.Payload[13], "XACK tx_type Release") } + +func TestRegistration_TrailingBytes_Declined(t *testing.T) { + manager := testRegManager20() + var peerAddr [32]byte + copy(peerAddr[:], manager[:]) + cases := []struct { + name string + xreg []byte + }{ + {"hub", buildXregHubXRP(manager)}, + {"peer", buildXregPeerXRP(manager, 2, peerAddr)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := newRegParser(t, manager) + // An otherwise-valid payload plus one trailing byte must be declined. + xreg := append(tc.xreg, 0xFF) + tx := regTxStream(managerAddress(t, manager), xreg) + msgs, err := p.ParseTransactionStream(tx) + require.NoError(t, err, "trailing bytes must not abort the dispatch") + assert.Nil(t, findRegistration(msgs), "XREG with trailing bytes must not synthesize a registration") + xack := findXACK(msgs) + require.NotNil(t, xack, "a ticketed managed Payment must still emit an XACK") + assert.Equal(t, regTicketSequence, binary.BigEndian.Uint64(xack.Payload[4:12]), "XACK ticket") + assert.Equal(t, uint8(xackTxTypeRelease), xack.Payload[13], "XACK tx_type Release") + }) + } +} diff --git a/node/pkg/watchers/xrpl/types.go b/node/pkg/watchers/xrpl/types.go index f19cb457df7..c37eafe171a 100644 --- a/node/pkg/watchers/xrpl/types.go +++ b/node/pkg/watchers/xrpl/types.go @@ -29,6 +29,7 @@ const ( xregManagerOffset = 5 // offset of the manager field: after prefix(4) + kind(1) xregHeaderLen = 25 // prefix(4) + kind(1) + manager(20); token_id + tail follow + xregHubTailLen = 1 // hub tail: token_decimals(1) xregPeerTailLen = 34 // peer tail: peer_chain(2) + peer_address(32) xregTokenXRPWireLen = 1 // XRP wire token_id: type(1)