Smart Contracts
Tipping (v3)

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.

NetworkAddress
Mainnetct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb
Testnetct_2J1wuuw9urs9ADBh5QbvuPyUCLdKbW5YRkfhgPoN7rGjBbPiBW

The ACI is Tipping_v3.aci.json.

Entrypoints

EntrypointSignatureFlags
init() => Tipping.state
post_without_tip(title: string, media: list(string)) => intstateful
post_without_tip_sig(title: string, media: list(string), author: address, sig: signature) => intstateful
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:

VersionTip surface
Tipping_v1tip(url, title), retip(tip_id), pre_claim, claim: oracle-backed URL tipping
Tipping_v2adds tip_token, tip_direct, tip_token_direct, retip_token
Tipping_v3none (posting only)
Tipping_v4post_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,  title

One 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:

BytesMeaning
1a26, 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 0aaeternity Signed Message: followed by a newline, 26 ASCII bytes
2032, 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