Xrpl ntt accountant#4893
Conversation
evan-gray
left a comment
There was a problem hiding this comment.
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.
| out := make([]byte, 0, transceiverInfoLen) | ||
| out = append(out, transceiverInfoPrefix[:]...) | ||
| out = append(out, manager32[:]...) | ||
| out = append(out, nttModeLocking) |
There was a problem hiding this comment.
why is this always locking? an XRPL manager could be either locking or burning depending on if it is the asset issuer.
| [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 |
There was a problem hiding this comment.
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 |
| ```go | ||
| [4]byte prefix = 0x9c23bd3b | ||
| [32]byte manager_address // manager32 (left-padded XRPL account) | ||
| uint8 manager_mode = 1 // locking |
There was a problem hiding this comment.
where is the mode determined?
| <!-- cspell:ignore XREG vaas --> | ||
|
|
||
| > 🚧 TODO | ||
| ### Registration (Initialize Transceiver & Peer Registration) |
There was a problem hiding this comment.
Might be worth linking to https://github.com/wormhole-foundation/native-token-transfers/blob/main/docs/Transceiver.md in this section
| ### 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 |
There was a problem hiding this comment.
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).
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. 😕
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
we seem to have this code multiple times now - this is very similar to extractSender
|
|
||
| // manager32 = left-padded 20-byte account id (same as transfer sourceNTTManager). | ||
| var manager32 [32]byte | ||
| copy(manager32[12:], manager20[:]) |
There was a problem hiding this comment.
getting nervous about the multiple re-implementations in this file - there's logic for this in addressToEmitter but they don't quite match.
There was a problem hiding this comment.
Note that common operations like this are often covered and well-tested by functions in the Go SDK. (under sdk/)
| } | ||
| tail := data[xregHeaderLen+consumed:] | ||
|
|
||
| emitter = (&Parser{}).calculateEmitterAddress(manager32, sourceToken) |
There was a problem hiding this comment.
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.
|
|
||
| // 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) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Missing comment for 'parseRegistrationTransaction' on the flow of this. Since we do it for the other four, I'd recommend adding it here.
| return msg, err | ||
| } | ||
|
|
||
| // Registration (XREG) publishes go to the core account but use a distinct |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| var transceiverRegPrefix = [4]byte{0x18, 0xfc, 0x67, 0xc2} // WormholeTransceiverRegistration | ||
|
|
||
| // XREG constants | ||
| const ( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
0e59a5d to
e02a142
Compare
| return nil, nil | ||
| } | ||
|
|
||
| destination, err := p.extractDestination(tx.Transaction) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
| // 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. |
There was a problem hiding this comment.
| // 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. |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
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