Official Linkdrop Standard

We’re proposing a new standard for Linkdrops! Huge thanks to Ken Miyachi for helping out with this standard!

Summary

A standard interface for linkdrops that support $NEAR, fungible tokens, non-fungible tokens, and is extensible for linkdrops of any type.

Motivation

Linkdrops are an extremely powerful tool that enable seamless onboarding and instant crypto experiences with the click of a link. The original near-linkdrop contract provides a minimal interface allowing users to embed $NEAR within an access key and create a simple Web2 style link that can then be used as a means of onboarding. This simple $NEAR linkdrop is not enough as many artists, developers, event coordinators, and applications want to drop more digital assets such as NFTs, FTs, tickets etc.

As linkdrop implementations start to push the boundaries of what’s possible, new data structures, methods, and interfaces are being developed. There needs to be a standard data model and interface put into place to ensure assets can be claimed independent of the contract they came from. If not, integrating any application with linkdrops will require customized solutions, which would become cumbersome for the developer and deteriorate the user onboarding experience. The linkdrop standard addresses these issues by providing a simple and extensible standard data model and interface.

Rationale and alternatives

  • Why is this design the best in the space of possible designs?

This design allows for flexibility and extensibility of the standard while providing a set of criteria that cover the majority of current linkdrop use cases. The design was heavily inspired by current, functional NEPs such as the Fungible Token and Non-Fungible Token standards.

  • What other designs have been considered and what is the rationale for not choosing them?

A generic data struct that all drop types needed to inherit from. This struct contained a name and some metadata in the form of stringified JSON. This made it easily extensible for any new types down the road.

The rationale for not choosing this design was both simplicity and flexibility. Having one data struct required keys to be of one type only when in reality, they can be many at once. In addition, having a generic, open-ended metadata field could lead to many interpretations and different designs.

We chose to use a KeyInfo struct that can be easily extensible and can cover all use-cases by having optional vectors of different data types. The proposed standard is simple, supports drops with multiple assets, and is backwards compatible with all previous linkdrops, and can be extended very easily.

  • What is the impact of not doing this?

The impact of not doing this is creating a fragmented ecosystem of linkdrops, increasing the friction for user onboarding. Linkdrop claim pages (e.g. wallet providers) would have to implement custom integrations for every linkdrop provider platform. Inherently this would lead to a bad user experience when new users are onboarding and interacting with linkdrops in general.

Specification

/// Information about a specific public key.
pub struct KeyInfo {
   /// yoctoNEAR$ amount that will be sent to the claiming account (either new or existing)
   // when the key is successfully used.
   pub balance: U128,

   /// If using the NFT standard extension, a set of NFTData can be linked to the public key      
   /// indicating that all those assets will be sent to the claiming account (either new or   
   /// existing) when the key is successfully used.
   pub nft_data: Option<Vec<NFTData>>,
  
   /// If using the FT standard extension, a set of FTData can be linked to the public key      
   /// indicating that all those assets will be sent to the claiming account (either new or   
   /// existing) when the key is successfully used.
   pub ft_data: Option<Vec<FTData>>,

   // ... other types can be introduced and the standard is easily extendable.
}


/// Data outlining a specific Non-Fungible Token that should be sent to the claiming account /// (either new or existing) when a key is successfully used.
pub struct NFTData {
   /// the id of the token to transfer
   pub token_id: string,
   /// The valid NEAR account indicating the Non-Fungible Token contract.
   pub contract_id: string
}


/// Data outlining Fungible Tokens that should be sent to the claiming account 
/// (either new or existing) when a key is successfully used.
pub struct FTData {
   /// The number of tokens to transfer, wrapped in quotes and treated
   /// like a string, although the number will be stored as an unsigned integer
   /// with 128 bits.
   pub amount: string,
   /// The valid NEAR account indicating the Fungible Token contract.
   pub contract_id: string
}


/****************/
/* VIEW METHODS */
/****************/

// Returns the $yoctoNEAR amount associated with a given public key 
// Panics if the key does not exist 
pub fn get_key_balance(&self, key: PublicKey) -> U128

// Returns the KeyInfo associated with a given public key
// Panics if the key does not exist
pub fn get_key_information(&self, key: PublicKey) -> KeyInfo


