Delegated & Gasless Posting
post_without_tip_sig lets any account submit a post on behalf of an author. The submitter
pays the gas; the on-chain record attributes the post to the signer. This is the mechanism
behind gasless onboarding: a user signs a message in a wallet that holds no AE, and a relayer
turns it into a transaction.
The entrypoint
post_without_tip_sig(
title : string,
media : list(string),
author : address,
sig : signature
) => intStateful, not payable, returns the new tip_id, and emits the same
PostWithoutTipReceived(author, title) event as a direct post. The stored tip_meta records
sender = author, and the indexer treats a delegated post exactly like a direct one. The two
paths are indistinguishable from the outside.
stateful entrypoint post_without_tip_sig(title : string, media : list(string), author : address, sig : signature) : int =
let msg = Crypto.blake2b(prefix_message_hashed(concat(title, media)))
require(Crypto.verify_sig(msg, author, sig), "SIGNATURE_HAS_TO_MATCH_AUTHOR")
save_post(title, media, author)The signed message
The message is the title immediately followed by every media entry in list order, with no separator:
message = title ++ media[0] ++ media[1] ++ ... ++ media[n-1]Media order is part of the signature: reordering the list produces a different digest. An empty media list signs the bare title.
The digest
1. message = title ++ media joined with no separator
2. inner = Blake2b-256(UTF-8 bytes of message) // 32 bytes
3. payload = prefix || inner // 28 + 32 = 60 bytes
4. digest = Blake2b-256(payload) // 32 bytes
5. ok = Crypto.verify_sig(digest, author, sig)The 28-byte prefix is the literal #1a61657465726e697479205369676e6564204d6573736167653a0a20
in the contract:
| Bytes | Hex | Meaning |
|---|---|---|
| 1 | 1a | 26, the length of the prefix string |
| 26 | 61657465726e697479205369676e6564204d6573736167653a0a | aeternity Signed Message: plus a newline |
| 1 | 20 | 32, the length of the body that follows |
This is the æternity personal-message framing the SDK's hashMessage produces, and it is what
stops a signature captured here from being replayed as a raw transaction signature. The scheme
hashes down to a 32-byte digest before verifying, so the contract always passes a 32-byte
message to Crypto.verify_sig regardless of compiler version.
Sign a post
This matches the contract line for line:
import blake2b from 'blakejs';
const MESSAGE_PREFIX = 'aeternity Signed Message:\n';
function personalMessageToBinary(bodyBytes) {
const prefix = new TextEncoder().encode(MESSAGE_PREFIX);
const out = new Uint8Array(1 + prefix.length + 1 + bodyBytes.length);
out[0] = prefix.length; // 26
out.set(prefix, 1);
out[1 + prefix.length] = bodyBytes.length; // 32
out.set(bodyBytes, 2 + prefix.length);
return out; // 60 bytes for a 32-byte body
}
export function postDigest(title, media) {
const message = title + media.join(''); // no separator
const inner = blake2b.blake2b(new TextEncoder().encode(message), undefined, 32);
const payload = personalMessageToBinary(inner);
return blake2b.blake2b(payload, undefined, 32);
}Sign postDigest(title, media) with the author's Ed25519 key and hand the relayer the tuple
(title, media, author, signature). The signature is a 64-byte value; a 128-character
lowercase hex string with no prefix (the convention the address-links API uses) is a good
wire format.
If you are signing from a connected wallet rather than a raw key, the wallet's signMessage
applies the prefix framing itself over the message you give it. Pass the 32-byte inner Blake2b
digest as that message to reproduce steps 2 to 4 exactly; passing the raw text would frame the
text rather than its digest.
Run a relayer
A minimal relayer is a queue in front of one funded account:
- Accept a
(title, media, author, signature)tuple over your own transport. - Verify before you spend. Recompute the digest and check the signature against
authorlocally. A contract call that fails verification aborts withSIGNATURE_HAS_TO_MATCH_AUTHORand still costs gas. - Submit
post_without_tip_sig(title, media, author, sig)from your funded account. - Return the transaction hash and the returned
tip_id, suffixed with_v3to match what the Superhero API serves.
Superhero does not operate a public relayer; the web app calls post_without_tip directly
from the user's wallet. To offer delegated posting, run your own.
Security considerations
The signed message contains no nonce, expiry or relayer address, so a signature stays valid
indefinitely and every submission of the same tuple creates a new post. A relayer should keep a
record of the tuples it has submitted and refuse to accept the same signature twice, and should
submit the (title, media) split exactly as the author provided it, since the digest does not
encode the boundary between title and media. Authors who want a post to be unique can include
a timestamp in the text.
What gets stored
save_post is shared by both entrypoints:
stateful function save_post(title : string, media : list(string), author : address) : int =
let tip_id = Map.size(state.tips)
let tip_meta = { sender = author, title = title, timestamp = Chain.timestamp }
put(state{ tips[tip_id] = PostWithoutTip(tip_meta, media) })
Chain.event(PostWithoutTipReceived(author, title))
tip_idThe id is Map.size(state.tips); since the map only grows, ids form a dense sequence from 0.
timestamp is Chain.timestamp of the submitting transaction, so a delegated post is dated
when the relayer submitted it, not when the author signed it.
When to use it
Use post_without_tip_sig when the author cannot pay: new-user onboarding before the wallet
is funded, or a custodial surface that holds no user key but can forward a signature. For
everything else prefer post_without_tip: it is simpler, and the author pays only gas.
Related
- Posting: the direct path, the data model, and the shared conventions.
- The Tipping contract: the contract reference for both entrypoints.
- AddressLink: the same signed-message construction, with a contract-held nonce.
- Contract security: the protocol's wider security model.
- Sophia standard library (opens in a new tab):
the reference for
Crypto.verify_sig.