diff --git a/cip27.go b/cip27.go new file mode 100644 index 0000000..46d99c1 --- /dev/null +++ b/cip27.go @@ -0,0 +1,170 @@ +// Copyright 2024 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "encoding/json" + "errors" + "strconv" + + "github.com/go-playground/validator/v10" +) + +// Cip27Metadata is the top-level container for royalties data under the "777" tag. +type Cip27Metadata struct { + Num777 Cip777 `cbor:"777,keyasint" json:"777" validate:"required"` +} + +// Cip777 represents the actual royalty info. It handles both modern "rate" and legacy "pct." +type Cip777 struct { + // Internally, Rate is our main numeric string (e.g., "0.20"). + Rate string `json:"-"` + + // We store the raw strings for either "pct" or "rate." + // We only expose "rate" in the final JSON. + pctRaw *string + rateRaw *string + + // 'addr' can be either a string or array of strings, so we wrap it in AddrField. + Addr AddrField `cbor:"addr" json:"addr" validate:"required"` +} + +func (c *Cip27Metadata) UnmarshalJSON(data []byte) error { + // Unmarshal into a map so we can check for "777" explicitly. + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + // Verify the "777" key exists at the top level. + val, ok := raw["777"] + if !ok { + return errors.New(`missing "777" key in CIP-27 metadata`) + } + + // Unmarshal the contents of "777" into c.Num777. + if err := json.Unmarshal(val, &c.Num777); err != nil { + return err + } + + // Run validation (so any "required" or numeric checks fail immediately). + if err := c.Validate(); err != nil { + return err + } + return nil +} + +// UnmarshalJSON checks which field ("rate" or "pct") is present, giving precedence to "rate." +func (c *Cip777) UnmarshalJSON(data []byte) error { + // Temporary structure for decoding both fields plus 'addr.' + var raw struct { + Pct *string `json:"pct"` + Rate *string `json:"rate"` + Addr AddrField `json:"addr"` + } + + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + switch { + case raw.Rate != nil: + c.Rate = *raw.Rate + case raw.Pct != nil: + c.Rate = *raw.Pct + default: + return errors.New("missing both 'rate' and 'pct' fields") + } + + c.pctRaw = raw.Pct + c.rateRaw = raw.Rate + c.Addr = raw.Addr + return nil +} + +// MarshalJSON outputs "rate" as our canonical field. +func (c Cip777) MarshalJSON() ([]byte, error) { + // We only expose "rate" in the final JSON. + var out struct { + Rate string `json:"rate"` + Addr AddrField `json:"addr"` + } + out.Rate = c.Rate + out.Addr = c.Addr + return json.Marshal(out) +} + +// AddrField supports either a single string or an array of strings in JSON. +type AddrField struct { + Addresses []string +} + +// UnmarshalJSON attempts to parse 'addr' as a single string; if that fails, it tries an array of strings. +func (af *AddrField) UnmarshalJSON(data []byte) error { + var single string + if err := json.Unmarshal(data, &single); err == nil { + af.Addresses = []string{single} + return nil + } + + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + af.Addresses = arr + return nil + } + + return errors.New("addr must be a string or an array of strings") +} + +// MarshalJSON returns 'addr' as a single string if only one address is present, otherwise an array. +func (af AddrField) MarshalJSON() ([]byte, error) { + if len(af.Addresses) == 1 { + return json.Marshal(af.Addresses[0]) + } + return json.Marshal(af.Addresses) +} + +// NewCip27Metadata creates a new CIP-027 metadata object with the given rate and addresses. +func NewCip27Metadata(rate string, addresses []string) (*Cip27Metadata, error) { + meta := &Cip27Metadata{ + Num777: Cip777{ + Rate: rate, + Addr: AddrField{Addresses: addresses}, + }, + } + if err := meta.Validate(); err != nil { + return nil, err + } + return meta, nil +} + +// Validate checks that Rate is within [0..1] and there's at least one address. +func (c *Cip27Metadata) Validate() error { + validate := validator.New() + if err := validate.Struct(c); err != nil { + return err + } + val, err := strconv.ParseFloat(c.Num777.Rate, 64) + if err != nil { + return errors.New("rate must be a valid floating point number") + } + if val < 0 || val > 1 { + return errors.New("rate must be between 0.0 and 1.0") + } + if len(c.Num777.Addr.Addresses) == 0 { + return errors.New("at least one address is required") + } + return nil +} diff --git a/cip27_test.go b/cip27_test.go new file mode 100644 index 0000000..ba62f1e --- /dev/null +++ b/cip27_test.go @@ -0,0 +1,297 @@ +// Copyright 2024 Blink Labs Software +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package models + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewCip27Metadata_Success(t *testing.T) { + // Single address, valid rate. + meta, err := NewCip27Metadata("0.25", []string{"addr1xy..."}) + require.NoError(t, err) + require.Equal(t, "0.25", meta.Num777.Rate) + require.Len(t, meta.Num777.Addr.Addresses, 1) +} + +func TestNewCip27Metadata_MultipleAddresses(t *testing.T) { + addrs := []string{"addr1abc", "addr2def"} + meta, err := NewCip27Metadata("0.25", addrs) + require.NoError(t, err) + require.True(t, reflect.DeepEqual(addrs, meta.Num777.Addr.Addresses)) +} + +func TestRateBoundaries(t *testing.T) { + // Valid boundary + _, err := NewCip27Metadata("1.0", []string{"addr1..."}) + require.NoError(t, err) + + // Out-of-range: 1.1 + _, err = NewCip27Metadata("1.1", []string{"addr1..."}) + require.Error(t, err) + + // Negative + _, err = NewCip27Metadata("-0.1", []string{"addr1..."}) + require.Error(t, err) + + // Not a float + _, err = NewCip27Metadata("abc", []string{"addr1..."}) + require.Error(t, err) +} + +func TestUnmarshal_LegacyPct(t *testing.T) { + input := `{ + "777": { + "pct": "0.125", + "addr": "addr1legacy..." + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.NoError(t, err) + require.Equal(t, "0.125", meta.Num777.Rate) + require.Equal(t, "addr1legacy...", meta.Num777.Addr.Addresses[0]) +} + +func TestUnmarshal_Rate(t *testing.T) { + input := `{ + "777": { + "rate": "0.20", + "addr": ["addr1qmodern","addr2other"] + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.NoError(t, err) + require.Equal(t, "0.20", meta.Num777.Rate) + require.Len(t, meta.Num777.Addr.Addresses, 2) +} + +func TestUnmarshal_BothPctAndRate(t *testing.T) { + // If both are present, 'rate' overrides 'pct'. + input := `{ + "777": { + "pct": "0.100", + "rate": "0.200", + "addr": "addr1override" + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.NoError(t, err) + require.Equal(t, "0.200", meta.Num777.Rate) + require.Equal(t, "addr1override", meta.Num777.Addr.Addresses[0]) +} + +func TestUnmarshal_MissingPctRate(t *testing.T) { + // Neither 'pct' nor 'rate' is present -> error + input := `{"777":{"addr":"addr1only"}}` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err) +} + +func TestAddrField_SingleString(t *testing.T) { + var af AddrField + err := json.Unmarshal([]byte(`"addrSingle"`), &af) + require.NoError(t, err) + require.Equal(t, []string{"addrSingle"}, af.Addresses) +} + +func TestAddrField_StringArray(t *testing.T) { + var af AddrField + err := json.Unmarshal([]byte(`["addr1","addr2"]`), &af) + require.NoError(t, err) + require.Equal(t, []string{"addr1", "addr2"}, af.Addresses) +} + +func TestAddrField_InvalidType(t *testing.T) { + var af AddrField + err := json.Unmarshal([]byte("123"), &af) + require.Error(t, err) +} + +func TestAddrField_MarshalSingle(t *testing.T) { + single := AddrField{Addresses: []string{"addr1single"}} + b, err := json.Marshal(single) + require.NoError(t, err) + require.Equal(t, `"addr1single"`, string(b)) +} + +func TestAddrField_MarshalArray(t *testing.T) { + multiple := AddrField{Addresses: []string{"addr1", "addr2"}} + b, err := json.Marshal(multiple) + require.NoError(t, err) + require.Equal(t, `["addr1","addr2"]`, string(b)) +} + +func TestUnmarshal_SingleAddressExample(t *testing.T) { + input := `{ + "777": { + "rate": "0.2", + "addr": "addr1v9nevxg9wunfck0gt7hpxuy0elnqygglme3u6l3nn5q5gnq5dc9un" + } + }` + + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.NoError(t, err, "Should unmarshal single address JSON without error") + + // Check that 'rate' is "0.2" and that we have exactly one address in our slice + require.Equal(t, "0.2", meta.Num777.Rate) + require.Len(t, meta.Num777.Addr.Addresses, 1) + require.Equal(t, + "addr1v9nevxg9wunfck0gt7hpxuy0elnqygglme3u6l3nn5q5gnq5dc9un", + meta.Num777.Addr.Addresses[0], + ) +} + +func TestUnmarshal_ArrayAddressExample(t *testing.T) { + input := `{ + "777": { + "rate": "0.2", + "addr": [ + "addr1q8g3dv6ptkgsafh7k5muggrvfde2szzmc2mqkcxpxn7c63l9znc9e3xa82h", + "pf39scc37tcu9ggy0l89gy2f9r2lf7husfvu8wh" + ] + } + }` + + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.NoError(t, err, "Should unmarshal multiple address JSON without error") + + // Check that 'rate' is "0.2" and that we have two addresses in our slice + require.Equal(t, "0.2", meta.Num777.Rate) + require.Len(t, meta.Num777.Addr.Addresses, 2) + require.Equal(t, + "addr1q8g3dv6ptkgsafh7k5muggrvfde2szzmc2mqkcxpxn7c63l9znc9e3xa82h", + meta.Num777.Addr.Addresses[0], + ) + require.Equal(t, + "pf39scc37tcu9ggy0l89gy2f9r2lf7husfvu8wh", + meta.Num777.Addr.Addresses[1], + ) +} + +func TestUnmarshal_Cip27Metadata_InvalidRootType(t *testing.T) { + // For example: top-level is a string, not an object. + input := `"this is not an object"` + + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Should fail unmarshaling top-level non-object JSON") +} + +func TestUnmarshal_Cip27Metadata_No777Key(t *testing.T) { + input := `{"someOtherKey":{"rate":"0.2"}}` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Should fail if '777' key is missing") +} + +func TestUnmarshal_EmptyRateString(t *testing.T) { + input := `{ + "777": { + "rate": "", + "addr": "addr1..." + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Empty rate string should not parse as a valid float") +} + +func TestUnmarshal_NoAddrKey(t *testing.T) { + input := `{ + "777": { + "rate": "0.2" + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Should error because 'addr' key is required") +} + +func TestUnmarshal_AddrAsObject(t *testing.T) { + input := `{ + "777": { + "rate": "0.2", + "addr": { "some": "thing" } + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Should fail because 'addr' must be string or array of strings") +} + +func TestUnmarshal_EmptyAddrArray(t *testing.T) { + input := `{ + "777": { + "rate": "0.25", + "addr": [] + } + }` + + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Should fail because we have 0 addresses in 'addr'") + require.Contains(t, err.Error(), "at least one address is required") +} + +func TestUnmarshal_BothPctAndRate_RateEmpty(t *testing.T) { + input := `{ + "777": { + "pct": "0.1", + "rate": "", + "addr": "addr1..." + } + }` + var meta Cip27Metadata + err := json.Unmarshal([]byte(input), &meta) + require.Error(t, err, "Empty rate should fail even if pct is valid, because rate takes precedence") +} + +func TestCip27Metadata_MarshalJSON(t *testing.T) { + // Create a CIP-27 metadata object. + meta, err := NewCip27Metadata("0.25", []string{"addr1xy...", "addr2zzz"}) + require.NoError(t, err, "Should create CIP-27 metadata without error") + + data, err := json.Marshal(meta) + require.NoError(t, err, "Should marshal CIP-27 metadata to JSON without error") + + // Check that the resulting JSON contains expected fields/values. + var result map[string]interface{} + require.NoError(t, json.Unmarshal(data, &result), "Should unmarshal JSON back into map") + + // Expect top-level key "777" + topLevel, ok := result["777"].(map[string]interface{}) + require.True(t, ok, "Should have a '777' key in marshaled JSON") + + // Ensure "rate" is "0.25" + require.Equal(t, "0.25", topLevel["rate"], "Rate should match the input string") + + // Since addresses can be single string or an array, + // we expect them as an array (2 addresses). + addrs, ok := topLevel["addr"].([]interface{}) + require.True(t, ok, "Should have an array of addresses") + require.Len(t, addrs, 2, "Should have exactly 2 addresses") + require.Equal(t, "addr1xy...", addrs[0]) + require.Equal(t, "addr2zzz", addrs[1]) +} diff --git a/go.mod b/go.mod index 192ea84..febeabb 100644 --- a/go.mod +++ b/go.mod @@ -8,17 +8,21 @@ require ( github.com/blinklabs-io/gouroboros v0.105.1 github.com/fxamacker/cbor/v2 v2.7.0 github.com/go-playground/validator/v10 v10.23.0 + github.com/stretchr/testify v1.10.0 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 74b6e3c..961db15 100644 --- a/go.sum +++ b/go.sum @@ -32,5 +32,7 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=