/******************/
/* CHANGE METHODS */
/******************/

// Transfer all assets linked to the signer’s public key to an *existing* NEAR account. If the transfer 
// fails for whatever reason, it is up to the smart contract developer to choose what should 
// happen. For example, the contract can choose to keep the assets or send them back to the 
// original linkdrop creator.
// 
// Requirements:
// * The predecessor account *MUST* be the current contract ID.
// * The assets being sent *MUST* be associated with the signer’s public key.
// * The assets *MUST* be sent to the `account_id` passed in.
//
// Arguments:
// * `account_id` the account that should receive the linkdrop assets.
pub fn claim(account_id: string) -> Promise


// Creates a new NEAR account and transfers all assets linked to the signer’s public key to the 
// *newly created account*. If the transfer fails for whatever reason, it is up to the smart contract 
// developer to choose what should happen. For example, the contract can  choose to keep the 
// assets or send them back to the original linkdrop creator. 
//
// Requirements 
// * The predecessor account *MUST* be the current contract ID.
// * The assets being sent *MUST* be associated with the signer’s public key.
// * The assets *MUST* be sent to the `new_account_id` passed in.
// * The newly created account *MUST* have a new access key added to its account (either       
//    full or limited access) in the same receipt that the account was created in. 
// 
// Arguments
// * `new_account_id`: the valid NEAR account which is being created and should 
//   receive the linkdrop assets
// * `new_public_key`: the valid public key that should be used for the access key added to         
//   the newly created account.
pub fn create_account_and_claim(new_account_id: string, new_public_key: string) -> Promise

Reference Implementation

