Social
Posting

Posting

A Superhero post is a contract call. The text and any attached media go into the Tipping v3 contract's state, the caller's address is recorded as the author, and the contract returns an integer id. The contract has no edit and no delete entrypoint, so a published post is permanent.

The data model

Each post is a PostWithoutTip variant holding structured metadata and a list of media strings:

FieldTypeMeaning
tip_meta.senderaddressThe author: the account whose signature authorised the post
tip_meta.titlestringThe post's text content
tip_meta.timestampintChain.timestamp at the moment of posting
medialist(string)Media URLs or identifiers attached to the post

Posts live in a map keyed by a monotonically increasing tip_id, giving a deterministic, append-only ordering of everything ever published through the contract. The contract's state is that map plus a version string, currently "v3".

The field is called title for historical reasons. It holds the whole post body, not a headline.

Contract entrypoints

Tipping v3 has four entrypoints. None of them is payable:

EntrypointSignatureStatefulPurpose
init() => Tipping.statenoDeployment
post_without_tip(title: string, media: list(string)) => intyesDirect posting; Call.caller is the author
post_without_tip_sig(title: string, media: list(string), author: address, sig: signature) => intyesDelegated posting on behalf of a signer
get_state() => Tipping.statenoRead the whole post history

Both posting entrypoints return the new tip_id and emit PostWithoutTipReceived(address, string) (the author and the title), so indexers can follow new content without polling state.

Tipping v3 has no tipping entrypoint. Tips are plain AE transfers with a payload; see Tipping.

Addresses

NetworkTipping v3 contract
Mainnetct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb
Testnetct_2J1wuuw9urs9ADBh5QbvuPyUCLdKbW5YRkfhgPoN7rGjBbPiBW

These are CONTRACT_V3_ADDRESS in the app's per-network configuration.

Post ids and slugs

The contract returns a bare integer. The indexer forms the API id as that value plus _v plus the contract version (_v3 today), so a post created as 3903 is referenced everywhere as 3903_v3. If the middleware hands the indexer a transaction with no return value, the id falls back to the last eight characters of the transaction hash plus the same suffix, which is why an occasional id is not numeric.

In-app links drop the suffix again: a post link is /post/ plus the id with _v3 stripped, or a human-readable slug the API generates for SEO. The resolver at /post/:slug tries, in order:

  1. GET /api/posts/ with the key exactly as given. This handles slugs.
  2. If the key is numeric or already ends in _v3, retry with _v3 appended.
  3. Fall back to GET /api/posts?search=…&limit=1&page=1 and fetch by the returned id.

Write the suffix when you call the API and drop it when you build a URL.

The slug is derived once, at index time, from the post's own text:

base = content.toLowerCase()
             .replace(/[^\p{L}\p{N}._~-]+/gu, '-')   // any non-word run becomes one hyphen
             .replace(/-+/g, '-')
             .replace(/^-+|-+$/g, '')
             .slice(0, 60)
slug = (base ? base + '-' : '') + <the numeric part of the id>

So "Hello, æternity!" as post 3903_v3 becomes hello-æternity-3903. The id suffix keeps the slug unique even when two posts normalise identically.

Content encoding

The contract stores a string. Everything below is a client-side convention the indexer and the renderer agree on; the contract does not enforce any of it.

ConventionWritten asMeaning
Account mention[account:ak_…]A tagged account. The composer shows a short display run and expands it to this macro on submit.
Token tag#SYMBOLA hashtag that resolves to a community token when one exists.
Token display envelope#SYMBOL{mode=advanced}An optional suffix asking for a richer rendering. The symbol is never modified.
Comment markercomment:<parent post id>First entry in media, marking the post as a reply.

The API does not index raw ak_ mentions; a bare address in the text renders as a plain link. An unparseable envelope payload falls back to the plain tag. See Hashtags & Topics for the envelope's option grammar.

Limits

Limits are enforced at three layers:

