sRFC 36 - Typed Message Payload Rendering in Wallets
TLDR;
To improve DeFi UX, we can eliminate gas fees for market takers by allowing them to sign an IDL-encoded message instead of submitting a transaction. The maker can then use this signed message to submit a single verified transaction on behalf of both parties, replacing the current two-transaction flow. This flow can already be implemented with on-chain and off-chain logic. However, the existing wallet UX for signing such messages is suboptimal. Drift proposes a better methodology for wallet frontends to display and parse these payloads before signing.
Background
Currently, Solana DeFi trades between a taker and a maker require two separate transactions:
- The taker submits an order, which is recorded on-chain.
- The maker reads these on-chain orders and fills the trade in a second transaction.
As a result, both parties must pay gas fees, and during periods of network congestion, landing both transactions becomes very difficult and costs will be exacerbated with spiking priority fees.
Proposal
For a faster DeFi experience gasless transactions for market participants, we want to use user signed, IDL-encoded messages for order submission. We are proposing a cosmetic enhancement in this sRFC. We can already implement this signed message flow, but the targeted goal of this sRFC is to improve user-transparency. Drift is proposing improvements to wallet UX that would allow users to better understand and trust these IDL-encoded messages, reducing friction and enhancing adoption.
Current state of wallets signing IDL typed data in messages
When borsh encoding a payload using a program’s IDL, the resulting buffer will cause Phantom to throw an exception. For instance, in this example:
import { PublicKey } from '@solana/web3.js';
import { Program } from '@project-serum/anchor';
import { Drift } from './idl'; //
import { useWallet } from '@solana/wallet-adapter-react';
async function signOrderParams(orderParamsMessage: any, program: Program<Drift>) {
const { publicKey, signMessage } = useWallet();
// Encode the order parameters using the program's IDL
const encodedMessage = program.coder.types.encode(
'OrderParamsMessage',
orderParamsMessage
);
try {
// Request the wallet to sign the encoded message
const signedMessage = await signMessage(encodedMessage);
console.log('Signed Message:', signedMessage);
return signedMessage;
} catch (error) {
console.error('Error signing message:', error);
throw error;
}
}
This code would not result in a log of Signed Message: ###
but instead an exception would be thrown and you would see the error log of Error signing message: WalletSignMessageError: You cannot sign solana transactions using sign message
. So phantom is flagging this borsh buffer as a transaction and does not allow the app to proceed.
To circumnavigate this, we have tested a couple options. Originally, we tried out a simple utf-8 encoding layer with new TextEncoder().encode
:
import { PublicKey } from '@solana/web3.js';
import { Program } from '@project-serum/anchor';
import { Drift } from './idl'; //
import { useWallet } from '@solana/wallet-adapter-react';
async function signOrderParams(orderParamsMessage: any, program: Program<Drift>) {
const { publicKey, signMessage } = useWallet();
if (!publicKey || !signMessage) {
throw new Error('Wallet not connected or does not support signMessage.');
}
// Encode the order parameters using the program's IDL
const encodedMessage = program.coder.types.encode(
'OrderParamsMessage',
orderParamsMessage
);
const utf8EncodedMessage = new TextEncoder().encode(encodedMessage.toString());
try {
// Request the wallet to sign the UTF-8 encoded message
const signedMessage = await signMessage(utf8EncodedMessage);
console.log('Signed Message:', signedMessage);
return signedMessage;
} catch (error) {
console.error('Error signing message:', error);
throw error;
}
}
This produced a very undesirable result in what was rendered in the wallet:
Obviously this is a bizarre/scary message to sign.
We decided to add an additional layer of hex encoding to see if that would produce a more desirable output. Here is the current code roughly:
import { PublicKey } from '@solana/web3.js';
import { Program } from '@project-serum/anchor';
import { Drift } from './idl'; //
import { useWallet } from '@solana/wallet-adapter-react';
async function signOrderParams(orderParamsMessage: any, program: Program<Drift>) {
const { publicKey, signMessage } = useWallet();
if (!publicKey || !signMessage) {
throw new Error('Wallet not connected or does not support signMessage.');
}
// Encode the order parameters using the program's IDL
const encodedMessage = program.coder.types.encode(
'OrderParamsMessage',
orderParamsMessage
);
const hexEncodedMessage = Buffer.from(encodedMessage.toString('hex'));
try {
const signedMessage = await signMessage(hexEncodedMessage);
console.log('Signed Message:', signedMessage);
return signedMessage;
} catch (error) {
console.error('Error signing message:', error);
throw error;
}
}
So we dropped the new TextEncoder.encode()
and simply did Buffer.from
with toString('hex')
to have only a hex layer of encoding.
The rendered message looks better, albeit still far from an optimal UX:
Besides creating a poor user experience, managing multiple layers of encoding (hex/utf-8 plus borsh) is painful for developers. It’s especially challenging to track the correct order of wrapping and unwrapping these encodings across different parts of a system’s architecture.
Proposed future state of wallets signing IDL typed data in messages
So that brings us to the desire for a better solution for both users and developers. Signing IDL-encoded instruction payloads as messages that wallets can use on-chain functionality to decode.
The client code for constructing and signing the message could retain the hex encoding for its needed layer of security. Wallets actually should not accept raw borsh buffers as signed messages because this could be used by scammers to submit transactions. So here again is the hex implementation:
import { PublicKey } from '@solana/web3.js';
import { Program } from '@project-serum/anchor';
import { Drift } from './idl'; //
import { useWallet } from '@solana/wallet-adapter-react';
async function signOrderParams(orderParamsMessage: any, program: Program<Drift>) {
const { publicKey, signMessage } = useWallet();
if (!publicKey || !signMessage) {
throw new Error('Wallet not connected or does not support signMessage.');
}
// Encode the order parameters using the program's IDL
const encodedMessage = program.coder.types.encode(
'OrderParamsMessage',
orderParamsMessage
);
const hexEncodedMessage = Buffer.from(encodedMessage.toString('hex'));
try {
const signedMessage = await signMessage(hexEncodedMessage);
console.log('Signed Message:', signedMessage);
return signedMessage;
} catch (error) {
console.error('Error signing message:', error);
throw error;
}
}
Note: this is the same implementation in the third code block that would display at least a hex string in Phantom.
Wallet renders human-readable message previews (Courtesy of Phantom)
Big shoutout to @FabioBerger and @KimPersson from Phantom for writing this part of our proposed standard.
For user security, simply rendering a decoded JSON object is insufficient. Not only is it not clear what the user is consenting to, the JSON includes fields that aren’t rendered optimally for legibility (e.g., token addresses instead of names/symbols, non-decimalized amounts, etc). In order to provide human-readable previews of how signed messages can modify on-chain state, protocol developers should provide a standardized on-chain read-only method on the program with programId
associated with the IDL-encoded message. This method should look like this:
human_readable_message(typedData: Buffer) -> string
It returns a template string similar to the following example:
"Place limit order on {nativeAsset}/{splAsset:sellToken} orderbook for {nativeAssetAmount:buyAmount} at {splAssetPrice:buyPrice}/{nativeAsset}"
where the variables are in the format {entity:idlProperty}
so that all template variables can map to a decoded IDL property but are further specified as a specific kind of entity. The reason for the entity specification is because just looking at the IDL definition (e.g., probability: float32
) the wallet has insufficient information on how to render the value — should it be rendered as a decimal or a percentage? The entity mapping gives wallets a clear instruction as to how to render the value.
Doing the above will improve the user experience drastically, and the protocol would not have to worry about proper rendering of entities (incl. decimalized amount conversions, asset names/symbols, etc.) which should be handled by the signing UI (i.e., wallets).
Wallets would need to:
- Design React components to render each of the possible entity types listed below:
-
address
- pubkey
- sns (eventually?)
{ "humanizedValue": "7ryWA6...Qutotj", "humanizedName": "account", "kind": "ADDRESS", "pubkey": "7ryWA6NYfsWzYPCJb49Th7RuHScHyjEByLssNXQutotj", "sns": "toly.sol" // eventually, doesn't need to be in first version }
-
storageAccount
- pubkey
- accountType
{ "accountType": "Pool", "pubkey": "HWDSaiUjqtgfj5ncHdFKjp6EZmFXKZ6Lee5ZZwJboxFH", "kind": "STORAGE_ACCOUNT", }
-
nativeAsset
{ "kind": "NATIVE_ASSET", "humanizedValue": "SOL", "humanizedName": "native asset", "decimals": 9, "symbol": "SOL" }
-
splAsset
{ "kind": "SPL_ASSET", "humanizedValue": "USDC", "humanizedName": "token", "decimals": 6, "symbol": "USDC", "mintPubkey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }
-
compressedNftAsset
-
metaplexCoreAsset
-
nativeAssetAmount
- amount
- symbol (
LAMPORTS
,WEI
, etc.) - decimals
{ "kind": "NATIVE_ASSET_AMOUNT", "humanizedValue": "0.07628 SOL", "humanizedName": "price", "amount": "76283872", "decimals": 9, "symbol": "SOL" }
-
splAssetAmount
- amount
- decimals
- symbol
- mintPubkey
- dollarValue? - maybe
{ "kind": "SPL_ASSET_AMOUNT", "humanizedValue": "10 USDC", "humanizedName": "price", "amount": "10000000", "decimals": 6, "symbol": "USDC", "mintPubkey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }
-
date (can render humanized date representation)
- timestamp
{ "kind": "DATE", "humanizedValue": "2024-11-01 05:41:55 UTC", "humanizedName": "lastUpdatedAt", "timestamp": "1730444515", }
-
duration
- seconds
{ "kind": "Duration", "humanizedValue": "22 hours 21 minutes 5 seconds", "humanizedName": "fuelQuantityInEscrow", "seconds": 80465 }
-
percentage
-
bigInt
-
string
-
bool
-
As an example, addresses and accounts could simply be rendered as a link out to an explorer. Assets could be tokenized to include their respective icons in-line and on-hover show a tooltip with more information (e.g., USD price) and if clicked navigate the user to it’s info page. The data could be displayed in the user’s timezone and duration amounts localized. This would make the UI more information dense while still providing the user all the contextual information they need to understand a transaction’s implications.
Design / Prototype
These are designs of using this same basic idea of template strings to augment the rendering transaction state changes in Phantom wallet.
Swaps
Meteora Liquidity Pool Rewards
Current Design:
Proposed Design:
For the full listing of Phantom’s proposed design improvements with these template strings, you can view their notion doc on the matter: https://phantomwallet.notion.site/Human-readable-Message-Previews-199d70940b2880ed8104cc82c3d1b231
Wallet method interface for devs
So how do dapps utilize this new functionality and let wallets know that this signed message can be decoded by an on-chain program? We propose a new interface that accepts both the encoded message and the program address so the wallet knows what program to call to render a message.
Introducing signTypedMessage
that wallets could offer as a method accepting the encodedMessage: Buffer
and the programId: PublicKey
. The return would essentially be the same as signMessage
but the user experience would obviously be vastly superior.
Block explorer rendering
Not just wallets, but also block explorers can utilize similar methods to render signedMessages in transaction instructions.
Another shoutout to Noah Gundotra from the Solana Foundation, who helped us a lot by prompting us to draft this sRFC and helping with editing. He also authored a PR that adds IDL payload decoding functionality to the Solana block explorer—allowing it to render IDL-encoded payloads from Ed25519 SigVerify instructions. Here’s a screenshot demonstrating his excellent work:
He uses a special function to do a default coding of the IDL payload fields, but the block explorer could also implement the utilizing of the IDL read method template as well, and perhaps fallback to this more default rendering. The more options the better.
Conclusion
This standard demonstrates how to create a clear, meaningful interfaces for users signing IDL-typed messages that programs can leverage in on-chain transactions. These improvements would advance both wallet user experience and developer workflows across the Solana ecosystem.
Credits
- @ngundotra at Solana Foundation: Review and providing code examples for IDL decoding
- Fabio at Phantom: Writing and Reviewing
- Backpack team: Reviewing
- moose at Drift: Writing and Reviewing