Future Possibilities

  • Function call data types
  • Optional configurations added to KeyInfo which can include multi-usekeys, time-based claiming etc…
  • Standard process for how links connect to claim pages (i.e a standardized URL such as an app’s baseUrl/contractId=[LINKDROP_CONTRACT]&secretKey=[SECRET_KEY]
  • Standard for deleting keys and refunding assets.

Copyright

Copyright and related rights waived via CC0.

19 Likes

Do we want “drops” to include multiple assets (e.g. DROP 1 NEAR, and 3 FT), or single asset (drop NEAR, or FT, or NFT)?

If we want single-asset drops, then I would recommend having different Drop Types, that all implement a common interface (trait in Rust). This would be simpler to maintain that defining multiple structures, and then having to check if any is empty . See a WIP here: near-drop/drop_types.rs at main · gagdiez/near-drop · GitHub

For multiple-asset drops, I would recommend to represent the drop as a “chain of actions”. This is, a drop is a Vector of Droptypes ([drop-near, ft-drop, nft-drop, function-call]), were each step is implemented as in the code pasted before, and it enables only on the first step to “create an account”.

3 Likes

I’d like to keep this standard as open ended as possible. For this reason, keys should be able to have multiple different types (1 NFT, 3 FTs etc.). I think it makes the most sense to just contain all the data in a common KeyInfo struct where each type has its own data (you can drop different tokens from different contracts all in 1 key use so you should have a vector of NFTData).

We can move all the common data across all types to the top level of the struct (i.e balance and maybe configs later down the road). I also feel like returning a simple struct from the view method is the simplest way to go.

Another thing to mention is that if you have an array of data types as you mentioned, it wouldn’t be trivial to group all the assets (i.e all your FTs together, all your $NEAR together) since you’d need to sort the array somehow and group them. I think the proposed model solves that nicely while still providing a clean, simple, extensible spec whereby other drop types can be added in future (FC drops etc.).

3 Likes

Standardising an interface for single DropTypes, and then using them inside the KeyInfo is more open ended that implementing multiple arrays.

This is because, when a new drop type is created (say, XFT drop), only a new DropType::XFT needs to be implemented, without needing to update or modify the KeyInfo.

Otherwise, you will need to add a new xft_data: Option<Vec<XFTData>> in the KeyInfo. Also, you save the developer the need to implement a new branch of if (if this array has elements then...). Moreover, this would require the contract to be updated, since the states would now be incompatible.

As far as I understand, the state migration would be seamless since you’re simply introducing a new field into a struct. I verified this by deploying the keypom contract, creating a drop with 10 keys and then modifying the Drop struct by adding a new field which was of type FooBar (a new type I created to represent a new drop type). Everything worked seamlessly and was fully backwards compatible.

While I agree with your other points about standardizing the interface, I’m leaning towards the initial proposal for the following reasons:

Simplicity from a client-side perspective (claim pages) both for being able to group and quickly understand exactly what assets are contained within the drop. As an example, if you had the following assets: ($NEAR, 6 NFTs, and 4 FTs), I would personally prefer to have all the FTs grouped under the ft data field, all the NFTs under the nft data field and the $NEAR exposed under the top level balance field. The alternative is to have an array (whose order you’re not sure of unless you standardize it) of a common data type such as [DataType:Simple,DataType:NFT, DataType:FT, DataType:NFT, etc…].

In addition, if a contract were to introduce a new data type, your array could be cluttered with random types that, as a claim page, you don’t care about. You could have a contract return 100 random data types that they’ve introduced (or are part of the standard) and you only support 1 asset - FTs. You would need to sift through every element in the array and disregard all the non FT assets. (yes I know this is an extreme case).

2 Likes

Thanks for bringing this up! This was an alternative design that was considered and is an important topic to discuss. Having a DropType trait which could be inherited by all the different types of link drops (NEAR, FT, NFT, etc.) is a simpler and more elegant solution for single-asset link drops and would being more straight forward to add an additional drop types to the standard. However, as you pointed out, a multi-asset drop becomes more complicated (e.g. chain of actions, standard for single and multi-asset drops separately).

Ben highlighted some of the the rationale that was used to recommend this design as oppose to the mentioned alternative. What convinced me, was that the proposed solution covers all the common use-cases with a single format (KeyInfo) which can handle single asset, and multi-asset drops in any combination of NEAR, FT, and NFT.

Questions that may help guide this discussion: How common are multi-asset link drops? What are the worst-case scenarios / pain points for developers for:

  1. single-asset drops with the proposed standard
  2. multi-asset drops with DropType trait implementation of the standard

@Ben Now I realise this operations do not deal with storage, my bad.

@kenjon @Ben

Option vs Empty Vec

Option<Vec<>> serialises to null in JSON. If we go for multi-asset by default (I don’t know how many people is dropping NEAR, FTs and NFTs at the same time) I would rather have the vectors as Vec instead of Option<Vec<>>.

Using Vec we would always return a vector, maybe empty[], but always a vector. This allows to not have to check for the nil case.

{balance: 1, nft_data: [], ft_data: []} vs {balance: 1, nft_data: null, ft_data: null }
{balance: 1, nft_data: [<NFTData>], ft_data: []} vs {balance: 1, nft_data: [<NFTDATA>], ft_data: null }

Moreover, this makes a simpler mindset, because then a drop is always something that drops NEAR, FTs, and NFTs.

Claim

On the multi-asset scenario, one claim might not be enough to send all assets. Maybe we could use claim() -> bool, which states if the method needs to be called again. What are your thought on this?

Extensibility

I am still not convinced that this is easily extensible. I mean, the NEP would be extensible because we can write new arrays on it, but devs consuming the data will need to keep different layers of compatibility: “is this contract returning an object with 3 parameters {nft_data: <>, ft_data: <>, xft_data: <>}, 2 params nft_data: <>, ft_data:<>?”, maybe 4 because there is a new NEP?.

Already with the first update the ecosystem will segment into those returning 2 params, and those returning 3 params, thus defeating the idea of standardising the return type.

I would suggest to follow the same route as Events [1], which uses a standard: String field, and a data: JSON, and then defines how data events look like for each NEP standard [2].

[1] Events Format | NEAR Protocol Specification
[2] Fungible Token Event | NEAR Protocol Specification

2 Likes

I’m fine with returning a Vec instead of Option. As for the extensibility, the developers need only take the returned data structure and consume the data that they want or the data that is compatible with their application. If there are more data types in the object that they don’t care to render, they don’t have to.

As an example, say there are 2 linkdrops, a complex one with many many data types ($NEAR, nft, ft, fc, multi-sig, etc…) and the other is simple with only $NEAR. If an application only wants to render the $NEAR portion of the linkdrop (as seen with wallet.near.org), they only need to call get_key_information and extract the balance field. This will work with both cases. The app does NOT need to have extra logic for the complex case as they can simply disregard the data coming in from the complex key.

For reference, this design was heavily influenced by the NFT standard where you have a Token struct and as you support more extensions such as metadata, approvals, royalties etc… the struct gets more and more fields.

As for the multi-asset claims, it should be up linkdrop factory frontend to ensure there will be enough gas for all assets to be claimed. For reference, Keypom can support extremely complex linkdrop logic including batches of function calls, $NEAR transfers etc… all with 100 TGas. If 300 TGas is attached instead, I can see a world where many many NFTs, FTs, and $NEAR are transferred all in 1 call. For your suggestion about returning a boolean, I think this is out of scope for this initial standard since it would mean having multi-use keys (an extension I will push for down the line but right now is not the time in my opinion).

1 Like

Assume you have a simple app that lists drops, particularly what they drop. This app would be the perfect consumer of the interface being proposed.

You now query multiple linkdrop-contracts. One would expect to have a similar response from the contracts, since they are all linkdrops, yet the responses are:

  • Some return {balance: U128, ft_data: Vec<FTData>, nft_data: Vec<NFTData>}
  • Some return {balance: U128, ft_data: Vec<FTData>, nft_data: Vec<NFTData>, xft_data: Vec<XFTData>}
  • Some return {balance:U128, ft_data: Vec<FTData>, nft_data: Vec<NFTData>, xft_data: Vec<XFTData>, msig_data: Vec<?>}

Meaning that:

  1. You need to know what you are searching for to find it (if you didn’t update your app with the new drop type, you cannot find it).
  2. You need to check if the property is present in the returned data (meaning the returned data is not standardised).

If your code needs to adapt to multiple scenarios, that means that the interface is not standardised, which defeats the purpose of an NEP.

Imagine instead the return is Vec<DropType>. Now you always know what to expect: an array of drop types, a type that has {standard: string, metadata: json}.

  • If you want to filter for FTs (NEP141), then simply discard the DropTypes which standard != "nep141"
  • You want to display all drops? simply show <standard-name>: <metadata>, feeling free to implement a better GUI for the standards you were aware of (e.g. FT, NFT), but being safe for those you didn’t know.

No need to check for missing or unexpected attributes in the response. No need to handle the “same type of response” in different ways. Always dealing with the same scenario. So much simpler to code and maintain.

What I do like from having the arrays is that they are less error prone with respect of the metadata being returned.

The strength of having arrays of standardised objects is not that it is easily extendable by adding more arrays (since, as I argued, it is not), but that it is less error prone in terms of devs creating it, and thus less problematic for users consuming it.

Incredibly hard to follow the exact proposed changes @gagdiez

If I understand correctly…? Are you saying you prefer get_key_information to return KeyInfo like this:

// Information about a specific public key.
pub struct KeyInfo {
   // simple, nft, ft, xft, ...
   pub standard: String,
   // additional json data to be deserialized by client and expected to be matching "standard"
   pub data: Option<String>,
}

This seems like a bit extra and switching one type of filtering a new field standard when a developer could simply filter based on the existence of a few standard extension key fields e.g. nft_data and ft_data.

If we consider an implementation:

if (keyInfo.ft_data) // do this thing
else if (keyInfo.nft_data) // do the other thing

// vs proposed standard string and generic json data payload

if (keyInfo.standard === 'ft_drop') { JSON.parse(keyInfo.data) // do this thing
else if (keyInfo.standard === 'nft_drop') { JSON.parse(keyInfo.data) // do the other thing

What is the difference in ergonomics of code, brevity and performance?

If you’re looking for filtering you can simply filter on existence of the key you’re interested in rendering a drop component / page for.

And what would be the difference if MORE of these “extension” options were added to the linkdrop standard?

In either case, codebases will need to be updated in order to take advantage of the additional standards.

2 Likes

I think this is out of scope for this initial standard since it would mean having multi-use keys, an extension I will push for down the line but right now is not the time in my opinion.

Pull Request has been made to NEPs repo: NEP-452 Linkdrop Standard by BenKurrek · Pull Request #452 · near/NEPs · GitHub

1 Like