@vishal-blocksone/testnet-portal
v0.2.0
Published
Solidity contracts for receiving XTalk cross-chain messages, verified by L1X validators
Maintainers
Readme
@vishal-blocksone/testnet-portal
Testnet preview. Published from a personal account while XTalk is on testnet. It will move to an official L1X package before mainnet — expect the name to change.
Solidity contracts for sending and receiving XTalk cross-chain messages.
A message sent on one chain is checked by L1X validators and delivered to your contract on another chain. You write one function to receive and one call to send. Signatures, quorum, replay protection and sender verification are all handled for you.
npm install @vishal-blocksone/testnet-portalThere is no registration step and no stake. Every application on a chain shares one XTalk endpoint that L1X deploys and registers, so sending is just a contract call.
Your contract
import {XTalkSender} from "@vishal-blocksone/testnet-portal/contracts/XTalkSender.sol";
import {XTalkReceiver} from "@vishal-blocksone/testnet-portal/contracts/XTalkReceiver.sol";
contract MyApp is XTalkSender, XTalkReceiver {
constructor(address sourceEndpoint, address endpoint, uint256 peerChainId, address peer)
XTalkSender(sourceEndpoint)
XTalkReceiver(endpoint, peerChainId, peer) {}
function send(uint256 destChainId, address to, uint256 amount) external {
_xtalkSend(destChainId, trustedSender, abi.encode(to, amount));
}
function _xtalkReceive(bytes32 messageId, bytes calldata payload) internal override {
(address to, uint256 amount) = abi.decode(payload, (address, uint256));
// your logic
}
}That's the whole integration. Inherit only XTalkReceiver if you just want to receive.
Constructor
| | |
|---|---|
| sourceEndpoint | the XTalk source endpoint on this chain — you send through it |
| endpoint | the XTalk Endpoint on this chain — it delivers to you |
| peerChainId | the chain your counterpart is on, e.g. 97 for BSC testnet |
| peer | your application contract on that chain |
You cannot deploy without naming your peer. That is deliberate — see below.
What you get
| Check | Handled by |
|---|---|
| Signatures are valid, enough validators signed, message is not a repeat | the Endpoint |
| The message came through an L1X-registered source endpoint | the Endpoint |
| Only the Endpoint can call your contract | XTalkReceiver |
| Only your peer can trigger your contract | XTalkReceiver |
You cannot switch these off. onXTalkReceive is not virtual, and the constructor rejects a
zero peer address.
Why naming your peer matters
The validators prove the message really came through XTalk. They do not prove it came from someone you trust.
Every application on a chain sends through the same shared endpoint, so "it arrived via XTalk"
says nothing on its own about who sent it — at the infrastructure level, every application's
messages look alike. sender is the calling application. If your app did not name its peer,
any other application on that chain could send a message pointing at yours — with genuine
validator signatures on it — and your app would act on it.
This is the same idea as LayerZero's OApp peer check: the endpoint owns the pathway, the
application owns the peer.
Contracts
| File | |
|---|---|
| contracts/XTalkSender.sol | inherit this to send |
| contracts/XTalkReceiver.sol | inherit this to receive |
| contracts/IXTalkReceiver.sol | the interface, if you need it directly |
| contracts/XTalkSourceEndpoint.sol | the source endpoint. L1X deploys this — you don't. Included for reference and so the tests run |
| contracts/XTalkOutboundVerifier.sol | the Endpoint. L1X deploys this — you don't. Same |
| contracts/examples/ExampleXTalkToken.sol | a complete working app, both directions, in ~50 lines |
The message
What validators sign:
keccak256(abi.encode(
uint256 srcChainId, // which chain it came from
address srcContract, // the source endpoint it came through — the pathway
address sender, // WHICH APP sent it — this is what your peer check pins
uint256 destChainId,
bytes32 messageId,
address destAddress, // WHERE it goes — the relayer cannot redirect it
bytes payload // WHAT it says — the relayer cannot change it
))srcContract and sender are different things and both are bound. srcContract is shared
infrastructure — the Endpoint checks it against the source endpoint L1X registered for that
chain, and refuses any chain that has none. sender is your counterpart application, and it is
what XTalkReceiver pins. Binding only the pathway would let every app on the chain speak for
every other.
Signatures are raw recoverable secp256k1 (65 bytes r‖s‖v, v = 27/28) with no
eth-personal prefix. Quorum is 60% of validator stake.
lib/message.js builds the same hash in JavaScript, for relayers and tests.
Sending
Call _xtalkSend(destChainId, destAddress, payload) from XTalkSender. It forwards to the
shared source endpoint, which assigns the message id and stamps your contract as sender.
You do not emit MessageSent yourself, and you do not register anything. Applications used to
do both; the endpoint now does it, which is what removed the per-application registration, the
stake, and the ability of one app to send in another's name.
Ask L1X for the source endpoint address on your chain.
Tests
npm install
npm test24 tests. Covers: delivery from the correct peer, deployment refused without a pinned peer, a genuinely-signed message from a different application being rejected, messages that did not come through the registered source endpoint, a chain with no registered endpoint, wrong source chain, direct calls that bypass the Endpoint, replay, source-endpoint id assignment, and validator set rotation including epoch expiry.
Current limits
- This moves messages, not value. Nothing is locked on the source chain.
- Check the payload yourself. XTalk proves a message is authentic, not that it is sensible.
- You cannot run your own relayer yet. One L1X-operated relayer serves everyone.
- No fees yet. L1X currently absorbs the cost of every message.
License
MIT
