Tipping v3
Tipping.aes is the content layer. Every post published through Superhero is a row in this
contract's state, keyed by an increasing integer and attributed to an æternity address. Once
mined, a post cannot be edited or deleted. Moderation happens at the application layer, not
here.
Despite the name, v3 has no tipping entrypoints. It declares four entrypoints, none of them payable. Tips on Superhero are spend transactions. See Tips are spend transactions.
| Network | Address |
|---|---|
| Mainnet | ct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb |
| Testnet | ct_2J1wuuw9urs9ADBh5QbvuPyUCLdKbW5YRkfhgPoN7rGjBbPiBW |
The ACI is Tipping_v3.aci.json.
Entrypoints
| Entrypoint | Signature | Flags |
|---|---|---|
init | () => Tipping.state | |
post_without_tip | (title: string, media: list(string)) => int | stateful |
post_without_tip_sig | (title: string, media: list(string), author: address, sig: signature) => int | stateful |
get_state | () => Tipping.state |
Both posting entrypoints return the new tip_id. Neither is payable, so a post costs only the
transaction fee and gas.
tip_id is assigned as Map.size(state.tips), so ids are dense and sequential from 0. There
is no delete, so ids are never reused. The Superhero API serves them suffixed: post 0
becomes 0_v3.
The contract does not constrain the length of title, the number of media entries, or their
format. The only require in the contract is the signature check in post_without_tip_sig.
Tips are spend transactions
A tip on Superhero is a plain spend transaction with a payload, not a call into this
contract. The payload starts with TIP_POST: followed by the post id for a post tip, or
TIP_PROFILE for a profile tip, and the AE goes straight from sender to recipient. If you are
indexing tips, index spend payloads, not contract calls.
Earlier versions of the contract family did tip on chain:
| Version | Tip surface |
|---|---|
Tipping_v1 | tip(url, title), retip(tip_id), pre_claim, claim: oracle-backed URL tipping |
Tipping_v2 | adds tip_token, tip_direct, tip_token_direct, retip_token |
Tipping_v3 | none (posting only) |
Tipping_v4 | post_via_burn(title, media, token, token_amount): posting paid by burning an AEX-9 |
v3 is what Superhero's social layer runs against.
Types
tip is a variant with a single constructor, which is why every post is a PostWithoutTip
even though the type is named tip.
type tip_id = int
record tip_meta =
{ sender : address // the author, as recorded on chain
, title : string // the post text
, timestamp : int } // Chain.timestamp at the moment of posting, in ms
datatype tip = PostWithoutTip(tip_meta, list(string))
// ^ the media list
record state =
{ tips : map(tip_id, tip)
, version : string } // "v3"Reading a post back through get_state() gives you a Map of tip_id to a variant object.
In JavaScript a decoded entry looks like:
const state = await tipping.get_state().then((r) => r.decodedResult);
const post = state.tips.get(42n);
// post = { PostWithoutTip: [ { sender, title, timestamp }, [ 'https://...' ] ] }
const [meta, media] = post.PostWithoutTip;get_state() returns every post ever made. On mainnet that is a large response. For
anything other than a one-off inspection, read posts from the Superhero API or from the
middleware's event log instead.
Event
datatype event = PostWithoutTipReceived(address, string)
// author, titleOne event per successful post, from either entrypoint. Indexers subscribe to it rather than
polling get_state(). Its middleware topic hash is in the
event catalogue.
The event carries the title but not the media list and not the tip_id. To
reconstruct a full post from an event you need the call arguments or a get_state() read.
Direct posting
post_without_tip records Call.caller as the author. This is the path a connected wallet
takes:
import { AeSdk, Node, MemoryAccount, Contract } from '@aeternity/aepp-sdk';
import fs from 'fs';
const aeSdk = new AeSdk({
nodes: [{ name: 'mainnet', instance: new Node('https://mainnet.aeternity.io') }],
accounts: [new MemoryAccount(process.env.AE_PRIVATE_KEY)],
});
const tipping = await Contract.initialize({
aci: JSON.parse(fs.readFileSync('./contracts/Tipping_v3.aci.json', 'utf8')),
address: 'ct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb',
onAccount: aeSdk,
onNode: aeSdk.api,
});
const result = await tipping.post_without_tip(
'Hello from the contract layer',
['https://example.com/image.png'],
);
console.log('tip_id:', result.decodedResult.toString());
console.log('tx:', result.hash);media is a plain array of strings. The contract does not validate or interpret them. They
are opaque references that the frontend renders as links or embeds.
Delegated posting
post_without_tip_sig lets any account submit a post on behalf of an author who signed it off
chain. This is how gasless posting works: the user signs a message in their wallet, a relayer
pays the fee, and the on-chain record still attributes the post to the signer.
The entire verification:
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)
function concat(acc : string, remaining : list(string)) : string =
switch(remaining)
[] => acc
s :: ss => concat(String.concat(acc, s), ss)
function prefix_message_hashed(msg : string) : bytes(60) =
let hash = Crypto.blake2b(msg)
let prefixBytes : bytes(28) = #1a61657465726e697479205369676e6564204d6573736167653a0a20
Bytes.concat(prefixBytes, hash)The concatenation
There is no separator. concat folds String.concat over the media list starting from
the title, in list order. The signed message is exactly:
title ++ media[0] ++ media[1] ++ ... ++ media[n-1]In JavaScript, title + media.join(''). Inserting any delimiter (a newline, a comma, a null
byte) produces a digest the contract rejects with SIGNATURE_HAS_TO_MATCH_AUTHOR.
The 28-byte prefix
#1a61657465726e697479205369676e6564204d6573736167653a0a20 is the æternity personal-message
framing:
| Bytes | Meaning |
|---|---|
1a | 26, the length of the prefix string that follows |
61 65 74 65 72 6e 69 74 79 20 53 69 67 6e 65 64 20 4d 65 73 73 61 67 65 3a 0a | aeternity Signed Message: followed by a newline, 26 ASCII bytes |
20 | 32, the length of the body that follows |
1 + 26 + 1 = 28 bytes of header, and the body is always the 32-byte inner Blake2b digest, so
Bytes.concat produces exactly 60 bytes, hence bytes(60).
The full construction
1. message = title ++ media.join('') // no separator
2. inner = Blake2b(message) // 32 bytes
3. payload = 0x1a
|| "aeternity Signed Message:\n"
|| 0x20
|| inner // 28 + 32 = 60 bytes
4. digest = Blake2b(payload) // 32 bytes
5. ok = Crypto.verify_sig(digest, author, sig)Steps 3 and 4 are exactly what the SDK's signMessage does to whatever you hand it, so from a
key the whole author side is two calls:
import { signMessage, hash } from '@aeternity/aepp-sdk';
const message = title + media.join('');
const signature = signMessage(hash(message), secretKey);Note what is being signed: the 32-byte inner digest, not the text. Handing a wallet's
signMessage the raw post text produces a different digest and the call reverts.
Notes
The signature carries no nonce, so a relayer should verify a signature locally before spending
gas and keep track of the signed posts it has already submitted. AddressLink, by contrast,
takes an explicit nonce on every signed write.
Related
- Posting: what posting looks like from the app, and what the API adds on top
- Delegated posting: the relayer flow end to end
- Tipping: the spend-with-payload mechanism
- Events: the topic hash for
PostWithoutTipReceivedand how to subscribe - Calling Contracts: SDK setup, gas, and reading revert reasons
- aeternity/tipping-contract (opens in a new tab): the contract family's source