Привет Хабр.
В прошлой статье, мы научились генерировать изображения для NFT коллекции, а сегодня я хочу рассказать, как и куда можно опубликовать сгенерированные изображения и их метаданные.
Потратив достаточно много времени на изучение существующих NFT проектов, я был свидетелем того, как разработчики публикуют свои изображения для NFT коллекций в централизованные файловые системы, такие как AWS s3, что вызывало у меня некоторое недоумение.
На мой субъективный взгляд, главный посыл NFT токенов, именно в том, что токен и его содержимое - никто и никогда не сможет изменить, и соответственно при разработке мы должны быть максимально абстрагирован от централизованных систем. Именно поэтому, публиковать все наши файлы, мы будем в децентрализованное хранилище - IPFS.
Итак, чтобы гарантировать, что наш контент останется сохраненным (закрепленным), мы должны запустить свои собственные IPFS узлы. Конечно мы можем настроить IPFS узлы сами, но гораздо удобней использовать готовый сервис, такой как Pinata. Далее статья будет посвящена тому, как работать с сервисом Pinata для публикаций NFT медиа файлов.
Согласно документации, для закрепления файлов мы должны вызвать ендпоинт pinFileToIPFS. Давайте напишем код, который собственно и будет это делать:
const (
pinFileURL = "https://api.pinata.cloud/pinning/pinFileToIPFS"
)
func (s *service) pinFile(fileName string, data []byte, wrapWithDirectory bool) (string, error) {
type pinataResponse struct {
IPFSHash string `json:"IpfsHash"`
PinSize int `json:"PinSize"`
Timestamp string `json:"Timestamp"`
}
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)
// this step is very important
fileWriter, err := bodyWriter.CreateFormFile("file", fileName)
if err != nil {
return "", err
}
if _, err := fileWriter.Write(data); err != nil {
return "", err
}
// Wrap your content inside of a directory when adding to IPFS.
// This allows users to retrieve content via a filename instead of just a hash.
if wrapWithDirectory {
fileWriter, err = bodyWriter.CreateFormField("pinataOptions")
if err != nil {
return "", err
}
if _, err := fileWriter.Write([]byte(`{"wrapWithDirectory": true}`)); err != nil {
return "", err
}
}
contentType := bodyWriter.FormDataContentType()
bodyWriter.Close()
req, err := http.NewRequest("POST", pinFileURL, bodyBuf)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("pinata_api_key", s.params.APIKey)
req.Header.Set("pinata_secret_api_key", s.params.SecretKey)
// Do request.
var (
retries = 3
resp *http.Response
)
for retries > 0 {
resp, err = s.client.Do(req)
if err != nil {
retries -= 1
} else {
break
}
}
if resp == nil {
return "", fmt.Errorf("Failed to upload files to ipfs, err: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errMsg := make([]byte, resp.ContentLength)
_, _ = resp.Body.Read(errMsg)
return "", fmt.Errorf("Failed to upload file, response code %d, msg: %s", resp.StatusCode, string(errMsg))
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
pinataResp := pinataResponse{}
err = json.NewDecoder(bytes.NewReader(body)).Decode(&pinataResp)
if err != nil {
return "", fmt.Errorf("Failed to decode json, err: %v", err)
}
if len(pinataResp.IPFSHash) == 0 {
return "", errors.New("Ipfs hash not found in the response body")
}
return pinataResp.IPFSHash, nil
}
При детальном рассмотрении кода, можно заметить, что для вызовов ендпоинтов pinata, необходимо получить API key + secret key.
На успешно загруженный файл, мы получим IPFS-хэш файла, следующего вида: QmPbxeGcXhYQQNgsC6a36dDyYUcHgMLnGKnF8pVFmGsvqi
Теперь, когда у нас есть метод для загрузки файлов в IPFS -pinFile
, нам нужно создать другой метод, который будет:
считывать *.png файл с указанной директории
загружать *.png файл в IPFS
считывать *.json файл с описание аттрибутов (traits)
создавать финальный *.json файл с описанием ERC-721 метаданных
Итак, давайте приступим к написанию кода:
func (s *service) uploadImage(p *uploadImageParam) error {
// 1. Read the image along the specified path.
imgBytes, err := ioutil.ReadFile(p.path)
if err != nil {
return err
}
// 2. Upload the image to the IPFS
ipfsImageHash, err := s.pinFile(p.fileName, imgBytes, false)
if err != nil {
return err
}
// 3. Read NFT token attributes (traits)
traitsBytes, err := ioutil.ReadFile(strings.ReplaceAll(p.path, ".png", ".json"))
if err != nil {
return err
}
traits := []*domain.ERC721Trait{}
if err := json.Unmarshal(traitsBytes, &traits); err != nil {
return err
}
// 4. Create ERC-721 metadata file.
erc721Metadata := &domain.ERC721Metadata{
Image: fmt.Sprintf("ipfs://%s", ipfsImageHash),
Attributes: traits,
}
erc721MetadataBytes, err := json.Marshal(erc721Metadata)
if err != nil {
return err
}
var (
key = strings.TrimSuffix(p.fileName, filepath.Ext(p.fileName))
)
metadataFile, err := os.Create(fmt.Sprintf("%s/%d.%s.json", s.params.OutputDirectory, p.number, key))
if err != nil {
return err
}
defer metadataFile.Close()
_, err = metadataFile.Write(erc721MetadataBytes)
return err
}
Такие структуры как ERC721Trait
и ERC721Metadata
были подробно описаны в предыдущей статье.
На выходе у нас получиться файл для ERC-721 метаданных, следующего вида:
{
"image":"ipfs://QmPbxeGcXhYQQNgsC6a36dDyYUcHgMLnGKnF8pVFmGsvqi",
"attributes":[
{
"trait_type":"Mouth",
"value":"Grin"
},
{
"trait_type":"Clothes",
"value":"Vietnam Jacket"
},
{
"trait_type":"Background",
"value":"Orange"
},
{
"trait_type":"Eyes",
"value":"Blue Beams"
},
{
"trait_type":"Fur",
"value":"Robot"
}
]
}
В следующей статье мы приступим к созданию смарт-контракта на Ethereum.
P.S.Весь исходный код можно посмотреть на github.