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:
| Field | Type | Meaning |
|---|---|---|
tip_meta.sender | address | The author: the account whose signature authorised the post |
tip_meta.title | string | The post's text content |
tip_meta.timestamp | int | Chain.timestamp at the moment of posting |
media | list(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:
| Entrypoint | Signature | Stateful | Purpose |
|---|---|---|---|
init | () => Tipping.state | no | Deployment |
post_without_tip | (title: string, media: list(string)) => int | yes | Direct posting; Call.caller is the author |
post_without_tip_sig | (title: string, media: list(string), author: address, sig: signature) => int | yes | Delegated posting on behalf of a signer |
get_state | () => Tipping.state | no | Read 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
| Network | Tipping v3 contract |
|---|---|
| Mainnet | ct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb |
| Testnet | ct_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:
GET /api/posts/with the key exactly as given. This handles slugs.- If the key is numeric or already ends in
_v3, retry with_v3appended. - Fall back to
GET /api/posts?search=…&limit=1&page=1and fetch by the returnedid.
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.
| Convention | Written as | Meaning |
|---|---|---|
| Account mention | [account:ak_…] | A tagged account. The composer shows a short display run and expands it to this macro on submit. |
| Token tag | #SYMBOL | A 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 marker | comment:<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:
| Limit | Value | Enforced by |
|---|---|---|
| Character count, composer | 280 | The web composer, for posts and comments alike |
| Character count, indexed copy | 5,000 | The indexer; longer content is truncated, not rejected |
| Character count, on chain | Unbounded | The contract stores whatever string you pass |
| Media entries, indexed | 5 | The indexer keeps the first five valid entries and drops the rest |
| Media entries, on chain | Unbounded | The contract stores the whole list |
| Topics per post | 10 | The indexer, each at most 50 characters after normalization |
| Editing | Not possible | The contract has no update entrypoint |
| Deletion | Not possible | The 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:
- Expands tagged mentions from their display form to the
[account:…]storage form. - Builds the
medialist: attached URLs for a post;comment:plus the parent id first for a reply. - Initialises the contract with the vendored ACI and
CONTRACT_V3_ADDRESS. - Calls
post_without_tip(content, media)through the connected wallet. - Suffixes the returned id with
_v3, optimistically inserts the post into the feed cache, and reconciles againstGET /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
- Delegated & Gasless Posting: the signature scheme behind
post_without_tip_sig, for relayers. - Comments: the same call with a
comment:marker inmedia. - Hashtags & Topics: what a
#tagin your text becomes. - The Tipping contract: the contract reference.
- Posting with an agent: the same operations from an AI agent runtime.