LimitValueEnforced by
Character count, composer280The web composer, for posts and comments alike
Character count, indexed copy5,000The indexer; longer content is truncated, not rejected
Character count, on chainUnboundedThe contract stores whatever string you pass
Media entries, indexed5The indexer keeps the first five valid entries and drops the rest
Media entries, on chainUnboundedThe contract stores the whole list
Topics per post10The indexer, each at most 50 characters after normalization
EditingNot possibleThe contract has no update entrypoint
DeletionNot possibleThe contract has no delete entrypoint

The 280-character limit is a client-side constant; a post written through the SDK or the CLI is not bound by it. The copy the API serves is normalised on the way in: trimmed, CRLF converted to LF, three or more consecutive newlines collapsed to two, and truncated to 5,000 characters. A 6,000-character post is on chain in full and truncated everywhere a reader sees it; read it back from the contract if you need the original.

What counts as a media entry

The indexer does not store the media list verbatim. Each entry must be a parseable URL with an http: or https: scheme, and must either end in .jpg, .jpeg, .png, .gif, .webp, .mp4, .webm or .mov, or be hosted on imgur.com, giphy.com, youtube.com or vimeo.com (or a subdomain of one). Everything else is dropped from the indexed media array, and only the first five survivors are kept.

A comment's comment:3903_v3 marker is therefore never in the API's media field; tx_args still carries the raw list. If any raw media entry contains the substring hidden, the indexer sets is_hidden = true on the post, which removes it from the feeds and from popular ranking. See Moderation.

Posting from the app

Compose at the top of the feed at https://superhero.com/, or from a token's topic tab, where the composer requires that token's hashtag before the Post button enables. On submit the app:

  1. Expands tagged mentions from their display form to the [account:…] storage form.
  2. Builds the media list: attached URLs for a post; comment: plus the parent id first for a reply.
  3. Initialises the contract with the vendored ACI and CONTRACT_V3_ADDRESS.
  4. Calls post_without_tip(content, media) through the connected wallet.
  5. Suffixes the returned id with _v3, optimistically inserts the post into the feed cache, and reconciles against GET /api/posts/ once the backend has indexed it.

If the backend returns 404 on the first fetch, the app keeps its optimistic copy and retries.

Posting from the command line

The agent skill wraps the same call. It needs AE_PRIVATE_KEY in the environment and posts to mainnet.

node scripts/superhero-post.mjs "Your message here"
node scripts/superhero-post.mjs "Check this out" "https://example.com"
node scripts/superhero-post.mjs "Multiple links" "https://link1.com" "https://link2.com"

Every argument after the first becomes a media entry. The script prints JSON:

{
  "success": true,
  "post_id": "3903",
  "content": "Your message here",
  "links": [],
  "tx_hash": "th_..."
}

post_id here is the bare integer. Add _v3 before you look it up through the API.

Posting with the SDK

import { AeSdk, Node, MemoryAccount, Contract } from '@aeternity/aepp-sdk';
import fs from 'fs';
 
const CONTRACT_ADDRESS = 'ct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb';
const node = new Node('https://mainnet.aeternity.io');
const account = new MemoryAccount(process.env.AE_PRIVATE_KEY);
 
const aeSdk = new AeSdk({
  nodes: [{ name: 'mainnet', instance: node }],
  accounts: [account],
});
 
const aci = JSON.parse(fs.readFileSync('./contracts/Tipping_v3.aci.json', 'utf8'));
const contract = await Contract.initialize({
  aci,
  address: CONTRACT_ADDRESS,
  onAccount: aeSdk,
  onNode: aeSdk.api,
});
 
const result = await contract.post_without_tip('Hello from the SDK', []);
console.log(result.decodedResult.toString(), result.hash);

The ACI ships with the agent skill at contracts/Tipping_v3.aci.json.

Cost

A post is a single stateful contract call with no payable amount, so you pay gas only. Gas follows the network's gas price and the size of the stored text.

Permanence

A published post is permanent and public. It lives on chain, where no one, Superhero, a community DAO or a moderator included, can alter or remove it. Community moderation manages chat-room access; see Moderation.

Related