Browse Source

Solana NFT example (#1002)

* Create NFT example

Signed-off-by: Lucas Steuernagel <lucas.tnagel@gmail.com>

* Update documentation

Signed-off-by: Lucas Steuernagel <lucas.tnagel@gmail.com>

* Rewrite example optimizing for Solana

Signed-off-by: Lucas Steuernagel <lucas.tnagel@gmail.com>

Signed-off-by: Lucas Steuernagel <lucas.tnagel@gmail.com>
Lucas Steuernagel 3 năm trước cách đây
mục cha
commit
c9c1f75c2c

+ 19 - 2
docs/examples.rst

@@ -3,8 +3,12 @@ Solang Solidity Examples
 
 Here are two examples of Solidity contracts.
 
+
+General examples
+----------------
+
 Flipper
--------
+_______
 
 This is the `ink! flipper example <https://github.com/paritytech/ink/blob/v3.3.0/examples/flipper/lib.rs>`_
 written in Solidity:
@@ -13,9 +17,22 @@ written in Solidity:
   :code: solidity
 
 Example
--------
+_______
 
 A few simple arithmetic functions.
 
 .. include:: ../examples/example.sol
   :code: solidity
+
+
+Solana examples
+---------------
+
+NFT example
+___________
+
+There is an example on Solana's integration tests for a Solidity contract that manages an NFT. The contract is supposed
+to be the NFT itself. It can mint itself and transfer ownership. It also stores on chain information about itself, such as its URI.
+Please, check `simple_collectible.sol <https://github.com/hyperledger/solang/blob/main/integration/solana/simple_collectible.sol>`_
+for the Solidity contract and `simple_collectible.spec.ts <https://github.com/hyperledger/solang/blob/main/integration/solana/simple_collectible.spec.ts>`_
+for the Typescript code that interacts with Solidity.

+ 3 - 3
integration/solana/calls.spec.ts

@@ -1,5 +1,5 @@
 import expect from 'expect';
-import { loadContract, load2ndContract } from './setup';
+import { loadContract, loadContractWithExistingConnectionAndPayer } from './setup';
 
 describe('Deploy solang contract and test', function () {
     this.timeout(100000);
@@ -7,10 +7,10 @@ describe('Deploy solang contract and test', function () {
     it('external_call', async function () {
         const { contract: caller, connection, payer, program } = await loadContract('caller', 'caller.abi');
 
-        const callee = await load2ndContract(connection, program, payer, 'callee', 'callee.abi');
+        const callee = await loadContractWithExistingConnectionAndPayer(connection, program, payer, 'callee', 'callee.abi');
 
 
-        const callee2 = await load2ndContract(connection, program, payer, 'callee2', 'callee2.abi');
+        const callee2 = await loadContractWithExistingConnectionAndPayer(connection, program, payer, 'callee2', 'callee2.abi');
 
         await callee.functions.set_x(102);
 

+ 1 - 1
integration/solana/package.json

@@ -18,7 +18,7 @@
   },
   "dependencies": {
     "@dao-xyz/borsh": "^3.1.0",
-    "@solana/solidity": "0.0.20",
+    "@solana/solidity": "0.0.21",
     "@solana/spl-token": "0.2.0",
     "@solana/web3.js": "^1.30.2 <1.40.0",
     "ethers": "^5.2.0",

+ 8 - 1
integration/solana/setup.ts

@@ -22,7 +22,14 @@ export async function loadContract(name: string, abifile: string, args: any[] =
     return { contract, connection, payer: payerAccount, program, storage };
 }
 
-export async function load2ndContract(connection: Connection, program: Keypair, payerAccount: Keypair, name: string, abifile: string, args: any[] = [], space: number = 8192): Promise<Contract> {
+export function newConnectionAndAccounts() : [Connection, Keypair, Keypair] {
+    const connection = new Connection(endpoint, 'confirmed');
+    const payerAccount = load_key('payer.key');
+    const program = load_key('program.key');
+    return [connection, payerAccount, program];
+}
+
+export async function loadContractWithExistingConnectionAndPayer(connection: Connection, program: Keypair, payerAccount: Keypair, name: string, abifile: string, args: any[] = [], space: number = 8192): Promise<Contract> {
     const abi = JSON.parse(fs.readFileSync(abifile, 'utf8'));
 
     const storage = Keypair.generate();

+ 107 - 0
integration/solana/simple_collectible.sol

@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: Apache-2.0
+
+// DISCLAIMER: This file is an example of how to mint and transfer NFTs on Solana. It is not production ready and has not been audited for security.
+// Use it at your own risk.
+
+import '../../solana-library/spl_token.sol';
+
+contract SimpleCollectible {
+    // On Solana, the mintAccount represents the type of token created. It saves how many tokens exist in circulation.
+    address private mintAccount;
+    // The public key for the authority that should sign every change to the NFT's URI
+    address private metadataAuthority;
+    // A resource identifier to access the NFT. It could be any other data to be saved on the blockchain
+    string private uri;
+
+    // These events log on the blockchain transactions made with this NFT
+    event NFTMinted(address owner, address mintAccount);
+    event NFTSold(address from, address to);
+
+    // The mint account will identify the NFT in this example
+    constructor (address _mintAccount, address _metadataAuthority) {
+        mintAccount = _mintAccount;
+        metadataAuthority = _metadataAuthority;
+    }
+
+    /// Create a new NFT and associate it to an URI
+    ///
+    /// @param tokenURI a URI that leads to the NFT resource
+    /// @param mintAuthority an account that signs each new mint
+    /// @param ownerTokenAccount the owner associated token account
+    function createCollectible(string memory tokenURI, address mintAuthority, address ownerTokenAccount) public {
+        SplToken.TokenAccountData token_data = SplToken.get_token_account_data(ownerTokenAccount);
+
+        // The mint will only work if the associated token account points to the mint account in this contract
+        // This assert is not necessary. The transaction will fail if this does not hold true.
+        assert(mintAccount == token_data.mintAccount);
+        SplToken.MintAccountData mint_data = SplToken.get_mint_account_data(token_data.mintAccount);
+        // Ensure the supply is zero. Otherwise, this is not an NFT.
+        assert(mint_data.supply == 0);
+        
+        // An NFT on Solana is a SPL-Token with only one minted token.
+        // The token account saves the owner of the tokens minted with the mint account, the respective mint account and the number
+        // of tokens the owner account owns
+        SplToken.mint_to(token_data.mintAccount, ownerTokenAccount, mintAuthority, 1);
+        updateNftUri(tokenURI);
+
+        // Set the mint authority to null. This prevents that any other new tokens be minted, ensuring we have an NFT.
+        SplToken.remove_mint_authority(mintAccount, mintAuthority);
+
+        // Log on blockchain records information about the created token
+        emit NFTMinted(token_data.owner, token_data.mintAccount);
+    }
+
+    /// Transfer ownership of this NFT from one account to another
+    /// This function only wraps the innate SPL transfer, which can be used outside this contract.
+    /// However, the difference here is the event 'NFTSold' exclusive to this function
+    ///
+    /// @param oldTokenAccount the token account for the current owner
+    /// @param newTokenAccount the token account for the new owner
+    function transferOwnership(address oldTokenAccount, address newTokenAccount) public {
+        // The current owner does not need to be the caller of this functions, but they need to sign the transaction
+        // with their private key.
+        SplToken.TokenAccountData old_data = SplToken.get_token_account_data(oldTokenAccount);
+        SplToken.TokenAccountData new_data = SplToken.get_token_account_data(newTokenAccount);
+
+        // To transfer the ownership of a token, we need the current owner and the new owner. The payer account is the account used to derive
+        // the correspondent token account in TypeScript.
+        SplToken.transfer(oldTokenAccount, newTokenAccount, old_data.owner, 1);
+        emit NFTSold(old_data.owner, new_data.owner);
+    }
+
+    /// Return the URI of this NFT
+    function getNftUri() public view returns (string memory) {
+        return uri;
+    }
+
+    /// Check if an NFT is owned by @param owner
+    ///
+    /// @param owner the account whose ownership we want to verify
+    /// @param tokenAccount the owner's associated token account
+    function isOwner(address owner, address tokenAccount) public returns (bool) {
+        SplToken.TokenAccountData data = SplToken.get_token_account_data(tokenAccount);
+
+        return owner == data.owner && mintAccount == data.mintAccount && data.balance == 1;
+    }
+
+    /// Updates the NFT URI
+    /// The metadata authority must sign the transaction so that the update can succeed.
+    ///
+    /// @param newUri a new URI for the NFT
+    function updateNftUri(string newUri) public {
+        requireMetadataSigner();
+        uri = newUri;
+    }
+
+    /// Requires the signature of the metadata authority.
+    function requireMetadataSigner() private {
+        for(uint32 i=0; i < tx.accounts.length; i++) {
+            if (tx.accounts[i].key == metadataAuthority) {
+                require(tx.accounts[i].is_signer, "the metadata authority must sign the transaction");
+                return;
+            }
+        }
+
+        revert("The metadata authority is missing");
+    }
+}

+ 114 - 0
integration/solana/simple_collectible.spec.ts

@@ -0,0 +1,114 @@
+// SPDX-License-Identifier: Apache-2.0
+
+// DISCLAIMER: This file is an example of how to mint and transfer NFTs on Solana. It is not production ready and has not been audited for security.
+// Use it at your own risk.
+
+import {loadContractWithExistingConnectionAndPayer, loadContract, newConnectionAndAccounts} from "./setup";
+import {Connection, Keypair, PublicKey, SystemProgram} from "@solana/web3.js";
+import {publicKeyToHex, HexToPublicKey} from "@solana/solidity";
+import {createMint, getOrCreateAssociatedTokenAccount, TOKEN_PROGRAM_ID} from "@solana/spl-token";
+import expect from "expect";
+
+describe('Simple collectible', function() {
+    this.timeout(500000);
+
+    it('nft example', async function mint_nft() {
+        const [connection, payer, program] = newConnectionAndAccounts();
+        const mint_authority = Keypair.generate();
+        const freezeAuthority = Keypair.generate();
+
+        // Create and initialize a new mint based on the funding account and a mint authority
+        const mint = await createMint(
+            connection,
+            payer,
+            mint_authority.publicKey,
+            freezeAuthority.publicKey,
+            0
+        );
+
+        const nft_owner = Keypair.generate();
+        const metadata_authority = Keypair.generate();
+
+        // On Solana, an account must have an associated token account to save information about how many tokens
+        // the owner account owns. The associated account depends on both the mint account and the owner
+        const owner_token_account = await getOrCreateAssociatedTokenAccount(
+            connection,
+            payer,
+            mint, // Mint account
+            nft_owner.publicKey // Owner account
+        );
+
+        // Each contract in this example is a unique NFT
+        const contract = await loadContractWithExistingConnectionAndPayer(
+            connection,
+            program,
+            payer,
+            "SimpleCollectible",
+            "SimpleCollectible.abi",
+            [publicKeyToHex(mint), publicKeyToHex(metadata_authority.publicKey)]
+        );
+
+        const nft_uri = "www.nft.com";
+
+        // Create a collectible for an owner given a mint authority.
+        await contract.functions.createCollectible(
+            nft_uri,
+            publicKeyToHex(mint_authority.publicKey),
+            publicKeyToHex(owner_token_account.address),
+            {
+                accounts: [TOKEN_PROGRAM_ID],
+                writableAccounts: [mint, owner_token_account.address],
+                signers: [mint_authority, metadata_authority]
+            }
+        );
+
+        const new_owner = Keypair.generate();
+
+        // A new owner must have an associated token account
+        const new_owner_token_account = await getOrCreateAssociatedTokenAccount(
+            connection,
+            payer,
+            mint, // Mint account associated to the NFT
+            new_owner.publicKey // New owner account
+        );
+
+
+        // Transfer ownership to another owner
+        await contract.functions.transferOwnership(
+            publicKeyToHex(owner_token_account.address),
+            publicKeyToHex(new_owner_token_account.address),
+            {
+                accounts: [TOKEN_PROGRAM_ID],
+                writableAccounts: [owner_token_account.address, new_owner_token_account.address],
+                signers: [nft_owner]
+            }
+        );
+
+        // Confirm that the ownership transference worked
+        const verify_transfer_result = await contract.functions.isOwner(
+            publicKeyToHex(new_owner.publicKey),
+            publicKeyToHex(new_owner_token_account.address),
+            {
+                accounts: [new_owner_token_account.address],
+            }
+        );
+
+        expect(verify_transfer_result.result).toBe(true);
+
+        // Retrieve information about the NFT
+        const token_uri = await contract.functions.getNftUri();
+        expect(token_uri.result).toBe(nft_uri);
+
+        // Update the NFT URI
+        const new_uri = "www.token.com";
+        await contract.functions.updateNftUri(
+            new_uri,
+            {
+                signers: [metadata_authority]
+            }
+        );
+
+        const new_uri_saved = await contract.functions.getNftUri();
+        expect(new_uri_saved.result).toBe(new_uri);
+    });
+});

+ 107 - 0
solana-library/spl_token.sol

@@ -176,4 +176,111 @@ library SplToken {
 
 		revert("account missing");
 	}
+
+	/// This enum represents the state of a token account
+	enum AccountState {
+		Uninitialized,
+		Initialized,
+		Frozen
+	}
+
+	/// This struct is the return of 'get_token_account_data'
+	struct TokenAccountData {
+		address mintAccount;
+		address owner;
+		uint64 balance;
+		bool delegate_present;
+		address delegate;
+		AccountState state;
+		bool is_native_present;
+		uint64 is_native;
+		uint64 delegated_amount;
+		bool close_authority_present;
+		address close_authority;
+	}
+
+	/// Fetch the owner, mint account and balance for an associated token account.
+	///
+	/// @param tokenAccount The token account
+	/// @return struct TokenAccountData
+	function get_token_account_data(address tokenAccount) public view returns (TokenAccountData) {
+		AccountInfo ai = get_account_info(tokenAccount);
+
+		TokenAccountData data = TokenAccountData(
+			{
+				mintAccount: ai.data.readAddress(0), 
+				owner: ai.data.readAddress(32),
+			 	balance: ai.data.readUint64LE(64),
+				delegate_present: ai.data.readUint32LE(72) > 0,
+				delegate: ai.data.readAddress(76),
+				state: AccountState(ai.data[108]),
+				is_native_present: ai.data.readUint32LE(109) > 0,
+				is_native: ai.data.readUint64LE(113),
+				delegated_amount: ai.data.readUint64LE(121),
+				close_authority_present: ai.data.readUint32LE(129) > 10,
+				close_authority: ai.data.readAddress(133)
+			}
+		);
+
+		return data;
+	}
+
+	// This struct is the return of 'get_mint_account_data'
+	struct MintAccountData {
+		bool authority_present;
+		address mint_authority;
+		uint64 supply;
+		uint8 decimals;
+		bool is_initialized;
+		bool freeze_authority_present;
+		address freeze_authority;
+	}
+
+	/// Retrieve the information saved in a mint account
+	///
+	/// @param mintAccount the account whose information we want to retrive
+	/// @return the MintAccountData struct
+	function get_mint_account_data(address mintAccount) public view returns (MintAccountData) {
+		AccountInfo ai = get_account_info(mintAccount);
+
+		uint32 authority_present = ai.data.readUint32LE(0);
+		uint32 freeze_authority_present = ai.data.readUint32LE(46);
+		MintAccountData data = MintAccountData( {
+			authority_present: authority_present > 0,
+			mint_authority: ai.data.readAddress(4),
+			supply: ai.data.readUint64LE(36),
+			decimals: uint8(ai.data[44]),
+			is_initialized: ai.data[45] > 0,
+			freeze_authority_present: freeze_authority_present > 0,
+			freeze_authority: ai.data.readAddress(50)
+		});
+
+		return data;
+	}
+
+	// A mint account has an authority, whose type is one of the members of this struct.
+	enum AuthorityType {
+		MintTokens,
+		FreezeAccount,
+		AccountOwner,
+		CloseAccount
+	}
+
+	/// Remove the mint authority from a mint account
+	///
+	/// @param mintAccount the public key for the mint account
+	/// @param mintAuthority the public for the mint authority
+	function remove_mint_authority(address mintAccount, address mintAuthority) public {
+		AccountMeta[2] metas = [
+			AccountMeta({pubkey: mintAccount, is_signer: false, is_writable: true}),
+			AccountMeta({pubkey: mintAuthority, is_signer: true, is_writable: false})
+		];
+
+		bytes data = new bytes(9);
+		data[0] = uint8(TokenInstruction.SetAuthority);
+		data[1] = uint8(AuthorityType.MintTokens);
+		data[3] = 0;
+		
+		tokenProgramId.call{accounts: metas}(data);
+	}
 }