-
Notifications
You must be signed in to change notification settings - Fork 141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Nonfungible Token Standard #4
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think in general an example implementation of some NFT would be really helpful for people to understand how each methods should be used in practice.
Here is a comment on approvals from discord chat (to keep a record): In async setting like NEAR other contracts can't rely on approvals being valid. Cause there are always can be a transaction in between the initial approval and removing the approval. In NEP#4 we use transfer with callback to ensure the transfer can either succeed or reverted in secure way. |
After more offline discussions and talking to a few folks @potatodepaulo and I decided to update the spec to look similar to Eth spec with a few key differences. We propose the following API: // Grant the access to the given `account_id` for the given `tokenId` .
// Requirements:
// * The caller of the function (`predecessor_id`) should have access to the token.
// * The token should not be locked.
function grantAccess(tokenId: TokenId, account_id: string): void;
// Revokes the access to the given `account_id` for the given `tokenId` .
// Requirements:
// * The caller of the function (`predecessor_id`) should have access to the token.
// * The token should not be locked.
function revokeAccess(tokenId: TokenId, account_id: string): void;
// Locks the given `tokenId` to the caller of the function (`predecessor_id`).
// The `predecessor_id` becomes the owner of the lock.
// Requirements:
// * The caller of the function (`predecessor_id`) should have access to the token.
// * The token should not be locked.
function lock(tokenId: TokenId): void;
// Unlocks the given `tokenId`. Removes lock owner.
// Requirements:
// * The caller of the function (`predecessor_id`) should be the owner of the lock.
// * The token should be locked.
function unlock(tokenId: TokenId): void;
// Transfers the given `tokenId` to the given `account_id`. Account `account_id` becomes the new owner.
// The token unlocks (if it was locked) and all access is revoked except for
// the new owner.
// Requirements:
// * The caller of the function (`predecessor_id`) should have access to the token.
// * If the token is locked, the locked owner should be `predecessor_id`.
function transfer(tokenId: TokenId, account_id: string): void; NOTES:
Examples on how it work:
|
I think the best way for swap NFT is to use escrow smart contract. When escrow will own NFT no one will be able to make permitted changes to it. But if you still wish to use your NFT while it is in the order we can do it in 2 steps. Buyer can call a method on escrow contract and escrow will Currently, 0x project uses a very strange approach: 0xProject/ZEIPs#39. For me it seems it is not enough, because soon we will see NFTs owning NFTs and much more complex things. We need a better approach to make it work automatic for all NFTs. And since ownership is one of the strongest relationships between smart contracts I'd suggest using escrow with 1 or 2-step orders. Example escrow implementation pseudocode: mapping(OrderHash => address) buyers;
function preBuy(order: Order) {
require(buyers[order.hash] == 0);
buyers[order.hash] = msg.sender;
order.asset.transferFrom(msg.sender, this, order.assetAmount);
order.nft.transferFrom(order.seller, this, order.nft_id);
}
function buy(order: Order) {
require(msg.sender == buyers[order.hash]);
order.checkSignatureIsStillValidAndInvalidateIt();
order.nft.transfer(msg.sender, order.nft_id);
order.asset.transfer(order.seller, order.assetAmount);
}
function cancel(order: Order) {
require(msg.sender == buyers[order.hash]);
buyers[order.hash] = 0;
order.nft.transfer(order.seller, order.nft_id);
order.asset.transfer(msg.sender, order.assetAmount);
} |
@k06a The following doesn't work in async design unless you own both tokens.
|
@evgenykuzyakov what do you mean? Approve should be enough to do |
approve can be invalidated before |
@evgenykuzyakov we should just revert in case of any |
No, since it's async. We can only revert the local state in case of failure. Any async operation that was successfully issued will not be canceled. So when you call 2 Here is the DEX example: // Arbitrary number, but has to be enough for the worst case.
const GAS_FOR_A_CALL = 1000000;
const GAS_TO_PROCESS_CALLBACKS = 3 * GAS_FOR_A_CALL;
const RESULT_STATUS_SUCCESS = 1;
export function buy(orderId: OrderId) {
let order = orders.get(tokenId);
// NFT for sale
let tokenId = order.tokenId;
let tokenContract = order.tokenContract;
// Buying price (assuming some fungible tokens)
let price = order.price;
let assetContract = order.priceContract;
// Should lock the order and save it.
order.lock();
// Who's trying to buy (the immediate predecessor account id who called this method, it's can be
// different from the signer of the transaction).
let orderBuyer = context.predecessor;
// Locking NFT token
let sellSideLockArgs: TokenContractLockArgs = {
tokenId,
};
let promiseSellSideLock = ContractPromise.create(tokenContract, "lock", sellSideLockArgs.encode().serialize(), GAS_FOR_A_CALL);
// Locking amount token (we don't have a standard for this, so improvising)
let buySideLockArgs: AssetContractLockArgs = {
owner: orderBuyer,
amount: price,
};
let promiseBuySideLockArgs = ContractPromise.create(assetContract, "lock", buySideLockArgs.encode().serialize(), GAS_FOR_A_CALL);
// Joining promises to attach a callback for both of them.
let promiseJoin = ContractPromise.all([promiseSellSideLock, promiseBuySideLockArgs]);
// Attaching a callback to process the results.
let processCallbackArgs: ProcessCallbackArgs = {
orderId,
orderBuyer,
}
let promiseProcessCallbacks = promiseJoin.then(context.contractName, "processCallbacks", processCallbackArgs.encode().serialize(), GAS_TO_PROCESS_CALLBACKS);
promiseProcessCallbacks.returnAsResult();
}
export function processCallbacks(orderId: OrderId, orderBuyer: string) {
// Checking that this function was called by us.
assert(context.predecessor == context.contractName);
let order = orders.get(tokenId);
// NFT for sale
let tokenId = order.tokenId;
let tokenContract = order.tokenContract;
let tokenOwner = order.tokenOwner;
// Buying price (assuming some fungible tokens)
let price = order.price;
let assetContract = order.priceContract;
// Collecting results
let results = ContractPromise.getResults();
if (results[0].status == RESULT_STATUS_SUCCESS && results[1].status == RESULT_STATUS_SUCCESS) {
// Success, exchanging assets to the new owners and closing the order.
// Transferring NFT token
let sellSideTransferArgs: TokenContractTransferArgs = {
tokenId,
account_id: orderBuyer,
};
// We don't care about result of the execution, because it should succeed after lock.
ContractPromise.create(tokenContract, "transfer", sellSideLockArgs.encode().serialize(), GAS_FOR_A_CALL);
// Transferring the ERC20 like asset
let buySideTransferArgs: AssetContractTransferArgs = {
owner: orderBuyer,
amount: price,
new_owner: tokenOwner,
};
ContractPromise.create(assetContract, "transfer", buySideTransferArgs.encode().serialize(), GAS_FOR_A_CALL);
// Closing the order. The sale was done.
order.close();
} else {
// One or both of the locks failed. Unlocking successful locks and the order
if (results[0].status == RESULT_STATUS_SUCCESS) {
// Token lock succeeded, unlocking
let sellSideUnlockArgs: TokenContractUnlockArgs = {
tokenId,
};
ContractPromise.create(tokenContract, "unlock", sellSideUnlockArgs.encode().serialize(), GAS_FOR_A_CALL);
}
if (results[1].status == RESULT_STATUS_SUCCESS) {
// Asset lock succeeded, unlocking
let buySideUnlockArgs: AssetContractUnlockArgs = {
owner: orderBuyer,
amount: price,
};
ContractPromise.create(assetContract, "unlock", buySideUnlockArgs.encode().serialize(), GAS_FOR_A_CALL);
}
// Unlocking the order
order.unlock();
}
} |
@evgenykuzyakov but I think we can easily send fetched token back in case of second failure: const promise1 = order.asset.transferFrom(msg.sender, this, order.assetAmount);
const promise2 = order.nft.transferFrom(order.seller, this, order.nft_id);
await Promise.all([promise1, promise2]);
if (!promise1 && !promise2) {
return;
}
if (promise1 && !promise2) {
order.asset.transfer(msg.sender, order.assetAmount);
return;
}
if (promise2 && !promise1) {
order.nft.transfer(order.seller, order.nft_id);
return;
} |
Or better provide something like this function shouldSuccessAll(calls: callback[], cancels: callback[]) {
const results = await calls.map(call => call.execute());
if (!results.all()) {
await results.map((v,i) => {
if (!v) {
cancels[i].execute();
}
});
return false;
}
return true;
}
await shouldSuccessAll(
calls: [
() => { order.asset.transferFrom(msg.sender, this, order.assetAmount); },
() => { order.nft.transferFrom(order.seller, this, order.nft_id); },
],
cancels: [
() => { order.asset.transfer(msg.sender, order.assetAmount); },
() => { order.nft.transfer(order.seller, order.nft_id); },
]
); |
And that's why you need locks to not lose approvals in case you revert the transfer. Otherwise, if the token1 fails to transfer, the token2 reverted and all approvals are removed, which delists token2 from any future transfers until the owner approves it again. |
Ok, got it. Let me think of it. |
@evgenykuzyakov it seems maker allowance will not be wasted in case we first try to grab funds from taker. Which have single tx with multiple actions: approve and exchange calls.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Few followups in code related to general discussion.
If I would like to propose more changes in the code - is there any way how I can edit it and make a commit without doing a separate PR?
|
There is one more thing I would strongly recommend: adding a This would tremendously help with all compliance integrations! |
@robert-zaremba Can you clarify on
We can't remove |
You can use suggestion feature in the github reviews. https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/reviewing-proposed-changes-in-a-pull-request |
|
Ah, right. We might transfer a token assuming wrong ownership. But, since this is NFT - each token has only one owner, and to transfer it we always have to have allowance of the current owner. So, security wise, we will not be able to transfer tokens we don't have authorization for if the owner query is happening inside the |
Yes it's true, but for this
Now from the token perspective the The token contract as for NEAR security model always relies on the This means the |
Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com>
Your Render PR Server at https://nomicon-pr-4.onrender.com is now live! View it on your dashboard at https://dashboard.render.com/static/srv-bqvi1j08atnabvm8j0ag. |
Co-authored-by: Chad Ostrowski <221614+chadoh@users.noreply.github.com>
Your Render PR Server at https://nomicon-pr-4.onrender.com is now live! View it on your dashboard at https://dashboard.render.com/static/srv-bqvi1j08atnabvm8j0ag. |
Finally some serious work is starting on the enterprise side with defining token standards: |
Hi all, Are there any news or updates on potential standard composition? e.g.
My view on metadata:
Am I missing something? |
@potatodepaulo @mikedotexe I think it makes sense to close this for now. It was never fully finalized, and we now have a replacement NEP that likewise has not yet been finalized. We may as well signal to people that they should refer to the new discussion instead of this one. New discussion at #171 Feel free to re-open this one if you think we should keep it around for a bit longer. |
A recommendation for an improvement to the NEP-4 spec at near/NEPs#4 The change: -// * The caller of the function (`predecessor`) should have access to the token. +// * The caller of the function (`predecessor`) should own the token. export function transfer(new_owner_id: string, token_id: TokenId): void BREAKING CHANGE: this intentionally deviates from the current spec
Rather than store an array of accounts with escrow access, store only one account. While a less practical implementation, it does conform to the spec and simplifies the code. In addition, remove non-sensical token_id existence requirements for `grant_access` and `remove_access` given by current version of near/NEPs#4 BREAKING CHANGE: this intentionally deviates from the current spec Co-Authored-By: @amgando
A recommendation for an improvement to the NEP-4 spec at near/NEPs#4 The change: -// * The caller of the function (`predecessor`) should have access to the token. +// * The caller of the function (`predecessor`) should own the token. export function transfer(new_owner_id: string, token_id: TokenId): void BREAKING CHANGE: this intentionally deviates from the current spec
Rather than store an array of accounts with escrow access, store only one account. While a less practical implementation, it does conform to the spec and simplifies the code. In addition, remove non-sensical token_id existence requirements for `grant_access` and `remove_access` given by current version of near/NEPs#4 BREAKING CHANGE: this intentionally deviates from the current spec Co-Authored-By: @amgando
A recommendation for an improvement to the NEP-4 spec at near/NEPs#4 The change: -// * The caller of the function (`predecessor`) should have access to the token. +// * The caller of the function (`predecessor`) should own the token. export function transfer(new_owner_id: string, token_id: TokenId): void BREAKING CHANGE: this intentionally deviates from the current spec
Rather than store an array of accounts with escrow access, store only one account. While a less practical implementation, it does conform to the spec and simplifies the code. In addition, remove non-sensical token_id existence requirements for `grant_access` and `remove_access` given by current version of near/NEPs#4 BREAKING CHANGE: this intentionally deviates from the current spec Co-Authored-By: @amgando
EDIT: Updated 18-May-2020 with simpler specification
Nonfungible Token
Summary
A standard interface for non-fungible tokens allowing for ownership and transfer.
Motivation
Non-fungible tokens (NFTs) have been described in many ways: digital goods, collectible items, unique online assets etc. The current core use case for NFTs is speculating on value and trading unique items. The use case of trading NFTs should be natively supported. This is the most basic set of functions needed to create an interoperable NFT that works in an asynchronous environment.
Prior art:
Guide-level explanation
This token should allow the following:
There are a few concepts in the scenarios above:
Simple transfer
Assumptions
corgi
alice
jerry
3
High-level
Alice needs to issue one transaction to the Corgi NFT contract to transfer one corgi token to Jeraldo.
Technical calls
alice
callscorgi::transfer({"new_owner_id":"jerry", "token_id":3})
Token swap through a third party escrow
Alice wants to transfer one Corgi NFT through a third party escrow to Jeraldo in exchange for one Sausage NFT.
Assumptions
corgi
sausage
alice
jerry
escrow
3
and a Sausage token with the ID of5
High-level
Both Alice and Jerry will issue asynchronous transactions to their respective contracts,
corgi
andsausage
to grant access to the escrow to trade tokens on their behalf.escrow
will call thesasuage
token contract asynchrounously to transfer the Sausage token toescrow
. After,escrow
will also call thecorgi
contract to asynchornously transfer the Corgi token toescrow
. Then,escrow
will conduct a transfer to both parties.transfer_from
calls succeed, then Alice will now own one Sausage token and Jerry will own one Corgi token.transfer_from
calls fail, then nothing will happen andescrow
should attempt reissuing the failed transaction.Technical calls
alice
makes an async call tocorgi::grant_access({"escrow_account_id":"escrow"})
jerry
makes an async call to ``sausage::grant_access({"escrow_account_id":"escrow"})`escrow
callssausage::transfer_from({"owner_id":"jerry", "new_owner_id:"escrow", "token_id": 5})
escrow::on_transfer({"owner_id":"jerry", "token_contract":"sausage", "token_id": 5})
escrow
callscorgi::transfer_from({"owner_id":"alice", "new_owner_id:"escrow", "token_id": 3})
escrow::on_transfer({"owner_id":"alice", "token_contract":"corgi", "token_id": 3})
escrow
callscorgi::transfer_from({"owner_id":"escrow", "new_owner_id:"jerry", "token_id": 3})
escrow::on_transfer({"owner_id":"alice", "token_contract:"corgi", "token_id": 3})
escrow
callssausage::transfer_from({"owner_id":"jerry", "new_owner_id:"escrow", "token_id": 5})
escrow::on_transfer({"owner_id":"jerry", "token_contract":"corgi", "token_id": 3})
Reference-level explanation
Template for smart contract in AssemblyScript
At time of writing, this standard is established with several constraints found in AssemblyScript. The first is that interfaces are not an implemented feature of AssemblyScript, and the second is that classes are not exported in the conversion from AssemblyScript to WASM. This means that the entire contract could be implemented as a class, which might be better for code organization, but it would be deceiving in function.
Drawbacks
The major design choice to not use a system of approvals for escrow in favor of performance means that it is up to implementors of markets to decide how they manage escrow themselves. This is a dilemma because it increases freedom, while increasing risk of making a mistake on the market side. Ultimately, it will be up to markets and their users to find the best solution to escrow, and we don't believe that approvals is the way to do it. This allows for that solution to be discovered with trail and error. The standard for the market will change, but not the token itself.
This token standard has been whittled down to the simplest fundamental use cases. It relies on extensions and design decisions to be useable.
There are some things that have been in contention in the design of this standard. Namely, the tokenId system relies on unique indices to function. This might cause a problem with use cases that need the
lock
andunlock
functionality.In addition, the
grant_access
andrevoke_access
functions act similarly to approvals, but must operate asynchronously and in batch transactions where appropriate.Rationale and alternatives
A multi-token standard was considered, as well a standard that allowed for the transfer of any type of token along with the assets associated with this contract. This was foregone for the sake of decoupling the market contracts from the token contracts. The emphasis of this standard is now on simplicity and flexibility. It allows for any type of token to interface with any type of market that accepts this standard. The explicit goal is to maximize developer freedom with a rigid enough foundation to make a standard useful.
Unresolved questions
Primarily edge cases for various applications should be surfaced. For example, the use case of creating an in-game store is different than creating a token for tracking real-world objects digitally. This token attempts to create a standard for both.
Neither a market standard nor an escrow system is addressed here. These should exists in the future, but are purposefully left separate. An item should not care about the place it is sold or agreed on.
The ability to
lock
andunlock
tokens is a likely requirement for many use cases, but there are many challenges around this. The initial solution to solely rely on callbacks was abandoned in favor of an access system that allows escrow contracts to lock and transfer tokens.Finally, in the original draft, metadata was included in the model for tokens. It was clear through some basic implementations that this is not ideal since users may want to store metadata elsewhere. This could be entirely offchain, or in a separate contract. This creates an unsolved problem of synchronizing metadata with contracts, and needs more design work.
Future possibilities
The next step in the development of this standard is extending it further in a new standard that addresses spcifically how a generic and safe escrow would function, and how metadata should be handled based on the specific use cases of tokens implemented. In addition, an importable module should be developed, allowing developers to integrate a token system with little overhead. Alternative uses of this token are of high interest. Known uses for nonfungible tokens include collectible items online, and item systems in games as discussed throughout. There are many uses cases yet to be invented. These might include tokens for supply chain or even tokens for shared custody of physical items. The possibilities are ultimately going to be driven by community use.