Social
Delegated & Gasless Posting

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
) => int

Stateful, 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:

BytesHexMeaning
11a26, the length of the prefix string
2661657465726e697479205369676e6564204d6573736167653a0aaeternity Signed Message: plus a newline
12032, 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:

  1. Accept a (title, media, author, signature) tuple over your own transport.
  2. Verify before you spend. Recompute the digest and check the signature against author locally. A contract call that fails verification aborts with SIGNATURE_HAS_TO_MATCH_AUTHOR and still costs gas.
  3. Submit post_without_tip_sig(title, media, author, sig) from your funded account.
  4. Return the transaction hash and the returned tip_id, suffixed with _v3 to 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_id

The 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