Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Building communities for public and underground societies.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Hup is a social app where any post can be a paid download. The buyer's payment is held in escrow and
released only when the seller publishes the decryption key — or refunded to the buyer if they never
do. Posts, comments, reposts, likes and the follow graph are written to smart contracts on nine EVM
chains; content bodies live on IPFS; an offchain indexer turns the event log into fast feeds.
Live at https://hup.social.
Indexing is a separate service (cidex) that owns all event scanning and writes the tables this
app reads. Nothing in here scans a chain log.README 전문 보기 ↗Hup is a social app where any post can be a paid download. The buyer's payment is held in escrow and released only when the seller publishes the decryption key — or refunded to the buyer if they never do. Posts, comments, reposts, likes and the follow graph are written to smart contracts on nine EVM chains; content bodies live on IPFS; an offchain indexer turns the event log into fast feeds.
Live at https://hup.social.
pnpm install
cp .env.example .env # then fill it in
pnpm dev:ssl # https://localhost:3000, self-signed cert
Indexing is a separate service (cidex) that owns all event scanning and writes the tables this app reads. Nothing in here scans a chain log.
| Path | What's there |
|---|---|
src/app | Routes. api/v1 is the app's own API. |
src/components | Shared UI. Modals go through ui/NativeDialog, menus through ui/NativePopover. |
src/config | Chains, per-chain contract addresses, gasless policy, sections. |
src/contracts | Solidity sources and the Foundry workspace. |
src/lib | Chain access, IPFS, storage, formatting. |
AGENTS.md holds the conventions this repo is held to — read it before writing code.
Everything below is the running design record for the protocol and its extensions, kept in one file on purpose so each decision sits next to the reasoning behind it.
By treating all social interactions as a single Content primitive, Hup operates through a single, highly efficient smart contract.
Every interaction on Hup is a "Content" object:
postChildren logic.By moving the actual text and media to IPFS and only keeping the Content struct onchain, this single contract can handle millions of interactions across any EVM chain without hitting state bloat limits.
The only reason to keep them split would be if your code exceeds the 24KB limit. However, since you are now using one struct and one set of logic to handle all three types, your code will actually be shorter than when you had two separate systems. You should have plenty of room.
Verdict: merged. One contract, one protocol, any EVM — easier to deploy across chains than two separate systems.
Additional data layers are decoupled and built as standalone contracts, attaching data to posts as the post's creator rather than the contract owner — the pattern usually called Pluggable Storage or an Extension Registry.
How it works architecturally Your new contract will simply use an interface to talk to your deployed Hup contract. When someone tries to attach data to Post #42, the extension contract performs an external call to Hup.allContent(42) to verify who the actual creator is. If the msg.sender matches the creator address returned by Hup, it allows the write operation.
The drops engine does not know how to deploy a collection. It holds a registry of deployer satellites, one per token standard, and calls whichever the creator asked for:
mapping(uint256 => address) public deployers; // standardId => satellite
| id | Standard | Shape | Chains |
|---|---|---|---|
| 1 | ERC721 | numbered — unique sequential ids | every EVM chain |
| 2 | ERC1155 | editions — copies of one artwork | every EVM chain |
| 3 | LSP7 | editions | LUKSO only |
| 4 | LSP8 | numbered | LUKSO only |
These are Hup's own registry numbers, not anything from the ERC or LSP specs. They are
append-only: every drop ever created stores its standardId, and indexers key on it, so a
number is never reused or renumbered. A future standard claims id 5.
Defined in src/lib/drops.js (DROP_STANDARDS) and mirrored in src/config/contracts.js.
Hup is readable and writable by AI agents without a browser. public/hup-skill.md (served at /hup-skill.md) is the self-contained agent guide: the read endpoints, the post document, pinning, the gasless relay signature, follow, the profile declaration, and conduct. mcp/ is its reference implementation as an MCP server (hup-mcp on npm): read tools that need no wallet, and write tools that act as the wallet in HUP_AGENT_PRIVATE_KEY. The read tools are also served remotely at /api/mcp (Streamable HTTP, stateless) by src/app/api/mcp/route.js, which imports them straight from mcp/src.
Two reasons, both structural:
setDeployer(id, satellite) is one admin
transaction. That matters more than it sounds: a collection stores its engine as
address public immutable drops, so redeploying the engine strands every collection ever
minted through it — no new phases, no new mints, ever. Anything that can be done without
touching the engine, should be.A standard with no registered satellite is simply unavailable: createDrop reverts
InvalidStandard, and the composer reads deployers(standardId) first so the creator sees
"not enabled on this network yet" instead of a failed transaction. So a chain can launch with
one standard and gain the rest later.
(hup, trustedForwarder, admin, followerSystem).setDeployer(id, satellite) per standard. LUKSO registers 3 and 4; other EVM chains 1 and 2.setCommunitySystem — it is not a constructor argument, and Community-gated phases fail
closed until it is set. setFollowerSystem too if the chain has LSP26 and the constructor
was passed address(0).drops in src/config/contracts.js, then register the address in cidex.Order matters: the satellites take the engine's address, so the engine must land first and be confirmed before any satellite is deployed.
if (metadata.startsWith('IPFS://')) route through your public IPFS gateway provider.
else render as a plain text string instantly.
Hup contract post IDs are local to each deployed contract. If a new Hup contract is deployed on the same network, its internal contentCount starts again from 1.
To keep public post URLs stable and clean, the app uses an off-chain public post ID system.
Example public URL:
/networks/42/45
The 45 in the URL is treated as a public/global post ID by the backend, not necessarily the raw contract contentCount ID.
The contract intentionally does not support a custom starting content ID. Keeping ID versioning off-chain avoids extra storage reads/checks and keeps gas costs lower for common actions like:
It also prevents the new contract from exposing “phantom” IDs for posts that only exist in an older contract.
The backend/indexer stores each Hup deployment as a range:
const hupDeployments = [
{
networkId: 42,
address: '0xOldHupContract',
startPublicId: 1,
endPublicId: 45,
offset: 0,
},
{
networkId: 42,
address: '0xNewHupContract',
startPublicId: 46,
endPublicId: null,
offset: 45,
},
]
When a request comes in for:
/networks/42/48
the backend finds the deployment range that contains public post ID 48.
Then it converts the public ID into the contract-local ID:
contractPostId = publicPostId - deployment.offset
Example:
publicPostId = 48
deployment.offset = 45
contractPostId = 3
So the backend reads:
0xNewHupContract.getContent(3)
function resolvePostDeployment(deployments, networkId, publicPostId) {
return deployments.find((deployment) => {
if (deployment.networkId !== networkId) return false
const startsInRange = publicPostId >= deployment.startPublicId
const endsInRange =
deployment.endPublicId === null || publicPostId <= deployment.endPublicId
return startsInRange && endsInRange
})
}
function resolveContractPost(deployments, networkId, publicPostId) {
const deployment = resolvePostDeployment(deployments, networkId, publicPostId)
if (!deployment) {
return null
}
return {
contractAddress: deployment.address,
contractPostId: publicPostId - deployment.offset,
}
}
endPublicId when replacing a contract.endPublicId: null.Hup keeps the core social protocol on-chain while leaving some user-experience and indexing features off-chain. This keeps gas costs lower, avoids unnecessary public storage, and gives the app more flexibility.
The core contract stores protocol-level actions that should be publicly verifiable:
These actions are emitted as events and indexed by the backend.
The following features are intentionally handled by the backend/indexer instead of the core contract:
communityId is stored in metadataBookmarks are treated as a private user preference, not protocol state.
Keeping bookmarks off-chain avoids:
Example table:
bookmarks
- id
- user_address
- network_id
- public_post_id
- created_at
Post views are high-frequency and easy to manipulate, so storing them on-chain would be expensive and not very meaningful as trustless protocol state.
Views are counted by the backend/API and can be rate-limited, filtered, or deduplicated off-chain.
Example table:
post_views
- id
- user_address nullable
- ip_hash nullable
- network_id
- public_post_id
- created_at
Each Hup contract deployment has its own local contentCount. If a contract is upgraded or redeployed, IDs may restart from 1.
To keep URLs stable, the backend owns public/global post IDs and maps them to the correct contract deployment.
Example:
/networks/42/45
The 45 is a public post ID. The backend resolves it to:
network_id + contract_address + contract_post_id
The contract stores mappings for cheap checks like:
has this user liked this post?
But mappings are not enumerable, so full lists are built from events by the indexer.
Examples:
If a feature must be publicly verifiable and affects protocol state, it belongs on-chain.
If a feature is private, high-frequency, expensive to store, or mainly needed for UI/querying, it belongs off-chain.
If the burner is later exfiltrated from the browser, the attacker has an indefinite write window over that wallet's posts/likes/reposts (though not over funds — sessions cannot move ETH).
https://gist.github.com/emmet-bot/77f06e48e7c2aaf92aac20c73d61f299 https://gist.github.com/emmet-bot/a40ac3fc6a5f0f5b44c73ac6720581f2
const postContent = {
version: '1',
elements: [
{
type: 'text',
data: {
text: 'Post text here',
},
},
{
type: 'media',
data: {
items: [
{
type: 'image',
cid: 'Qm1234...image-cid',
alt: 'Image description',
storage: 'IPFS',
mimeType: 'image/jpeg',
spoiler: false,
},
{
type: 'video',
cid: 'Qm5678...video-cid',
alt: 'Video description',
storage: 'IPFS',
mimeType: 'video/mp4',
duration: 45,
spoiler: false,
},
],
},
},
],
}
Gated community posts are end-to-end encrypted. Neither the server, the indexer, nor the chain can read them — only current members.
Posts are locked with one shared master key per community; that master key travels between members as chat-style sealed envelopes stored on-chain instead of sent.
src/lib/securityVault.js → src/lib/communityVault.js). Same wallet + same PIN reproduces it on any device. This exists only so the master key can be delivered to them — wallets can sign but not decrypt.HupCommunity.wrappedKeys[communityId][member][version]. Visible to everyone, openable only by that member. This is exactly Chat's encrypt-to-pubkey operation, reused — the "message" is the master key, the "inbox" is the contract.createCommunity(...) stores community + envelope + keyVersion = 1 in a single tx.join() (sets isPending, no event) and the app records the request off-chain so moderators can discover it. Bob's vault unlock registers his pubkey on-chain — required before he can receive a key.approveRequest tx, then automatically in the browser — unwrap own envelope → re-wrap the same master key to Bob's pubkey → grantKey tx. Join is O(1): one envelope. Bob can now also read the history back to the last rotation (those posts used this same key) — but nothing before it: grantKey only ever grants the current version, so pre-rotation epochs stay locked to newcomers permanently.{encrypted: true, keyVersion, iv, ciphertext, communityId} to IPFS → ordinary Hup.create(cid).keyVersion encrypted it. Fetch your envelope for that version, unwrap, decrypt. No envelope (0x) → 🔒 locked placeholder.bumpKeyVersion, generates a fresh master key, and immediately re-grants only the moderators (one grantKeyBatch tx). Security is achieved at that instant — new posts use a key the departed member will never receive. Every other member picks the key up on demand: their client files a grant key request when it notices the missing envelope, and a moderator batch-clears the queue (grantKeyBatch, up to 100 per tx) from the members panel. Rotation cost is O(moderators) up front, then proportional to active members over time — dead accounts never cost anything. Voluntary leave can't rotate by itself (bumpKeyVersion is moderator-only), so it files a rotation request that moderators see as a pending banner. Envelopes are append-only: the departed member keeps opening old-version posts they legitimately had, but never receives the new version.By default every rotation is an epoch wall: new members receive only the current key, so posts from before the last rotation stay locked for them. Communities whose value is the archive can flip this: a moderator toggles "New members can read history", and from then on each rotation also publishes a keyBacklink — the retiring key encrypted under the new one. Holding the current key plus an unbroken chain of backlinks yields every older key, but never a newer one, so a departed member still can't read anything after their removal. The links are write-once and can be backfilled retroactively (moderators hold envelopes for all versions), and toggling the policy off only affects future rotations — links already published are on-chain forever, and any member could have cached old keys anyway.
A community's access model has three independent axes, which the retired MembershipType enum used to conflate:
IHupEligibilityModule contract for logic the list can't express. Checked inside join() for Self-serve mode and re-checked live in canPost() for everyone, so selling the gating asset suspends posting. "Invite-only but must hold 100 tokens" is just configuration.keyVersion > 0 onchain, chosen at creation (atomic key init) or enabled later with one initializeKey tx. The composer's rule is exactly this flag.The old constraint — "only approval-based types can be encrypted" — is solved by key-delivery timing instead of by forbidding combinations: approval/invite admissions hand the envelope over at the moment a moderator acts on the member; self-admit admissions (Open/Self-serve/Pay) admit instantly and the member's client files a key request that a moderator batch-delivers from the lazy grant queue. The honest cost is that new members of self-admit encrypted communities can't read until a moderator is next online — the UI says so at creation time.
E2EE in a huge community is security theater regardless of scheme — any one of 500k members can leak everything, and "request-based" at that scale is functionally public. Encryption genuinely protects small/medium groups. Rotation cost reflects this too: removals are inherently O(n) across all group-encryption designs (every remaining member needs fresh secret material the leaver can't compute) — batching and lazy on-demand re-grants compress the cost, but very large communities belong in the plaintext gated types.
An honest grading of the encryption stack, so future maintainers know what's a deliberate choice versus a TODO:
eciesjs) — sound; secp256k1 chosen over the academically nicer X25519 because it's the wallet ecosystem's native curve.cryptoHelper.js, but it re-keys vaults — same coordination cost as a PIN change.In practice the weakest link is none of the above: it's a member leaking plaintext, which no cipher fixes.
Member rosters are hidden from non-moderators: getMembers/memberCount (and the whitelist getters) are moderator-gated on the contract, the cidex-backed members API only serves the moderator list, and the Members panel in the UI is moderator-only. This is best-effort hiding, not cryptographic privacy — view-function gating can be bypassed by spoofing from in eth_call, contract storage is publicly readable, and every member's join() transaction and MemberStatusUpdated event is on-chain forever. It raises the bar from "one click" to "deliberate chain analysis." Genuinely hidden membership (ZK commitments in a Merkle tree, Semaphore-style, with relayed joins) is planned as a separate contract; per-address checks (registry, canPost) stay public because content gating and indexing depend on them.
Membership is a public, indexed, permanent signal (MemberStatusUpdated events live in the chain log forever), so being listed on a roster must be the wallet owner's own choice — otherwise a moderator could conscript any address into any community, including ones whose association is reputationally damaging.
The contract makes consent structural, not procedural:
inviteMember grants nothing. It only records an invite and emits MemberInvited. Membership happens exclusively when the invitee calls acceptInvite themselves (declineInvite and the moderator's cancelInvite clean up unaccepted invites). Bans and archived status are re-checked at accept time, since either may have changed while the invite sat open.approveRequest requires an actual request. It reverts with NoPendingRequest unless the wallet itself filed a join request (join() set isPending) — without this guard, approving arbitrary addresses would have been a consentless addMember by another name. The old addMember is removed entirely.The remaining self-service paths are unchanged because consent is inherent to them: join() for Public/Whitelisted/Pay-to-Join, and request-then-approve for the gated types.
Community authority in HupCommunity is just addresses — onlyCreator and moderator flags. DAO mode exploits that: nothing requires the creator to be a human wallet.
setGovernor(id, executor), pointing the community at a governance contract (an OpenZeppelin Governor + Timelock, a Safe, or any executor). The governor passes every onlyCreator and onlyModerator gate alongside the creator. The creator keeps their powers, including the power to remove the governor — good for trying governance without burning the ships.transferCommunityOwnership → acceptCommunityOwnership flow (the executor calls accept by proposal, mirroring Ownable2Step so a typo'd address can't strand the community). After that, every creator-level action — membership rule changes, archiving, moderator appointments, replacing the governor — requires a passed proposal.setGovernor is callable by the creator or the current governor, so a DAO can replace or renounce itself by proposal. Setting address(0) clears DAO mode. Every change emits CommunityGovernorUpdated for the indexer.
Voting on every ban is unusable — onchain governance is slow by design. The intended shape:
Power then comes from the electorate rather than from being creator, while day-to-day moderation stays usable.
HupCommunity stays agnostic — the governor slot accepts anything that can send a transaction. Which Governor implementation, votes token, quorum, and timelock delay a community uses is its own business, and each chain's deployment is its own governance instance (no cross-chain voting).CommunityGovernorUpdated, CommunityOwnershipTransferStarted, and CommunityOwnershipTransferred are cidex's to index (the app reads governors(id) live from chain in the meantime — pre-governor deployments simply revert the read, which the UI treats as "no governor"). A governor column on the indexed communities table unlocks a directory-level "DAO" filter later.
This project strictly adheres to Semantic Versioning (SemVer) via the MAJOR.MINOR.PATCH format to ensure predictable deployments and reliable cross-chain indexing.
A microbounty is three things: escrow, a submission window, and a payout decision. "Design a logo, $500 to the winner." The obvious way to build it is a governance pipeline — a proposal contract, quorum rules, and an executor that releases the funds once a vote passes. That executor is the part that would have to be trusted, and it is the part Hup does not build.
HupCommunity already resolves a community's money to a single address. setPayoutDestination(id, destination) points join fees at a wallet, a Safe, a DAO treasury, or a splitter contract, and join() pushes the fee straight there in the same transaction — nothing rests in the contract, there is no ledger to reconcile, and there is no withdraw call for anyone to authorize. Rules richer than "one address" (a co-founder split, a quorum, a vesting schedule) live inside the destination, not inside Hup. Under DAO mode the setter is reachable by the governor as well, since onlyCreator admits governors[id] alongside the creator.
A bounty funds from that same address, and that single fact removes the need for the pipeline:
create transaction. A Safe destination makes it literally the second signature. A Governor destination makes it a passed proposal executing a call. A single wallet makes it one person deciding — an honest reflection of what that community actually is.The funding transaction is the execution of the vote. Nothing stands between the poll result and the money, so nothing there can be captured, bribed, or bugged. Hup operates no executor, no keeper, and no privileged role that can move a community's funds.
Escrow is not governance, so it stays small and unopinionated:
create(token, amount, deadline, kind) — the funder deposits once. Native plus ERC20/LSP7, because a "$500 bounty" is a stablecoin, not gas coin.submit(bountyId, postId) — entries are ordinary posts. Post ids are per-network, so entries key on network_id:id.award(bountyId, winners[], splits[]) — paid with .call{value: ...}("") so Universal Profiles with LSP1 delegates receive normally.refund() after the deadline when nothing was awarded. Non-negotiable: escrow without a timeout is a hostage, and a community that funds a bounty nobody wins has to be able to recover it.One contract covers the variants. A JokeRace-style contest, a call for peer review, and a karmagap-style project application differ only in kind and in what the submission points at — a post, an article, a project. Forking the contract per use case would multiply the audit surface for no new mechanism.
Bounty and entry events are cidex's, per the standing rule that the app reads the database and never scans chain itself: a runBountySync runner filling bounties and bounty_entries, surfaced at /jobs — today that route renders the leaderboard, which becomes one tab of it.
The protocol is currently open and free. If monetization is introduced, the preferred approach is a subscription NFT — not an on-chain license key check.
A separate HupSubscription ERC-721 (or ERC-1155) contract is deployed independently of the core protocol. Users mint a subscription token with an expiry timestamp encoded in the token metadata or a contract mapping.
The relayer/meta-transaction forwarder checks subscription validity before forwarding a transaction. The core contracts stay unchanged — no license gate added to any function.
User → Relayer → [check: does this wallet hold a valid subscription token?] → Hup contracts
If the user has no valid subscription, the relayer rejects the request off-chain. Gas cost: zero.
licenseOf[address] check to every call would add gas to every transaction and break permissionlessnessDirect contract interaction bypasses the relayer entirely, so technically the protocol always remains open. Subscription would gate the app experience (meta-tx relay, IPFS pinning, push notifications, etc.), not the raw protocol.
HupPredict escrows real stakes, so its trust posture is stricter than the other Hup extensions:
Escrow is not withdrawable — by anyone. The contract has no withdrawAll. The admin can only sweep accruedFees, a ledger that grows exclusively from the platform fee at resolution. Market creators earn a separate creator fee into creatorFees, a per-creator pull-based ledger only they can claim (claimCreatorFees). Bettors' pools never enter either ledger, and the combined fee rate (platform + creator) is hard-capped at 10% and snapshotted per market at creation.
The exits are pause-immune. claim(), claimCreatorFees(), enableRefunds(), and renounceJudge() carry no whenNotPaused. Whatever happens — paused contract, vanished judges, compromised admin — once the resolve window lapses, anyone can flip an unresolved market to Refunding and every bettor reclaims their full stake, fee-free. No sequence of admin actions can strand escrowed money.
Market-control actions require direct signatures. resolve, confirmJudging, closeBetting, cancelMarket, and renounceJudge read raw msg.sender — burner sessions and ERC2771 meta-transactions are deliberately not honored. A compromised session source or trusted forwarder therefore cannot fake a verdict, consent, closure, or cancellation. Only the convenience paths (createMarket, placeBet, claim) support sessions, and each can only move funds to or from the rightful owner.
Judges must consent onchain. Being named a judge attaches a name but no power; only calling confirmJudging — with the judge's own key — grants the ability to act. Judges can step down at any time, and the last judge leaving a funded market opens refunds immediately.
Residual trust assumptions: the ADMIN_ROLE holder can pause new activity, change fees for future markets, adjust the resolve window within 1–90 days, and moderate market visibility. Holding that role with a multisig or timelock is the recommended posture.
Every other Hup extension accepts ERC2771 meta-transactions and resolves burner session keys to their primary wallet. HupOffers accepts neither. Its constructor takes one argument — the admin — with no Hup Core reference and no trusted forwarder, and there is no setTrustedForwarder to add one later. Every action is credited to msg.sender and nothing else. This is a deliberate departure, not an omission.
Relaying would buy nothing. Gasless exists so someone can act without holding the chain's coin. But both sides of an offer must already control value: the offerer's escrow is pulled from the caller (native msg.value, or an ERC20/LSP7 allowance the caller granted), and the seller's asset leaves the caller's own holdings. There is no useful action a third party could perform on someone's behalf, because every action spends something only the owner has.
Relaying would cost a great deal. An ERC2771 forwarder is, by construction, an address permitted to name any sender — _msgSender() reads the last 20 bytes of calldata on its word alone. This contract's entire job is spending standing approvals. Combine the two and a forwarder can forge makeOffer as a victim up to whatever allowance they granted, then fill it with a worthless asset; or forge acceptOffer as a victim against a lowball offer and take an approved NFT. Neither needs the victim's key. A rotatable forwarder makes this the admin's power too, since whitelisting one is an admin call — which would have quietly undone the reason HupPredict reads raw msg.sender for its market-control paths.
Approvals here are collection-wide, which widens what an impersonation would reach. Sellers grant ERC721 rights with setApprovalForAll rather than a per-token approve, because the per-token approval is exclusive and would silently revoke the one HupTrade holds for a live listing. That is the right trade — but it means one forged acceptOffer would reach an entire collection, not one token. Removing the forge is better than narrowing it.
What an admin can still do. Pause new offers and fills, move the fee within its hard 10% cap (snapshotted per offer at creation, so pending offers settle at the rate they were made under), whitelist ERC677 payment tokens, and withdraw accruedFees. It can never move a user's tokens, and it can never touch escrow: there is no whole-balance sweep, and cancelOffer carries no whenNotPaused, so an exit from escrow is never blockable.
The one place the contract credits anyone but its caller is ERC677's onTokenTransfer, which attributes the offer to the _sender the token reports. That is safe for the opposite reason: the caller there is the whitelisted token, and the whitelist is the only proof a real transfer preceded the callback. It is also the closest thing to a gasless convenience the contract keeps — whitelisted tokens (G$ on Celo, for instance) fund or fill an offer in a single transferAndCall with no prior approve.
The cost is real and worth naming: every offer action is a transaction the user pays for, including Universal Profile holders, who get the session-key skip when tipping or playing the miner game. Offers ask them to sign each action themselves. For a contract whose whole purpose is holding other people's money against other people's approvals, that is the correct side to err on.
Installed on Android, Hup appears in the OS share sheet: share a photo from the gallery or a link from any browser, pick Hup, and the composer opens with it already attached.
The manifest declares a POST/multipart-form-data share target pointing at /api/share, but that endpoint is the fallback, not the main path. public/sw.js intercepts the POST before it leaves the device:
share sheet → POST /api/share → [service worker] → parks payload in Cache Storage → 303 → /share → composer
Two reasons the worker owns this:
The worker writes one JSON envelope (/__share-payload) plus one entry per file (/__share-file-N) into a hup-share-v1 cache, listed in CURRENT_CACHES so activate does not sweep it. src/lib/shareTarget.js drains and deletes that cache in a single move — a refresh of /share must not re-attach media the author already dismissed. The cache name and keys are declared in both files because sw.js is served raw and cannot import from src/; changing one means changing the other.
src/app/api/share/route.js only ever runs when no worker controls the client — the first launch after install, or service workers disabled. It cannot keep files, so it preserves the text half in the query string and redirects with media=dropped, which /share surfaces as a toast rather than silently losing attachments.
Shared files enter through the same ingestFiles() used by the composer's own file picker (src/components/NewPost.jsx), so size limits, the 8-item cap, dimension probing, and IPFS upload behave identically. The picker pins ingestion to the media type it asked for; a share passes null and each file is classified by its own MIME type instead.
iOS has no Web Share Target support — this is Android/Chromium and desktop Chrome only. Accepted types are image/*, video/*, and audio/*.