Solana Blinks with Go
Brymes
Posted on August 23, 2024
Blinks are metadata-rich links that represent and enable on-chain activities throughout the Solana ecosystem without needing to navigate to a different app or webpage.
Blinks supports a wide range of activities enabled by Solana Actions
and primarily allow users to interact with the blockchain through social media and other off-chain platforms.
Use-Cases include:
- NFT trading & minting,
- Donations,
- Crowd funding,
- Token swaps,
- Lottery/Casino apps and much more
In this article, we shall explore a simple Blink app focused on minting NFTs using Go. Whilst the article is Go focused, the core concepts apply to any Blink app. You can find the complete code on GitHub.
We'll begin by setting up a basic web server using the Gin framework, along with the necessary CORS configuration as defined by the specification. Also we’ll define some endpoints that will be discussed in detail below.
func main() {
var (
corsConfig = cors.DefaultConfig()
router = gin.Default()
port = os.Getenv("PORT")
)
corsConfig.AllowAllOrigins = true
corsConfig.AddAllowHeaders([]string{"Content-Length", "Content-Type", "Access-Control-Allow-Origin"}...)
corsConfig.AddAllowMethods([]string{"GET", "POST", "OPTIONS"}...)
router.Use(cors.New(corsConfig))
router.GET("/actions.json", app.ActionsRulesHandler)
router.GET("/api/actions/mint_nft", app.GetActionsHandler)
router.OPTIONS("/api/actions/mint_nft", app.OptionsHandler)
router.POST("/api/actions/mint_nft", app.PostHandler)
log.Println("StickyLabs Blink Active 🚀")
if port == "" {
port = "8081"
}
log.Println("Server is running")
err := router.Run(fmt.Sprintf(":%v", port))
if err != nil {
log.Fatal(err)
return
}
}
The core of any Blinks application lies in replicating the Solana Actions API Spec. Below is a visual representation of how Blinks work.
Action Handlers
Blinks on Solana uses an Action URL scheme to provide a metadata-rich link, enabling various on-chain activities. This section outlines the primary handlers responsible for processing the mint NFT action on the /api/actions/mint_nft
- GET Handler : Returns Metadata, supported actions and required parameters.
type ActionGetResponse struct {
Title string `json:"title"`
Icon string `json:"icon"`
Description string `json:"description"`
Label string `json:"label"`
Links struct {
Actions []Actions `json:"actions"`
} `json:"links"`
}
type Actions struct {
Label string `json:"label"`
Href string `json:"href"`
Parameters []ActionParameters `json:"parameters,omitempty"`
}
type ActionParameters struct {
Name string `json:"name"`
Label string `json:"label"`
Required bool `json:"required"`
}
func GetActionsHandler(c *gin.Context) {
payload := ActionGetResponse{
Title: "Actions Example - Mint NFT",
Icon: c.Request.URL.Scheme + "://" + c.Request.URL.Host + "/solana_devs.jpg",
Description: "Transfer SOL to another Solana wallet",
Label: "Transfer",
}
payload.Links.Actions = []Actions{
{"Mint NFT", "/api/actions/mint_nft", []ActionParameters{
{"name", "Enter the Name of the NFT", true},
{"symbol", "Enter the Symbol of the NFT", true},
{"uri", "Enter the Uri of the NFT", true},
}},
}
c.JSON(http.StatusOK, payload)
}
- OPTIONS : The OPTIONS handler handles CORS requirements, ensuring compatibility with browsers and other client request mechanisms.
var ACTIONS_CORS_HEADERS = map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
func OptionsHandler(c *gin.Context) {
for key, value := range ACTIONS_CORS_HEADERS {
c.Header(key, value)
}
c.Status(http.StatusOK)
}
- POST Handler :The POST handler accepts query parameters, parses the account in base58 provided as JSON, and returns a base64-encoded serialized transaction along with a message for the user to sign and execute.
type MintNFTParams struct {
Name string `form:"name" binding:"required"`
Symbol string `form:"symbol" binding:"required"`
URI string `form:"uri" binding:"required"`
}
// { "account": "<account>" } //JSON
type ActionPostRequest struct {
Account string `json:"account"`
}
type ActionPostResponse struct {
Fields ActionPostResponseFields `json:"fields"`
}
type ActionPostResponseFields struct {
Transaction string `json:"transaction"`
Message string `json:"message"`
}
func PostHandler(c *gin.Context) {
var (
qPayload MintNFTParams
request ActionPostRequest
response ActionPostResponse
)
if err := c.ShouldBindQuery(&qPayload); err != nil {
c.JSON(http.StatusBadRequest, ActionError{Message: "Invalid Query Params"})
return
}
if err := c.ShouldBindJSON(&request); err != nil {
log.Println(err)
c.JSON(http.StatusBadRequest, ActionError{Message: "Invalid request"})
return
}
account, err := types.AccountFromBase58(request.Account)
if err != nil {
log.Println(err)
c.JSON(http.StatusBadRequest, ActionError{Message: "Invalid request; Error validating account"})
return
}
response.Fields.Transaction, response.Fields.Message = mintNFT(qPayload, account)
c.JSON(http.StatusOK, response)
}
- Minting NFTs
The mintNFT
function leverages Solana-Go-SDK for minting NFTs, with few adjustments.
func mintNFT(metadata MintNFTParams, feePayer types.Account) (transaction, message string) {
message = fmt.Sprintf("Mint NFT %s", metadata.Name)
c := client.NewClient(rpc.DevnetRPCEndpoint)
log.Println(metadata)
mint := types.NewAccount()
fmt.Printf("NFT: %v\n", mint.PublicKey.ToBase58())
collection := types.NewAccount()
fmt.Printf("collection: %v\n", collection.PublicKey.ToBase58())
ata, _, err := common.FindAssociatedTokenAddress(feePayer.PublicKey, mint.PublicKey)
if err != nil {
log.Fatalf("failed to find a valid ata, err: %v", err)
}
tokenMetadataPubkey, err := token_metadata.GetTokenMetaPubkey(mint.PublicKey)
if err != nil {
log.Fatalf("failed to find a valid token metadata, err: %v", err)
}
tokenMasterEditionPubkey, err := token_metadata.GetMasterEdition(mint.PublicKey)
if err != nil {
log.Fatalf("failed to find a valid master edition, err: %v", err)
}
mintAccountRent, err := c.GetMinimumBalanceForRentExemption(context.Background(), token.MintAccountSize)
if err != nil {
log.Fatalf("failed to get mint account rent, err: %v", err)
}
recentBlockhashResponse, err := c.GetLatestBlockhash(context.Background())
if err != nil {
log.Fatalf("failed to get recent blockhash, err: %v", err)
}
tx, err := types.NewTransaction(types.NewTransactionParam{
Signers: []types.Account{mint, feePayer},
Message: types.NewMessage(types.NewMessageParam{
FeePayer: feePayer.PublicKey,
RecentBlockhash: recentBlockhashResponse.Blockhash,
Instructions: []types.Instruction{
system.CreateAccount(system.CreateAccountParam{
From: feePayer.PublicKey,
New: mint.PublicKey,
Owner: common.TokenProgramID,
Lamports: mintAccountRent,
Space: token.MintAccountSize,
}),
token.InitializeMint(token.InitializeMintParam{
Decimals: 0,
Mint: mint.PublicKey,
MintAuth: feePayer.PublicKey,
FreezeAuth: &feePayer.PublicKey,
}),
token_metadata.CreateMetadataAccountV3(token_metadata.CreateMetadataAccountV3Param{
Metadata: tokenMetadataPubkey,
Mint: mint.PublicKey,
MintAuthority: feePayer.PublicKey,
Payer: feePayer.PublicKey,
UpdateAuthority: feePayer.PublicKey,
UpdateAuthorityIsSigner: true,
IsMutable: true,
Data: token_metadata.DataV2{
Name: metadata.Name,
Symbol: metadata.Symbol,
Uri: metadata.URI,
SellerFeeBasisPoints: 100,
Creators: &[]token_metadata.Creator{
// tODO rede && Minter
{
Address: feePayer.PublicKey,
Verified: true,
Share: 100,
},
},
Collection: &token_metadata.Collection{
Verified: false,
Key: collection.PublicKey,
},
Uses: nil,
},
CollectionDetails: nil,
}),
associated_token_account.Create(associated_token_account.CreateParam{
Funder: feePayer.PublicKey,
Owner: feePayer.PublicKey,
Mint: mint.PublicKey,
AssociatedTokenAccount: ata,
}),
token.MintTo(token.MintToParam{
Mint: mint.PublicKey,
To: ata,
Auth: feePayer.PublicKey,
Amount: 1,
}),
token_metadata.CreateMasterEditionV3(token_metadata.CreateMasterEditionParam{
Edition: tokenMasterEditionPubkey,
Mint: mint.PublicKey,
UpdateAuthority: feePayer.PublicKey,
MintAuthority: feePayer.PublicKey,
Metadata: tokenMetadataPubkey,
Payer: feePayer.PublicKey,
MaxSupply: pointer.Get[uint64](0),
}),
},
}),
})
if err != nil {
log.Fatalf("failed to new a tx, err: %v", err)
}
serialized, err := tx.Serialize()
if err != nil {
log.Fatal(err)
}
transaction = base64.StdEncoding.EncodeToString(serialized)
return
}
- Error Handling: Actions should return user friendly errors in the format below.
// { "message" : "Insert Error Message" } //JSON
type ActionError struct {
Message string `json:"message"`
}
- actions.json: The actions.json file should be stored at the root of the domain. It provides instructions to clients on what URLs support Solana Actions and provide mappings that can be used to perform GET requests to the Blink app. For simplicity we would be returning a JSON response from the url path
func ActionsRulesHandler(c *gin.Context) {
payload := gin.H{
"rules": []gin.H{
{
"pathPattern": "/*",
"apiPath": "/api/actions/*",
},
{
"pathPattern": "/api/actions/**",
"apiPath": "/api/actions/**",
},
},
}
c.JSON(http.StatusOK, payload)
}
Testing your Blink
After deploying your app, you could use the Blinks Inspector app to test.
Conclusion
I hope this article provides a practical introduction to building Blinks applications on Solana using Go. Entire code can be found here.
For a deeper dive into the Solana Actions framework, and detailed documentation, check out Solana’s official resources
Posted on August 23, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.