Skip to content

Xrpl ntt accountant#4893

Open
douglasgalico wants to merge 12 commits into
wormhole-foundation:mainfrom
wormholelabs-xyz:xrpl-ntt-accountant
Open

Xrpl ntt accountant#4893
douglasgalico wants to merge 12 commits into
wormhole-foundation:mainfrom
wormholelabs-xyz:xrpl-ntt-accountant

Conversation

@douglasgalico

Copy link
Copy Markdown
Contributor

Integrates XRPL into the NTT global accountant. The XRPL watcher now recognizes NTT transfer emitters and synthesizes transceiver registration messages, so XRPL custody accounts can be tracked by the ntt-global-accountant

M-Picco
M-Picco previously approved these changes Jul 6, 2026

@evan-gray evan-gray left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be misunderstanding something, but between this and the sequencer PR, I'm not clear how the XRPL manager sends the payment to the XRPL core bridge account. I'm also concerned about not being able to accurately distinguish locking and burning configurations.

Comment thread node/pkg/watchers/xrpl/parse.go Outdated
out := make([]byte, 0, transceiverInfoLen)
out = append(out, transceiverInfoPrefix[:]...)
out = append(out, manager32[:]...)
out = append(out, nttModeLocking)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this always locking? an XRPL manager could be either locking or burning depending on if it is the asset issuer.

