plebbit-js icon indicating copy to clipboard operation
plebbit-js copied to clipboard

implement tipping

Open estebanabaroa opened this issue 2 years ago • 10 comments

The first step of tipping would be some EVM/solidity smart contract with an API similar to:

tip(commentCid, address, amount) getTips(commentCid)

though with this API I'm not sure how we could link comments with tips

and there would be some sybil resistance tax like 20% that would go to the plebbit DAO treasury

we would only use the native token (eth) for the first test version for simplicity, and add the pleb token later

estebanabaroa avatar Dec 14 '23 03:12 estebanabaroa

I think the tip comments should be stored in a separate table than comment, and when we generate a CommentUpdate we include all the tips of that comment.

The tipper would send the tip as a tx first, then call tip(commentCid, txId, msg). A tx id can only be used once, so a tipper can't use it multiple times.

Rinse12 avatar Jan 03 '24 10:01 Rinse12

I think the tip comments should be stored in a separate table than comment, and when we generate a CommentUpdate we include all the tips of that comment.

I hadnt thought about the sub owner actually providing the tips, wouldnt it be more decentralized for the tips to be stored and fetched on chain, via the getTips() function?

tip(commentCid, address, amount)
getTips(commentCid)

this would actually be the smart contract API, not a plebbit-js api. havent thought of what the plebbit-js API would be yet

what are the pros and cons of doing it fully on chain?

pros

  1. more decentralized, sub owner doesn't need to be online, and can't censor tips
  2. more decentralized, sub owner can't censor tips

cons

  1. higher gas fee because we need to store a mapping of commentCid => tip[] on chain
  2. it's possible an RPC could censor our contract getTips function, and nobody using plebbit will run their own ETH or Arbitrum full node

I'm not sure how we would attach a tip to a comment, possibly by doing tip(recipientCommentCid, senderCommentCid, ethAddress, amount) but then this uses even more gas cause the senderCommentCid must be part of tip type, so it uses more storage, so more gas. We could potentially truncate the commentCid to only be 16bits or something to take less space. it'd be less secure, but spoofing a tip wouldnt be the end of the world I think

estebanabaroa avatar Jan 03 '24 19:01 estebanabaroa

