diff --git a/.gitmodules b/.gitmodules index fc6c8bc..99d920f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,4 +5,3 @@ [submodule "lib/suave-std"] path = lib/suave-std url = https://github.com/flashbots/suave-std - diff --git a/examples/712/L1/src/NFTEE.sol b/examples/712/L1/src/NFTEE.sol new file mode 100644 index 0000000..3035eb9 --- /dev/null +++ b/examples/712/L1/src/NFTEE.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {ERC721} from "solmate/tokens/ERC721.sol"; + +/// @title NFTMinter +/// @notice Contract to mint ERC-721 tokens with a signed EIP-712 message +contract SuaveNFT is ERC721 { + // Event declarations + event NFTMintedEvent(address indexed recipient, uint256 indexed tokenId); + + // EIP-712 Domain Separator + // keccak256(abi.encode(keccak256("EIP712Domain(string name,string symbol,uint256 chainId,address verifyingContract)"),keccak256(bytes(NAME)),keccak256(bytes(SYMBOL)),block.chainid,address(this)) + bytes32 public DOMAIN_SEPARATOR = 0x07c5db21fddca4952bc7dee96ea945c5702afed160b9697111b37b16b1289b89; + + // EIP-712 TypeHash + // keccak256("Mint(string name,string symbol,uint256 tokenId,address recipient)"); + bytes32 public constant MINT_TYPEHASH = 0x686aa0ee2a8dd75ace6f66b3a5e79d3dfd8e25e05a5e494bb85e72214ab37880; + + // Authorized signer's address + address public authorizedSigner; + + // NFT Details + string public constant NAME = "SUAVE_NFT"; + string public constant SYMBOL = "NFTEE"; + string public constant TOKEN_URI = "IPFS_URL"; + + constructor(address _authorizedSigner) ERC721(NAME, SYMBOL) { + authorizedSigner = _authorizedSigner; + + // TODO: Make dynamic + // // Initialize DOMAIN_SEPARATOR with EIP-712 domain separator, specific to your contract + // DOMAIN_SEPARATOR = keccak256( + // abi.encode( + // keccak256("EIP712Domain(string name,string symbol,uint256 chainId,address verifyingContract)"), + // keccak256(bytes(NAME)), + // keccak256(bytes(SYMBOL)), + // block.chainid, + // address(this) + // ) + // ); + } + + // Mint NFT with a signed EIP-712 message + function mintNFTWithSignature(uint256 tokenId, address recipient, uint8 v, bytes32 r, bytes32 s) external { + require(verifyEIP712Signature(tokenId, recipient, v, r, s), "INVALID_SIGNATURE"); + + _safeMint(recipient, tokenId); + + emit NFTMintedEvent(recipient, tokenId); + } + + // Verify EIP-712 signature + function verifyEIP712Signature(uint256 tokenId, address recipient, uint8 v, bytes32 r, bytes32 s) + internal + view + returns (bool) + { + bytes32 digestHash = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256( + abi.encode(MINT_TYPEHASH, keccak256(bytes(NAME)), keccak256(bytes(SYMBOL)), tokenId, recipient) + ) + ) + ); + + address recovered = ecrecover(digestHash, v, r, s); + + return recovered == authorizedSigner; + } + + // Token URI implementation + function tokenURI(uint256 tokenId) public view override returns (string memory) { + require(_ownerOf[tokenId] != address(0), "NOT_MINTED"); + return TOKEN_URI; + } +} diff --git a/examples/712/L1/test/NFTEE.t.sol b/examples/712/L1/test/NFTEE.t.sol new file mode 100644 index 0000000..0aef1fb --- /dev/null +++ b/examples/712/L1/test/NFTEE.t.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/NFTEE.sol"; + +contract SuaveNFTTest is Test { + uint256 internal signerPrivateKey; + address internal signerPubKey; + SuaveNFT suaveNFT; + + function setUp() public { + signerPrivateKey = 0xA11CE; + signerPubKey = vm.addr(signerPrivateKey); + suaveNFT = new SuaveNFT(signerPubKey); + } + + function testMintNFTWithSignature() public { + uint256 tokenId = 1; + address recipient = 0xE0f5206BBD039e7b0592d8918820024e2a7437b9; + uint8 v; + bytes32 r; + bytes32 s; + + // Prepare the EIP-712 signature + { + bytes32 DOMAIN_SEPARATOR = suaveNFT.DOMAIN_SEPARATOR(); + bytes32 structHash = keccak256( + abi.encode( + suaveNFT.MINT_TYPEHASH(), // Use MINT_TYPEHASH from the contract + keccak256(bytes(suaveNFT.NAME())), // Use NAME constant from the contract + keccak256(bytes(suaveNFT.SYMBOL())), // Use SYMBOL constant from the contract + tokenId, + recipient + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); + // example forge logs for debugging 712 + console.logBytes32(DOMAIN_SEPARATOR); + console.logBytes32(suaveNFT.MINT_TYPEHASH()); + console.logBytes32(keccak256(bytes(suaveNFT.NAME()))); + console.logBytes32(keccak256(bytes(suaveNFT.SYMBOL()))); + console.logBytes32(digest); + + // Sign the digest + (v, r, s) = vm.sign(signerPrivateKey, digest); + } + + // Mint the NFT + suaveNFT.mintNFTWithSignature(tokenId, recipient, v, r, s); + + // Assertions + assertEq(suaveNFT.ownerOf(tokenId), recipient); + assertEq(suaveNFT.tokenURI(tokenId), "IPFS_URL"); + } +} diff --git a/examples/712/README.md b/examples/712/README.md new file mode 100644 index 0000000..356088b --- /dev/null +++ b/examples/712/README.md @@ -0,0 +1,29 @@ +# NFTEE - EIP712 Minting Example + +This SUAPP example showcases how you can write a SUAPP to generate a 712 signature on SUAVE that can then ben sent to a contract on Eth L1 which allows you to mint an NFT. + +## Usage +## Solidity +Like all examples in this repo: +```sh +forge build +``` +## Go Script +Before running you need to fill in some values: +- `PRIV_KEY`: Valid ECDSA Private Key with L1 Eth. (Hexadecimal format) +- `ETH_RPC_URL`: Ethereum L1 testnet RPC URL. +- `ETH_CHAIN_ID`: Chain Id of the L1 you're testing on. + +To run the script, execute the following command in your terminal: + +```sh +go run main.go +``` + +## Notes +- The `DOMAIN_SEPARATOR` and `MINT_TYPEHASH` are currently hard coded, you will need to make this dynamic for you prod application. Also Accepting PRs! +- Ensure that the Ethereum Goerli testnet account associated with the provided private key has sufficient ETH to cover transaction fees. +- The script currently targets the Goerli testnet. For mainnet deployment, update the `ETH_RPC_URL` and `ETH_CHAIN_ID` appropriately, and ensure that the account has sufficient mainnet ETH. + +# 712 +The source code for creating the 712 Signature is based off [Testing EIP-712 Signatures](https://book.getfoundry.sh/tutorials/testing-eip712.html). diff --git a/examples/712/SUAVE/src/712Emitter.sol b/examples/712/SUAVE/src/712Emitter.sol new file mode 100644 index 0000000..1852d29 --- /dev/null +++ b/examples/712/SUAVE/src/712Emitter.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "../../../../lib/suave-std/src/suavelib/Suave.sol"; + +contract Emitter { + // Constants matching those in SuaveNFT + string private constant NAME = "SUAVE_NFT"; + string private constant SYMBOL = "NFTEE"; + bytes32 private constant MINT_TYPEHASH = 0x686aa0ee2a8dd75ace6f66b3a5e79d3dfd8e25e05a5e494bb85e72214ab37880; + bytes32 private constant DOMAIN_SEPARATOR = 0x07c5db21fddca4952bc7dee96ea945c5702afed160b9697111b37b16b1289b89; + string private cstoreKey = "NFTEE:v0:PrivateKey"; + + // Private key variable + Suave.DataId public privateKeyDataID; + address public owner; + + // Constructor to initialize owner + constructor() { + owner = msg.sender; + } + + function getPrivateKeyDataIDBytes() public view returns (bytes16) { + return Suave.DataId.unwrap(privateKeyDataID); + } + + // function to fetch private key from confidential input portion of Confidential Compute Request + function fetchConfidentialPrivateKey() public returns (bytes memory) { + require(Suave.isConfidential()); + + bytes memory confidentialInputs = Suave.confidentialInputs(); + return confidentialInputs; + } + + event PrivateKeyUpdateEvent(Suave.DataId dataID); + + // setPrivateKey is the onchain portion of the Confidential Compute Request + // inside we need to store our reference to our private key for future use + // we must do this because updatePrivateKey() is offchain and can't directly store onchain without this + function setPrivateKey(Suave.DataId dataID) public { + privateKeyDataID = dataID; + emit PrivateKeyUpdateEvent(dataID); + } + + // offchain portion of Confidential Compute Request to update privateKey + function updatePrivateKey() public returns (bytes memory) { + require(Suave.isConfidential()); + + bytes memory privateKey = this.fetchConfidentialPrivateKey(); + + // create permissions for data record + address[] memory peekers = new address[](1); + peekers[0] = address(this); + + address[] memory allowedStores = new address[](1); + allowedStores[0] = 0xC8df3686b4Afb2BB53e60EAe97EF043FE03Fb829; // using the wildcard address for allowedStores + + // store private key in conf data store + Suave.DataRecord memory record = Suave.newDataRecord(0, peekers, allowedStores, cstoreKey); + + Suave.confidentialStore(record.id, cstoreKey, privateKey); + + // return calback to emit data ID onchain + return bytes.concat(this.setPrivateKey.selector, abi.encode(record.id)); + } + + event NFTEEApproval(bytes signedMessage); + + function emitSignedMintApproval(bytes memory msg) public { + emit NFTEEApproval(msg); + } + + // Function to create EIP-712 digest + function createEIP712Digest(uint256 tokenId, address recipient) public view returns (bytes memory) { + require(Suave.DataId.unwrap(privateKeyDataID) != bytes16(0), "private key is not set"); + + bytes32 structHash = + keccak256(abi.encode(MINT_TYPEHASH, keccak256(bytes(NAME)), keccak256(bytes(SYMBOL)), tokenId, recipient)); + bytes32 digestHash = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); + + return abi.encodePacked(digestHash); + } + + // Function to sign and emit a signed EIP 712 digest for minting an NFTEE on L1 + function signL1MintApproval(uint256 tokenId, address recipient) public view returns (bytes memory) { + require(Suave.isConfidential()); + require(Suave.DataId.unwrap(privateKeyDataID) != bytes16(0), "private key is not set"); + + bytes memory digest = createEIP712Digest(tokenId, recipient); + + bytes memory signerPrivateKey = Suave.confidentialRetrieve(privateKeyDataID, cstoreKey); + + bytes memory msgBytes = Suave.signMessage(digest, string(signerPrivateKey)); + + return bytes.concat(this.emitSignedMintApproval.selector, abi.encode(msgBytes)); + } +} diff --git a/examples/712/SUAVE/test/712Emitter.t.sol b/examples/712/SUAVE/test/712Emitter.t.sol new file mode 100644 index 0000000..ef50508 --- /dev/null +++ b/examples/712/SUAVE/test/712Emitter.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/712Emitter.sol"; + +contract EmitterTest is Test { + Emitter emitter; + address internal owner; + + event NFTEEApproval(bytes signedMessage); + + function setUp() public { + owner = address(this); // Setting the test contract as the owner for testing + emitter = new Emitter(); + } + + function testOwnerInitialization() public { + assertEq(emitter.owner(), owner, "Owner should be initialized correctly"); + } + + function testSetPrivateKey() public { + // Mock DataId and set private key + bytes16 dataIDValue = bytes16(0x1234567890abcdef1234567890abcdef); // Ensure it's 16 bytes + Suave.DataId dataID = Suave.DataId.wrap(dataIDValue); + emitter.setPrivateKey(dataID); + + // Assertion to check if private key was set + // Note: Requires getter for privateKeyDataID or event validation + bytes16 expectedDataIDBytes = Suave.DataId.unwrap(dataID); + bytes16 actualDataIDBytes = emitter.getPrivateKeyDataIDBytes(); + assertEq(actualDataIDBytes, expectedDataIDBytes, "Private key DataID should match"); + } + + function testSignL1MintApproval() public { + // can't actually test this atm + } + + function testEmitSignedMintApproval() public { + bytes memory message = "test message"; + + // Start recording logs + vm.recordLogs(); + + // Call the function to test + emitter.emitSignedMintApproval(message); + + // Get the recorded logs + Vm.Log[] memory logs = vm.getRecordedLogs(); + + // Ensure at least one event was emitted + assert(logs.length > 0); // This line is updated + + // Decode the event data - the structure depends on the event signature + (bytes memory loggedMessage) = abi.decode(logs[0].data, (bytes)); + + // Assertions to validate the event data + assertEq(loggedMessage, message, "Emitted message should match the input message"); + } +} diff --git a/examples/712/main.go b/examples/712/main.go new file mode 100644 index 0000000..1090fa9 --- /dev/null +++ b/examples/712/main.go @@ -0,0 +1,231 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/hex" + "fmt" + "io" + "log" + "math/big" + "net/http" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/flashbots/suapp-examples/framework" +) + +const ( + // Deployment specific + PRIV_KEY = "VALID_PRIVATE_KEY" // FILL IN TO RUN EXAMPLE + ETH_RPC_URL = "VALID_ETH_L1_RPC_URL" // FILL IN TO RUN EXAMPLE + + // Contract Specific + MINT_TYPEHASH = "0x686aa0ee2a8dd75ace6f66b3a5e79d3dfd8e25e05a5e494bb85e72214ab37880" + DOMAIN_SEPARATOR = "0x617661b7ab13ce21150e0a39abe5834762b356e3c643f10c28a3c9331025604a" + ETH_CHAIN_ID = 5 + NFTEE_TOKEN_ID = 1 +) + +func main() { + // create private key to be used on SUAVE and Eth L1 + privKey := framework.NewPrivKeyFromHex("VALID_PRIVATE_KEY") + fmt.Printf("SUAVE Signer Address: %s\n", privKey.Address()) + + // Deploy SUAVE L1 Contract + suaveContractAddress, suaveTxHash, suaveSig := deploySuaveContract(privKey) + + fmt.Printf("SUAVE Contract deployed at: %s\n", suaveContractAddress.Hex()) + fmt.Printf("SUAVE Transaction Hash: %s\n", suaveTxHash.Hex()) + + // Deploy Ethereum L1 Contract and Mint NFT + ethContractAddress, ethTxHash, ok := deployEthContractAndMint(privKey.Address(), suaveSig, privKey.Priv) + + fmt.Printf("Ethereum Contract deployed at: %s\n", ethContractAddress.Hex()) + fmt.Printf("Ethereum Transaction Hash: %s\n", ethTxHash.Hex()) + + // Check if NFT was minted + if !ok { + panic("NFTEE minting on L1 failed") + } + +} + +func deploySuaveContract(privKey *framework.PrivKey) (common.Address, common.Hash, []byte) { + relayerURL := "localhost:1234" + go func() { + log.Fatal(http.ListenAndServe(relayerURL, &relayHandlerExample{})) + }() + + fr := framework.New() + contract := fr.DeployContract("712Emitter.sol/Emitter.json") + + addr := privKey.Address() + fundBalance := big.NewInt(100000000000000000) + fr.FundAccount(addr, fundBalance) + + contractAddr := contract.Ref(privKey) + skHex := hex.EncodeToString(crypto.FromECDSA(privKey.Priv)) + + _ = contractAddr.SendTransaction("updatePrivateKey", []interface{}{}, []byte(skHex)) + + tokenId := big.NewInt(NFTEE_TOKEN_ID) + + // Call createEIP712Digest to generate digestHash + digestHash := contract.Call("createEIP712Digest", []interface{}{tokenId, addr}) + + // Call signL1MintApproval and compare signatures + receipt := contractAddr.SendTransaction("signL1MintApproval", []interface{}{tokenId, addr}, nil) + nfteeApprovalEvent := &NFTEEApproval{} + if err := nfteeApprovalEvent.Unpack(receipt.Logs[0]); err != nil { + panic(err) + } + + // Sign the digest in Go + goSignature, err := crypto.Sign(digestHash[0].([]byte), privKey.Priv) + if err != nil { + log.Fatalf("Error signing message: %v", err) + } + + if !bytes.Equal(goSignature, nfteeApprovalEvent.SignedMessage) { + log.Fatal("Signed messages do not match") + } else { + fmt.Println("Signed messages match") + } + + // Extract the signature from SUAVE transaction logs + var signature []byte + if len(receipt.Logs) > 0 { + nfteeApprovalEvent := &NFTEEApproval{} + if err := nfteeApprovalEvent.Unpack(receipt.Logs[0]); err != nil { + log.Fatalf("Error unpacking logs: %v", err) + } + signature = nfteeApprovalEvent.SignedMessage + } + + return contractAddr.Address(), receipt.TxHash, signature +} + +func deployEthContractAndMint(suaveSignerAddr common.Address, suaveSignature []byte, privKey *ecdsa.PrivateKey) (common.Address, common.Hash, bool) { + ethClient, err := ethclient.Dial(ETH_RPC_URL) + if err != nil { + log.Fatalf("Failed to connect to the Ethereum client: %v", err) + } + + auth, err := bind.NewKeyedTransactorWithChainID(privKey, big.NewInt(ETH_CHAIN_ID)) // Chain ID for Goerli + if err != nil { + log.Fatalf("Failed to create authorized transactor: %v", err) + } + + artifact, err := framework.ReadArtifact("NFTEE.sol/SuaveNFT.json") + if err != nil { + panic(err) + } + + // Deploy contract with SUAVE signer address as a constructor argument + _, tx, _, err := bind.DeployContract(auth, *artifact.Abi, artifact.Code, ethClient, suaveSignerAddr) + if err != nil { + log.Fatalf("Failed to deploy new contract: %v", err) + } + + // Wait for the transaction to be included + fmt.Println("Waiting for contract deployment transaction to be included...") + receipt, err := bind.WaitMined(context.Background(), ethClient, tx) + if err != nil { + log.Fatalf("Error waiting for contract deployment transaction to be included: %v", err) + } + + if receipt.Status != types.ReceiptStatusSuccessful { + log.Printf("Contract deployment transaction failed: receipt status %v", receipt.Status) + return common.Address{}, common.Hash{}, false + } + + fmt.Println("Contract deployed, address:", receipt.ContractAddress.Hex()) + + // Mint NFT with the signature from SUAVE + tokenId := big.NewInt(NFTEE_TOKEN_ID) + isMinted, err := mintNFTWithSignature(receipt.ContractAddress, tokenId, suaveSignerAddr, suaveSignature, ethClient, auth, artifact.Abi) + if err != nil { + log.Printf("Error minting NFT: %v", err) + return receipt.ContractAddress, tx.Hash(), false + } + + return receipt.ContractAddress, tx.Hash(), isMinted +} + +func mintNFTWithSignature(contractAddress common.Address, tokenId *big.Int, recipient common.Address, signature []byte, client *ethclient.Client, auth *bind.TransactOpts, sabi *abi.ABI) (bool, error) { + + contract := bind.NewBoundContract(contractAddress, *sabi, client, client, client) + + if len(signature) != 65 { + return false, fmt.Errorf("signature must be 65 bytes long") + } + + // Extract r, s, and v + r := [32]byte{} + s := [32]byte{} + copy(r[:], signature[:32]) // First 32 bytes + copy(s[:], signature[32:64]) // Next 32 bytes + + v := signature[64] // Last byte + + // Ethereum signatures are [R || S || V] + // Where V is 0 or 1, it must be adjusted to 27 or 28 + if v == 0 || v == 1 { + v += 27 + } + + tx, err := contract.Transact(auth, "mintNFTWithSignature", tokenId, recipient, v, r, s) + if err != nil { + return false, fmt.Errorf("mintNFTWithSignature transaction failed: %v", err) + } + + // Wait for the transaction to be included + fmt.Println("Waiting for mint transaction to be included...") + receipt, err := bind.WaitMined(context.Background(), client, tx) + if err != nil { + return false, fmt.Errorf("waiting for mint transaction mining failed: %v", err) + } + + if receipt.Status != types.ReceiptStatusSuccessful { + log.Printf("Mint transaction failed: receipt status %v", receipt.Status) + return false, nil + } + + fmt.Println("NFT minted successfully, transaction hash:", receipt.TxHash.Hex()) + return true, nil +} + +// NFTEEApprovalEventABI is the ABI of the NFTEEApproval event. +var NFTEEApprovalEventABI = `[{"anonymous":false,"inputs":[{"indexed":false,"internalType":"bytes","name":"signedMessage","type":"bytes"}],"name":"NFTEEApproval","type":"event"}]` + +type NFTEEApproval struct { + SignedMessage []byte +} + +func (na *NFTEEApproval) Unpack(log *types.Log) error { + eventABI, err := abi.JSON(strings.NewReader(NFTEEApprovalEventABI)) + if err != nil { + return err + } + + return eventABI.UnpackIntoInterface(na, "NFTEEApproval", log.Data) +} + +type relayHandlerExample struct { +} + +func (rl *relayHandlerExample) ServeHTTP(w http.ResponseWriter, r *http.Request) { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + + fmt.Println(string(bodyBytes)) +} diff --git a/framework/framework.go b/framework/framework.go index a276787..c0244cf 100644 --- a/framework/framework.go +++ b/framework/framework.go @@ -116,8 +116,8 @@ type Contract struct { Abi *abi.ABI } -func (c *Contract) Call(methodName string) []interface{} { - input, err := c.Abi.Pack(methodName) +func (c *Contract) Call(methodName string, args []interface{}) []interface{} { + input, err := c.abi.Pack(methodName, args...) if err != nil { panic(err) } diff --git a/go.mod b/go.mod index c90863f..68ea701 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/flashbots/suapp-examples -go 1.21.3 +go 1.21 + +toolchain go1.21.3 replace github.com/ethereum/go-ethereum => github.com/flashbots/suave-geth v0.1.3 diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..4b47a19 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit 4b47a19038b798b4a33d9749d25e570443520647