diff --git a/cspell-custom-words.txt b/cspell-custom-words.txt index 7cb5728101..00b440335b 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/accountant/ntt_config.go b/node/pkg/accountant/ntt_config.go index 998e845e3a..a125ebb4d2 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 acff30a76a..85e6da6dd0 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 6ceefd9dd2..b96aec9198 100644 --- a/node/pkg/watchers/xrpl/README.md +++ b/node/pkg/watchers/xrpl/README.md @@ -100,13 +100,88 @@ uint32 nonce []byte payload ``` -### Initialize Transceiver +### 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 +[`WormholeTransceiverRegistration`](https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md#transceiver-peer-registration) (peer) messages cannot be emitted natively. +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. -> 🚧 TODO +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, per the sequencer token_id encoding (see below) +// Hub tail (kind=0): +uint8 token_decimals +// Peer tail (kind=1): +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 | -### Transceiver (Peer) Registration +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 // 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): + +```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 1fc5ad41b6..de3f57f2a6 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" @@ -82,6 +83,107 @@ const ( // tesSUCCESS is the XRPL transaction result code for successful transactions const tesSUCCESS = "tesSUCCESS" +// 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 +// 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, xregTokenXRPWireLen, nil // all zeros + case xregTokenIOU: + 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:xregTokenIOUWireLen]) + sourceToken[0] = tokenTypeIssued + copy(sourceToken[1:], hash[1:]) + return sourceToken, xregTokenIOUWireLen, nil + case xregTokenMPT: + 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: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]) + } +} + +// 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, mode, decimals uint8) []byte { + out := make([]byte, 0, transceiverInfoLen) + out = append(out, transceiverInfoPrefix[:]...) + out = append(out, manager32[:]...) + out = append(out, mode) + 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, transceiverRegLen) + 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". @@ -123,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 @@ -157,11 +265,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 { @@ -180,11 +291,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) @@ -285,7 +398,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( @@ -321,35 +434,283 @@ 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. -// Returns (nil, nil) if none matched. -func (p *Parser) parseTransaction(tx GenericTx) (*common.MessagePublication, error) { +// 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 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 nil 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 { + p.logDetected(tx, "ticket_create_xtcf") + return []*common.MessagePublication{msg}, nil + } + + regMsg := p.parseRegistrationTransaction(tx) + if regMsg != nil { + msgs := []*common.MessagePublication{regMsg} + var ackMsg *common.MessagePublication + ackMsg, err = p.parseXACKTransaction(tx) + if err != nil { + return nil, err + } + if ackMsg != nil { + msgs = append(msgs, ackMsg) + } + p.logDetected(tx, "registration+xack") + return msgs, nil } msg, err = p.parseXACKTransaction(tx) - if msg != nil || err != nil { - return msg, err + if err != nil { + return nil, err + } + if msg != nil { + p.logDetected(tx, "xack") + 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 { + p.logDetected(tx, "core") + return []*common.MessagePublication{msg}, nil + } + + msg, err = p.parseNttTransaction(tx) + if err != nil { + 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. +// +// 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 + } + + destination, err := p.extractDestination(tx.Transaction) + if err != nil || destination != p.coreAccount { + 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 // not a (valid) registration; fall through to the XACK handler. + } + + senderID, err := p.registrationSenderAccountID(tx.Transaction) + if err != nil || senderID == nil { + 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 + } + + // 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 // failed registration Payment falls through to the XACK handler. + } + if err = validateTransactionType(tx.Transaction); err != nil { + 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) so the XACK + // handler runs and clears the ticket — never abort the dispatch. + txHash, sequence, err := p.extractTxHashAndSequence(tx) + if err != nil { + 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 // fall through to the XACK handler so the ticket is acked. + } + + 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, } +} - return p.parseNttTransaction(tx) +// 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 +} + +// 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) { + account, err := extractAccountString(tx) + if err != nil { + return nil, err + } + 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. +// +// 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 + 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[xregManagerOffset:xregHeaderLen]) + + // 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) + if err != nil { + return nil, emitter, err + } + tail := data[xregHeaderLen+consumed:] + + emitter = calculateEmitterAddress(manager32, sourceToken) + + switch kind { + case xregKindHub: + 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 length %d, want %d", len(tail), xregPeerTailLen) + } + peerChain := binary.BigEndian.Uint16(tail[0:2]) + var peerAddr [32]byte + copy(peerAddr[:], tail[2:xregPeerTailLen]) + 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). @@ -776,19 +1137,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) @@ -925,11 +1295,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). + emitter, err := vaa.BytesToAddress(accountID) + if err != nil { + return vaa.Address{}, fmt.Errorf("failed to pad account id: %w", err) + } return emitter, nil } @@ -1283,7 +1654,7 @@ 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 { +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 42ce0a9272..bcc13ea235 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) @@ -743,23 +772,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 +795,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") } @@ -1361,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) @@ -1412,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") @@ -1511,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") @@ -1589,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) @@ -1653,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) @@ -1733,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) @@ -1774,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") @@ -2155,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) @@ -2543,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) @@ -2711,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) { @@ -2761,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) @@ -2810,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,11 +3643,11 @@ 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") - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -3631,11 +3656,11 @@ 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") - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -3650,11 +3675,13 @@ 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}) - msg, err := p.parseTransaction(tx) + msg, err := p.parseOne(tx) require.NoError(t, err) require.NotNil(t, msg) @@ -3833,11 +3860,11 @@ 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") - 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 new file mode 100644 index 0000000000..7765ab71a5 --- /dev/null +++ b/node/pkg/watchers/xrpl/registration_test.go @@ -0,0 +1,485 @@ +package xrpl + +import ( + "bytes" + "encoding/binary" + "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" + "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. +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 +} + +// 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). +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, xrpDecimals) // XRP decimals are fixed at 6 + 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 +} + +// 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 { + 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 +} + +// 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", + LedgerIndex: 12345, + CloseTimeISO: "2024-06-15T14:30:00Z", + Validated: true, + Transaction: transaction.FlatTransaction{ + "TransactionType": "Payment", + "Account": sender, + "Destination": testRegCoreAccount, + "TicketSequence": float64(regTicketSequence), + "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", + }, + } +} + +// 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 { + var out [32]byte + copy(out[12:], m[:]) + return out +} + +func TestRegistration_HubXRP_EmitterMatchesTransferPath(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + + msgs, err := p.ParseTransactionStream(regTxStream(managerAddress(t, manager), buildXregHubXRP(manager))) + require.NoError(t, err) + + // 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) + + // 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 := 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, 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(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) + + // 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) + } + + 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:]. + sourceToken, consumed, err := regSourceTokenFromWire(append([]byte{xregTokenIOU}, append(currency[:], issuer[:]...)...)) + require.NoError(t, err) + require.Equal(t, xregTokenIOUWireLen, consumed) + + expected := 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 + + 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)) + 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 + + 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)) + assert.Equal(t, uint8(nttModeBurning), msg.Payload[36], "manager == MPT issuer => Burning") +} + +func TestRegistration_PeerXRP(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + + var peerAddr [32]byte + for i := range peerAddr { + peerAddr[i] = byte(0x10 + i) + } + const peerChain = uint16(6) // Avalanche + + 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 + expected := 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, 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])) +} + +func TestRegistration_WrongMemoFormat_NotRegistration(t *testing.T) { + manager := testRegManager20() + p := newRegParser(t, manager) + 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, 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) + + // 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) + assert.Nil(t, findRegistration(msgs), + "non-XREG memo must not synthesize a transceiver registration") +} + +// 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)) + + // 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, 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 +// 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)) + + // 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, 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") +} + +// 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") +} + +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") +} + +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 new file mode 100644 index 0000000000..c37eafe171 --- /dev/null +++ b/node/pkg/watchers/xrpl/types.go @@ -0,0 +1,45 @@ +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 + 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) + 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) +) diff --git a/node/pkg/watchers/xrpl/watcher.go b/node/pkg/watchers/xrpl/watcher.go index 9a772ddf25..c082c137a3 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))) - msg, 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)), @@ -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,27 @@ 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) { +// 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),