The sub owner can remove the comment from showing on users' frontend, so I don't think there is a point to having it on-chain. I think users should be able to send any ERC-20 token or NFT to author.wallets.eth.address, and then create a ChallengeRequest with the type Tip: {commentCid: string; txId: string; tipMessage: string; proof: string} which the sub owner will store and include in `CommentUpdate.

This way the sub owner will not be able to intercept the tipping, but they can definitely reject or hide the tipping message from showing on frontends if they wanted to. Which I don't think is a big deal, if the tip is received, it's good enough imo. Also this way we won't have to develop a smart contract and worry about its security, gas, etc

Rinse12 avatar Jan 04 '24 05:01 Rinse12

actually in hindsight the tipping design is more complex than I thought. I think we need to think about it from UX first principle first, then design the API around it.

what is the best UX (user convenience/tx fee/censorship resistance of sending or reading/on chain composability/sybil resistance) for each use case, in order of priority:

  1. sending a tip, including a text comment
  2. reading total tips received on a post/reply
  3. reading each tip received on a post/reply, including sender data and text comment, sorting them by date or tip values
  4. sorting post/replies by most tip received. maybe also sorting combination of votes/tips
  5. ???

you can try writing down a list of possible designs and how they rank in terms of UX for each of these 4, so we can compare them. I will also try to do it myself in the next few days when I have time

estebanabaroa avatar Mar 07 '24 16:03 estebanabaroa

tipping design 1: fully on chain, no tip messages

struct Tip {
  int amount
}
mapping (recipientCid => Tip[]) tips
getTips(recipientCid): Tip[]
getTipsTotalAmount(recipientCid): int
tip(recipientCid, recipientAddress, tipAmount): void

pros:

  • fully on chain, sub owner cannot censor tips
  • sending tip is a single step
  • no extra code in plebbit-js/plebbit protocol

cons:

  • no tip messages
  • requires chain rpc calls to view tips (can be fixed by letting sub owner optionally add commentUpdate.tipsReceivedTotalAmount)
  • storing recipientCid costs extra gas

tipping design 2: fully on chain, tip messages are plebbit reply cids

struct Tip {
  int amount
  string senderCid
}
mapping (recipientCid => Tip[]) tips
function getTips(recipientCid): Tip[]
function getTipsTotalAmount(recipientCid): int
function tip(recipientCid, recipientAddress, tipAmount): void
function tipWithMessage(senderCid, recipientCid, recipientAddress, tipAmount): void

pros:

  • fully on chain, sub owner cannot censor tips (but can censor tip messages)
  • has optional tip messages
  • sending tip without message is a single step
  • no extra code in plebbit-js/plebbit protocol
  • can add tip to a reply already written

cons:

  • sending tip with message is 2 step, but the UI can automatically fill senderCid, making it a single click to tip
  • requires chain rpc calls to view tips (can be fixed by letting sub owner optionally add commentUpdate.tipsReceivedTotalAmount and commentUpdate.tipSentAmount)
  • cannot add message after tip is sent
  • storing recipientCid and senderCid costs extra gas
  • fetching and sorting thousands of tips will take several seconds depending on rpc speed, could get restricted by rpc (can be fixed by letting sub owner optionally add commentUpdate.tipsReceivedTotalAmount and commentUpdate.tipSentAmount)

tipping design 3: partly on chain, partly plebbit protocol, tip messages are plebbit replies

smart contract

tip(recipientAddress, tipAmount): void

plebbit protocol

createCommentOptions.tipTxHash?: string
createCommentOptions.tipChainTicker?: string
commentUpdate.tipSentAmount?: number // the amount the author sent
commentUpdate.tipsReceivedTotalAmount: number // the total amount the author received
commentUpdate.tipsReceivedCount: number // how many tips the author received

pros:

  • does not require chain rpc calls to view tips
  • has tip messages
  • very gas efficient, does not store any data
  • can add message after tip is sent
  • fetching and sorting thousands of tips is fast

cons:

  • sub owner can fabricate fake tips, no way for reader client to validate
  • not fully on chain, sub owner can censor tips
  • sending tip is 4 steps, 1. tip chain tx, 2. wait for confirmation, 3. publish plebbit reply, 4 wait for challenge verification
  • sub owner requires chain rpc calls to add tips to comment updates
  • extra code/complexity in plebbit-js/plebbit protocol
  • cannot add tip to a reply already written

tipping design 4: partly on chain, partly plebbit protocol, tip messages are optional plebbit replies

explanation: use tip(recipientCid, recipientAddress, tipAmount) to store the tip on chain without a message, like design 1. use tipWithMessage(recipientAddress, tipAmount) to store the tip with the sub owner, with a message, like design 3.

smart contract

struct Tip {
  int amount
}
mapping (recipientCid => Tip[]) tips
getTips(recipientCid): Tip[]
getTipsTotalAmount(recipientCid): int
tip(recipientCid, recipientAddress, tipAmount): void
tipWithMessage(recipientAddress, tipAmount): void

plebbit protocol

createCommentOptions.tipTxHash?: string
createCommentOptions.tipChainTicker?: string
commentUpdate.tipSentAmount?: number // the amount the author sent
commentUpdate.tipsReceivedTotalAmount: number // the total amount the author received
commentUpdate.tipsReceivedCount: number // how many tips the author received

pros:

  • does not require chain rpc calls to view tips
  • has optional tip messages
  • very gas efficient for tips with messages, does not store any data
  • can add message after tip is sent
  • sending tip wihout message is a single step
  • fetching and sorting thousands of tips is fast

cons:

  • sub owner can fabricate fake tips, no way for reader client to validate
  • not fully on chain, sub owner can censor tips
  • sending tip with message is 4 steps, 1. tip chain tx, 2. wait for confirmation, 3. publish plebbit reply, 4 wait for challenge verification
  • sub owner requires chain rpc calls to add tips to comment updates
  • extra code/complexity in plebbit-js/plebbit protocol
  • cannot add tip to a reply already written

initial thoughts

It seems to me like design 3 and 4 cannot be used at all, because it forces the sub owner to run an rpc for all chains, even obscure ones, which is simply not possible. Eventually we would ideally support 50+ chains for tipping.

The biggest benefit to design 3 and 4 imo are the gas savings, but since most people will tip on low fee chains, it probably won't matter in practice, design 1 and 2 are not that storage intensive, only a few bytes since we don't have to store the full cid, we can store only enough bytes to prevent a bruteforce collision (like short cids).

Another benefit is that there's no need for users to do rpc calls, but we can also have the sub owner optionally add commentUpdate.tipSentAmount/tipsReceivedTotalAmount/tipsReceivedCount if they are willing to run an rpc for some chains. This doesn't force sub owners to run an rpc.

Some big (but not breaking) downsides of design 3 and 4 are that sub owners can fabricate tips and there's no way for reader clients to validate them and that sending a tip is extremely annoying process, you need to do the blockchain tx first, wait for it to confirm, then do the plebbit publication, and also wait for it to confirm. It seems to me like most people will probably write a comment first, then tip, or simply tip without comment, and the UX for that is much quicker and less annoying with design 1 and 2.

With design 2, the UX can be pretty simple, we have a "tip" button, the user clicks it, a modal opens for the amount, the user types amount, and clicks confirm. The senderCid is automatically filled for him under the hood if he has a reply for the comment he is tipping. Also the tip could be added while writing the reply, it could be an optional text box. the tipWithMessage function would trigger automatically under the hood after the challenge verification is received.

And for displaying tips, we just have a useCommentTips() hook and useCommentTipsTotalAmount() that calls getTips and getTipsTotalAmount. No code gets added to plebbit-js.

estebanabaroa avatar Apr 14 '24 20:04 estebanabaroa

To finalize the design we need to add a way for subplebbits to receive a tip tax. As well as possibly for the plebbit DAO, the client the user is using and the public rpc the user is using to receive a tip tax.

Also we need to finalize how to fetch a limited array of tips (ie startIndex, endIndex), as well as how to optionally sort them, probably by value.

estebanabaroa avatar May 23 '24 17:05 estebanabaroa

Updated design including the subplebbit tax and necessary UI components.

UI component 1: Send tip button

  • a send tip button, when you click on it, it opens up a reply box, but with 3 extra fields: assets/balance, amount, and gas fee info. If the balance is 0, I guess we would have a link to go to the deposit page or wallet main page (like the metamask or phantom main page)
function tip(
  address recipient,
  uint256 amount,
  address feeRecipient,
  bytes32 senderCommentCid, // 0x0 if tipping without a comment
  bytes32 recipientCommentCid
) external payable
  • recipient is comment.author.wallets[chainTicker].address
  • feeRecipient is subplebbit.tipping[chainTicker].feeRecipientAddress
  • feeRecipientAmount is not a param, it would be set by the smart contract admin, between 1 and 20%, too complex to let the sub owner set it for now
  • recipientCommentCid would be the comment receiving the tip, encoded as bytes, not a Qm multihash
  • senderCommentCid would be the comment sending the tip, ie the message attached to the tip
  • if subplebbit.tipping[chainTicker].feeRecipientAddress does not exist, we can either auto generate one, or use our own address, allowing the user to burn or split the feeRecipient is too complicated for now
  • the plebbit comment would first be published by the author, then once approved, the UI would automatically publish the tip (no need to confirm since the plebbit app is the wallet)

UI component 2: Total tips received

  • a total tips received badge, displayed on each post/reply
function getTipsTotalAmount(
  bytes32 recipientCommentCid,
  address[] calldata feeRecipients
) external view returns (uint256)

function getTipsTotalAmounts( // for comments in different subs
  bytes32[] calldata recipientCommentCids,
  address[][] calldata feeRecipients
) external view returns (uint256[] memory)

function getTipsTotalAmountsSameFeeRecipients( // for comments in same sub
  bytes32[] calldata recipientCommentCids,
  address[] calldata feeRecipients
) external view returns (uint256[] memory)
  • feeRecipients would be subplebbit.tipping[chainTicker].feeRecipientAddress and subplebbit.tipping[chainTicker].previousFeeRecipientAddresses, because we don't want to invalidate previous fee recipient addresses. if a tip was sent without a fee recipeint address, it would not be displayed to prevent spam.
  • possible to batch multiple replies to save RPC time

UI component 3: List of all tips received (with their messages if any)

function getTipsAmounts(
  bytes32 recipientCommentCid,
  address[] calldata feeRecipients,
  uint256 offset,
  uint256 limit
) external view returns (uint256[] memory amounts)

function getTips(
  bytes32 recipientCommentCid,
  address[] calldata feeRecipients,
  uint256 offset,
  uint256 limit
) external view returns (Tip[] memory tips)
  • getTipsTotalAmounts would be called to identify posts / replies with tips in bulk, then a more granular getTips can be called to list all tips on a particular comment
  • offset and limit are for pagination, in case a comment has 100s of tips
  • NOTE: we're not saving the timestamp of the tips without messages to save on gas, but we have to save eth address for validating comment.tipSenderAddress

UI component 4: The tip amount sent with a reply

  • a tip amount sent badge, to display how much tip was sent with a plebbit reply/comment
function getSenderTipsTotalAmount(
  bytes32 senderCommentCid,
  address sender,
  bytes32 recipientCommentCid,
  address[] calldata feeRecipients
) external view returns (uint256)

function getSenderTipsTotalAmounts( // for comments in different subs
  bytes32 senderCommentCid,
  address sender,
  bytes32[] calldata recipientCommentCids,
  address[][] calldata feeRecipients
) external view returns (uint256[] memory)

function getSenderTipsTotalAmountsSameFeeRecipients( // for comments in same sub
  bytes32 senderCommentCid,
  address sender,
  bytes32[] calldata recipientCommentCids,
  address[] calldata feeRecipients
) external view returns (uint256[] memory)
  • senderCommentCid would the comment cid
  • sender would be comment.tipSenderAddress, to prevent other people from tipping on behalf of the author
  • preferable to use getTipsWithComments() to get all tips, and then associate them with the reply cid, but if there are a lot of tips, getSenderTipsTotalAmount could be better

Data storage / internal variables

struct Tip {
  uint96 amount // uint96 to save gas, works with eth but not with erc20
  address feeRecipient // needed for getTips(feeRecipients)
  address sender // needed for getSenderTipsTotalAmount(sender)
  bytes32 senderCommentCid // needed for getSenderTipsTotalAmount(senderCommentCid), 0x0 if no comment
}

// store all tips in arrays, by their recipientCid and feeRecipient
mapping(bytes32 /* keccak256(abi.encodePacked(recipientCid, feeRecipient)) */ => Tip[]) public tips

// accumulators to avoid looping
mapping(bytes32 /* keccak256(abi.encodePacked(recipientCid, feeRecipient)) */ => uint256) public tipsTotalAmounts

// accumulators to avoid looping, abi.encode because 4 params
mapping(bytes32 /* keccak256(abi.encode(senderCommentCid, sender, recipientCid, feeRecipient)) */ => uint256) public senderTipsTotalAmounts

event Tip(
  address indexed sender,
  address indexed recipient,
  uint256 amount,
  address indexed feeRecipient,
  bytes32 recipientCommentCid,
  bytes32 senderCommentCid // 0x0 if no comment
)

uint256 public minimumTipAmount // admin can change, if minimum is too low, could be possible to spam the tip array
uint256 public feePercent // admin can change, between 1 and 20

Some stuff that isn't included for simplicity of V1:

  • tipping with a token
  • letting the user choose between donating to the plebbit DAO, burning, or the subplebbit, always use subplebbit.tipping[chainTicker].feeRecipientAddress, or if undefined, plebbit DAO
  • subplebbit owner cannot set the tax percent. we can't let them set the tax too low, or people can tip themselves, we also can't let them set it too high, or they will rob the entire tip, we also need to be able to test what the best setting is ourselves, so it should be some admin setting in the smart contract.

Potential issues:

  • a lot of gas is wasted by storing the Tip struct on chain, but it provides the simplest design and the most censorship resistance. if it doesn't work in practice we can change the design in V2.
  • we will be calling getTipsTotalAmounts a lot, maybe it uses a lot of RPC credits
  • we will have to call getTipsTotalAmounts async, it might be slow, and could have to displace the UI in some way when it loads

estebanabaroa avatar Jul 11 '25 03:07 estebanabaroa

Does the sub include the tips anywhere within their pages, or does the user have to RPC query each comment/post to see if they had tips? If it's the latter, plebbit UIs would probably get throttled pretty fast by RPCs, especially L2 RPCs since there aren't that many of them like ETH L1.

Also, shouldn't feeRecipient be an array? In the case we want the fee to go to both Plebbit RPC + subplebbit owner.

With this design, I assume the sub will moderate tip messages by removing its replyCid correct?

we will be calling getTipsTotalAmounts a lot, maybe it uses a lot of RPC credits

Maybe we need to introduce a new field in plebbit-js, reply.txId, and we only check the tips if the reply has it?

Rinse12 avatar Jul 12 '25 11:07 Rinse12

Does the sub include the tips anywhere within their pages, or does the user have to RPC query each comment/post to see if they had tips? If it's the latter, plebbit UIs would probably get throttled pretty fast by RPCs, especially L2 RPCs since there aren't that many of them like ETH L1.

the user would first use getTipsTotalAmounts() on multiple cids at once, one for each reply loaded in the UI, so he would only have to do a few calls per minutes, unless he's scrolling a lot. I think RPC wise it's probably gonna be fine but I guess we will see, this is why this is V1, we have to test it in production. Also I changed the design to use mapping accumulators for getting amounts instead of looping, so we should be able to get 100s / 1000s of amounts in a single call.

the sub would not include any information about tips themselves, as we don't really want to force the sub owner to run an RPC for every chain we allow tips for. it would also be really bad UX for tippers to have their tips censored by the sub owner. with the current design, the sub owner can censor comments associated with tips, but he can't censor the tip amount. also via alternative UIs people could inspect deleted comments that had a tip, so it's more censorship resistant. If you pay to store your comment on chain, it should be fully uncensorable.

maybe this design wont work in practice, but we should try it, it's a simpler design, more censorship resistant as well.

Also, shouldn't feeRecipient be an array? In the case we want the fee to go to both Plebbit RPC + subplebbit owner.

splitting the fee recipient adds transaction fees and design complexity, I don't think it's worth doing in V1. In the final version maybe it would be useful to split, even allow user to choose between burning or donating between which fee recipients.

With this design, I assume the sub will moderate tip messages by removing its replyCid correct?

Yes, but via alternative UIs it would be possible to still list all tips and load their CIDs seeded by someone else. Which I think is fine, if you pay to put your comment on chain, it should probably be visible. We'll see how this design turn out we could change it in future versions.

Maybe we need to introduce a new field in plebbit-js, reply.txId, and we only check the tips if the reply has it?

I think the user probably has to publish their comment first, then the tip gets published afterwards automatically in the background (because the plebbit app is the wallet, so it can automate the transactions), otherwise they can't add the CID of the comment to the transaction. publishing the tip first, then sending the tip tx to the sub owner doesn't seem great, because what if the sub owner doesnt accept it, then you paid for nothing, it will be extremely frustrating.

we could have the sub owner listen for plebbit tipping contract events, and then add them to comment updates, but this forces the sub owner to run an RPC for tips to work. ideally we want to remove dependencies, if the tips can work without any help from the sub owner, that's great, less work from them, users are more likely to be happy. but we'll have to see how the V1 design turns out.

estebanabaroa avatar Jul 12 '25 18:07 estebanabaroa

One thing that I realized is that most of the time in the UI, we only need tip amounts, not the tip data. The tip data would be mostly just used for extra censorship resistance (like the sub owner can't delete your message). So maybe we should not store the tip data at all, and just store tipsTotalAmounts and senderTipsTotalAmounts. this would reduce gas cost by around 70%, or around 10c on mainnet per tip at 1gwei instead of 40c.

Another thing we would lose is the ability to see how many tips, and what were each of their amount, from an RPC, but it could be fetched from a block explorer using events.

I think for now saving 70% doesn't matter, but if we ever deploy to mainnet, we should consider it.

Another possibility could be to make saving the tip data optional, like TipWithData, and only people who want extreme censorship resistance could use it. But this is also something low priority that we can think about for later.

estebanabaroa avatar Jul 28 '25 02:07 estebanabaroa