Comment thread node/pkg/watchers/xrpl/README.md Outdated
[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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this follows the encoding from the sequencer spec?

token_id encoding:

Token Type Byte 103 Bytes 104+ Total token_id
XRP 0x00 (none) 1 byte
IOU 0x01 currency (20) + issuer (20) 41 bytes
MPT 0x02 mpt_issuance_id (24) 25 bytes

Comment thread node/pkg/watchers/xrpl/README.md Outdated
```go
[4]byte prefix = 0x9c23bd3b
[32]byte manager_address // manager32 (left-padded XRPL account)
uint8 manager_mode = 1 // locking

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is the mode determined?

<!-- cspell:ignore XREG vaas -->

> 🚧 TODO
### Registration (Initialize Transceiver & Peer Registration)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread node/pkg/watchers/xrpl/README.md Outdated
### Transceiver (Peer) Registration
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might have expected the Solana Sequencer to form the payload and the core bridge to allow for a generic "emit as transceiver" functionality that accepts a token_id and generic payload. Though I suppose this approach prevents malicious transfer messages from being emitted directly via the core bridge so perhaps it is more secure albeit more tedious (and would need to be extended across multiple codebases should the need for another message type arise).

Comment thread node/pkg/watchers/xrpl/parse.go Outdated
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accountant only accepts locking hubs, which is true, but these init messages should reflect whether the manager is in locking or burning mode. all XRPL managers should not have messages attesting that they are locking when they may not be.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added logic to detect based on token_id (IOU and MPT) by checking if Issuer is the same as the Manager/CustodyAccount

message under the correct NTT transceiver emitter.

> 🚧 TODO
Upon a Payment to the Core Account, the guardian watcher MUST

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also must verify that the message came from the matching manager? though I'm confused about how an XREG message from the sequencer results in a payment from the manager without either manager changes in this PR or a generic payment message in the Sequencer PR. I would expect the latter - that the Sequencer issues a generic payment to the core bridge containing this registration. Maybe I'm missing where that occurs, but it looks like this is simply posted as a VAA payload. 😕

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed at sequencer level! XREG is wrapped in XREL which is signed by delegated manager and captured by watcher.

// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we seem to have this code multiple times now - this is very similar to extractSender

Comment thread node/pkg/watchers/xrpl/parse.go Outdated

// manager32 = left-padded 20-byte account id (same as transfer sourceNTTManager).
var manager32 [32]byte
copy(manager32[12:], manager20[:])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getting nervous about the multiple re-implementations in this file - there's logic for this in addressToEmitter but they don't quite match.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that common operations like this are often covered and well-tested by functions in the Go SDK. (under sdk/)

Comment thread node/pkg/watchers/xrpl/parse.go Outdated
}
tail := data[xregHeaderLen+consumed:]

emitter = (&Parser{}).calculateEmitterAddress(manager32, sourceToken)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure calculateEmitterAddress needs to be method of parser, maybe switch it to a free function instead or match the other helpers and make buildRegistrationMessage a method of Parser.

Comment thread node/pkg/watchers/xrpl/parse.go Outdated

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the XREG is wrapped as XREL and send as standard payment, the transaction should produce XACK.
In that case this code won't be reached because parseXACKTransaction will consume the tx.

The test did not catch this because the regTxStream function builds the transaction without the TicketSequence. In such case parseXACKTransaction returns (nil, nil) because the field is empty and parseRegistrationTransaction takes place. However the manager code assigns TicketSequence: uint32(payload.TicketID).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for spotting it! Indeed it's a miss, parser was adjusted to output a list of transactions (the registration message for accountant and ACK) - adjusted also at watcher code in all paths

// 2. 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
// memo produce a generic Wormhole message.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing comment for 'parseRegistrationTransaction' on the flow of this. Since we do it for the other four, I'd recommend adding it here.

Comment thread node/pkg/watchers/xrpl/parse.go
Comment thread node/pkg/watchers/xrpl/parse.go Outdated
return msg, err
}

// Registration (XREG) publishes go to the core account but use a distinct

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XREG is being sent through the manager, which uses tickets. This currently doesn't handle the ticket management because it's not sending an ACK back to the sequencer. This means that the ticket would effectively be stuck there until a BURN was triggered for the ticket. Naturally, this would fail because the sequence was indeed used up.

Additionally, what should we do in the case that the XREG fails? Should the hub or peer registration accountant message be rebroadcasted?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the ordering of checks in parser and ACK is handled properly. For the XREG failure there was also a gap, now after checking the transaction result - and when it fails - it will pass through parseXACKTransaction, with that we can consume XACK and clear the ticket on sequencer.

Comment thread node/pkg/watchers/xrpl/parse.go Outdated
var transceiverRegPrefix = [4]byte{0x18, 0xfc, 0x67, 0xc2} // WormholeTransceiverRegistration

// XREG constants
const (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Could we move the XRPL discriminator constants into custom typed domains instead of leaving them as untyped constants?

Right now values like xRegKind and xRegTokenXRP are easy to mix in-place because the compiler does not know they represent different concepts. A future change could accidentally compare or pass a registration kind where a token kind is expected, and it would still compile if both are just plain constants.

It would look something like this:

type xRegKind string
type xRegToken string

const (
    xRegKindRegister xRegKind = "..."
    xRegKindAck      xRegKind = "..."

    xRegTokenXRP xRegToken = "..."
)

Then helper signatures can encode intent:

func buildXRegPayload(kind xRegKind, token xRegToken) ...

That makes invalid mixing harder to write and easier to catch during review/compile time.

I’d also suggest putting these XRPL domain types and constants in a dedicated file, e.g. node/pkg/watchers/xrpl/types.go, so the parser code stays focused on transaction handling and the XRPL wire/domain vocabulary has one obvious home.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! Moved the XREG wire consts into a dedicated types.go file. Kept them untyped to match the package other wire discriminants (tokenType*, xackTxType*, ..) since they are raw bytes read from/appended to []byte

Comment thread node/pkg/watchers/xrpl/README.md Outdated
Comment thread node/pkg/accountant/ntt_config.go
return nil, nil
}

destination, err := p.extractDestination(tx.Transaction)

@mdulin2 mdulin2 Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returning an error is a problem with the current ordering again. There are many transaction types where the destination is NOT set. Both a failed TicketCreate and AccountSet (burn ticket) will hit this error, and return, skipping the parseXACKTransaction logic.

@mdulin2 mdulin2 Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need more tests to ensure things work with the current ordering realistic RPC responses, and realistic settings. This is the second change that would have broken existing functionality in the watcher within this PR.

From what I can tell, the reason the existing tests didn't catch this was because the coreAccount was never set on the tests, hitting the exit path with no error above this. In reality, the existing tests (if properly configured) should have caught this regression.


// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: update the function name and comment to clarify that this now returns multiple message publications

// registration consumed on the sequencer. Every other transaction yields at most
// one message.
//
// Returns an empty slice if none matched.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Returns an empty slice if none matched.
// Returns nil if none matched.

The function only returns nil or non-empty slices, not an empty slice.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// with 12 zero bytes). Uses the SDK helper rather than hand-rolled copying.
// with 12 zero bytes).

This line can be removed -- I assume this was added during an LLM revision but we want to avoid comments like this getting into the codebase

// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line can be removed -- I assume this was added during an LLM revision but we want to avoid comments like this getting into the codebase

// one message.
//
// Returns an empty slice if none matched.
func (p *Parser) parseTransaction(tx GenericTx) ([]*common.MessagePublication, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given how complicated this function is, I think it's worth threading a zap.Logger through to the Parser type. It could just use a pointer to the logger the watcher already has.

This would allow us to add logging alongside the detection of the transaction type. It would be helpful for testing and debugging to know if this function ever returns the wrong type, for example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants