diff --git a/account.go b/account.go index be7fc7e..a1e33c8 100644 --- a/account.go +++ b/account.go @@ -14,6 +14,7 @@ import ( type AccountAddress [32]byte +// TODO: find nicer naming for this? Move account to a package so this can be account.ONE ? Wrap in a singleton struct for Account.One ? var Account0x1 AccountAddress = AccountAddress{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1} // Returns whether the address is a "special" address. Addresses are considered @@ -52,6 +53,7 @@ func (aa *AccountAddress) Random() { rand.Read((*aa)[:]) } func (aa *AccountAddress) FromEd25519PubKey(pubkey ed25519.PublicKey) { + // TODO: Other SDK implementations have an internal AuthenticationKey type to wrap this. Maybe follow that pattern later? hasher := sha3.New256() hasher.Write(pubkey[:]) hasher.Write([]byte{0}) diff --git a/bcs.go b/bcs.go index b16152e..32a7a0f 100644 --- a/bcs.go +++ b/bcs.go @@ -150,6 +150,34 @@ func DeserializeSequence[T any](bcs *Deserializer) []T { return out } +// DeserializeMapToSlices returns two slices []K and []V of equal length that are equivalent to map[K]V but may represent types that are not valid Go map keys. +func DeserializeMapToSlices[K, V any](bcs *Deserializer) (keys []K, values []V) { + count := bcs.Uleb128() + keys = make([]K, 0, count) + values = make([]V, 0, count) + for _ = range count { + var nextk K + var nextv V + switch sv := any(&nextk).(type) { + case BCSStruct: + sv.UnmarshalBCS(bcs) + case *string: + *sv = bcs.ReadString() + } + switch sv := any(&nextv).(type) { + case BCSStruct: + sv.UnmarshalBCS(bcs) + case *string: + *sv = bcs.ReadString() + case *[]byte: + *sv = bcs.ReadBytes() + } + keys = append(keys, nextk) + values = append(values, nextv) + } + return +} + func BcsDeserialize(dest BCSStruct, bcsBlob []byte) error { bcs := Deserializer{ source: bcsBlob, diff --git a/client.go b/client.go index 078d089..3bd378f 100644 --- a/client.go +++ b/client.go @@ -256,13 +256,17 @@ func (rc *RestClient) Account(address AccountAddress, ledger_version ...int) (in // AccountResourceInfo is returned by #AccountResource() and #AccountResources() type AccountResourceInfo struct { - Type string `json:"type"` - Data map[string]any `json:"data"` // TODO: what are these? Build a struct. + // e.g. "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>" + Type string `json:"type"` + + // Decoded from Move contract data, could really be anything + Data map[string]any `json:"data"` } func (rc *RestClient) AccountResource(address AccountAddress, resourceType string, ledger_version ...int) (data map[string]any, err error) { au := rc.baseUrl // TODO: offer a list of known-good resourceType string constants + // TODO: set "Accept: application/x-bcs" and parse BCS objects for lossless (and faster) transmission au.Path = path.Join(au.Path, "accounts", address.String(), "resource", resourceType) if len(ledger_version) > 0 { params := url.Values{} @@ -288,6 +292,8 @@ func (rc *RestClient) AccountResource(address AccountAddress, resourceType strin return } +// AccountResources fetches resources for an account into a JSON-like map[string]any in AccountResourceInfo.Data +// For fetching raw Move structs as BCS, See #AccountResourcesBCS func (rc *RestClient) AccountResources(address AccountAddress, ledger_version ...int) (resources []AccountResourceInfo, err error) { au := rc.baseUrl au.Path = path.Join(au.Path, "accounts", address.String(), "resources") @@ -315,6 +321,64 @@ func (rc *RestClient) AccountResources(address AccountAddress, ledger_version .. return } +// DeserializeSequence[AccountResourceRecord](bcs) approximates the Rust side BTreeMap> +// They should BCS the same with a prefix Uleb128 length followed by (StructTag,[]byte) pairs. +type AccountResourceRecord struct { + // Account::Module::Name + Tag StructTag + + // BCS data as stored by Move contract + Data []byte +} + +func (aar *AccountResourceRecord) MarshalBCS(bcs *Serializer) { + aar.Tag.MarshalBCS(bcs) + bcs.WriteBytes(aar.Data) +} +func (aar *AccountResourceRecord) UnmarshalBCS(bcs *Deserializer) { + aar.Tag.UnmarshalBCS(bcs) + aar.Data = bcs.ReadBytes() +} + +func (rc *RestClient) GetBCS(getUrl string) (*http.Response, error) { + req, err := http.NewRequest("GET", getUrl, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/x-bcs") + return rc.client.Do(req) +} + +// AccountResourcesBCS fetches account resources as raw Move struct BCS blobs in AccountResourceRecord.Data []byte +func (rc *RestClient) AccountResourcesBCS(address AccountAddress, ledger_version ...int) (resources []AccountResourceRecord, err error) { + au := rc.baseUrl + au.Path = path.Join(au.Path, "accounts", address.String(), "resources") + if len(ledger_version) > 0 { + params := url.Values{} + params.Set("ledger_version", strconv.Itoa(ledger_version[0])) + au.RawQuery = params.Encode() + } + response, err := rc.GetBCS(au.String()) + if err != nil { + err = fmt.Errorf("GET %s, %w", au.String(), err) + return + } + if response.StatusCode >= 400 { + err = NewHttpError(response) + return + } + blob, err := io.ReadAll(response.Body) + if err != nil { + err = fmt.Errorf("error getting response data, %w", err) + return + } + response.Body.Close() + bcs := NewDeserializer(blob) + // See resource_test.go TestMoveResourceBCS + resources = DeserializeSequence[AccountResourceRecord](bcs) + return +} + // TransactionByHash gets info on a transaction // The transaction may be pending or recently committed. // @@ -437,8 +501,9 @@ func (rc *RestClient) Transactions(start *uint64, limit *uint64) (data []map[str return } -// Deprecated-ish, #SubmitTransaction() should be much faster and better in every way -func (rc *RestClient) TransactionEncode(request map[string]any) (data []byte, err error) { +// testing only +// There exists an aptos-node API for submitting JSON and having the node Rust code encode it to BCS, we should only use this for testing to validate our local BCS. Actual GO-SDK usage should use BCS encoding locally in Go code. +func (rc *RestClient) transactionEncode(request map[string]any) (data []byte, err error) { rblob, err := json.Marshal(request) if err != nil { return diff --git a/cmd/goclient/goclient.go b/cmd/goclient/goclient.go index 60eb606..d7b2f36 100644 --- a/cmd/goclient/goclient.go +++ b/cmd/goclient/goclient.go @@ -216,7 +216,9 @@ func main() { fmt.Fprintf(os.Stdout, "new account %s funded for %d, privkey = %s\n", bob.Address.String(), amount, hex.EncodeToString(bob.PrivateKey.(ed25519.PrivateKey)[:])) time.Sleep(2 * time.Second) - stxn, err := aptos.TransferTransaction(&client.RestClient, alice, bob.Address, 42) + + stxn, err := aptos.APTTransferTransaction(&client.RestClient, alice, bob.Address, 42) + maybefail(err, "could not make transfer txn, %s", err) slog.Debug("transfer", "stxn", stxn) result, err := client.RestClient.SubmitTransaction(stxn) diff --git a/resource.go b/resource.go new file mode 100644 index 0000000..698fd81 --- /dev/null +++ b/resource.go @@ -0,0 +1,57 @@ +package aptos + +type MoveResource struct { + Tag MoveStructTag + Value map[string]any // MoveStructValue // TODO: api/types/src/move_types.rs probably actually has more to say about what a MoveStructValue is, but at first read it effectively says map[string]any; there's probably convention elesewhere about what goes into those 'any' parts +} + +func (mr *MoveResource) MarshalBCS(bcs *Serializer) { + panic("TODO") +} +func (mr *MoveResource) UnmarshalBCS(bcs *Deserializer) { + mr.Tag.UnmarshalBCS(bcs) +} + +type MoveStructTag struct { + Address AccountAddress + Module string // TODO: IdentifierWrapper ? + Name string // TODO: IdentifierWrapper ? + GenericTypeParams []MoveType +} + +func (mst *MoveStructTag) MarshalBCS(bcs *Serializer) { + panic("TODO") +} +func (mst *MoveStructTag) UnmarshalBCS(bcs *Deserializer) { + mst.Address.UnmarshalBCS(bcs) + mst.Module = bcs.ReadString() + mst.Name = bcs.ReadString() + mst.GenericTypeParams = DeserializeSequence[MoveType](bcs) +} + +// enum +type MoveType uint8 + +const ( + MoveType_Bool MoveType = 0 + MoveType_U8 MoveType = 1 + MoveType_U16 MoveType = 2 + MoveType_U32 MoveType = 3 + MoveType_U64 MoveType = 4 + MoveType_U128 MoveType = 5 + MoveType_U256 MoveType = 6 + MoveType_Address MoveType = 7 + MoveType_Signer MoveType = 8 + MoveType_Vector MoveType = 9 // contains MoveType of items of vector + MoveType_MoveStructTag MoveType = 10 // contains a MoveStructTag + MoveType_GeneritTypeParam MoveType = 11 // contains a uint16 + MoveType_Reference MoveType = 12 // {mutable bool, to MoveType} + MoveType_Unparsable MoveType = 13 // contains a string +) + +func (mt *MoveType) MarshalBCS(bcs *Serializer) { + bcs.Uleb128(uint64(*mt)) +} +func (mt *MoveType) UnmarshalBCS(bcs *Deserializer) { + *mt = MoveType(bcs.Uleb128()) +} diff --git a/resource_test.go b/resource_test.go new file mode 100644 index 0000000..58c2a40 --- /dev/null +++ b/resource_test.go @@ -0,0 +1,36 @@ +package aptos + +import ( + "encoding/base64" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func decodeB64(x string) ([]byte, error) { + reader := strings.NewReader(x) + dec := base64.NewDecoder(base64.StdEncoding, reader) + return io.ReadAll(dec) +} + +func TestMoveResourceBCS(t *testing.T) { + // fetched from local aptos-node 20240501_152556 + // curl -o /tmp/ar_bcs --header "Accept: application/x-bcs" http://127.0.0.1:8080/v1/accounts/{addr}/resources + // base64 < /tmp/ar_bcs + b64text := "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBGNvaW4JQ29pblN0b3JlAQcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQphcHRvc19jb2luCUFwdG9zQ29pbgBpKsLrCwAAAAAAAgAAAAAAAAACAAAAAAAAANGdA6RyqwjAFP2cXRokfP3YJqHHNb55lM2GQFYwd6a7AAAAAAAAAAADAAAAAAAAANGdA6RyqwjAFP2cXRokfP3YJqHHNb55lM2GQFYwd6a7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEHYWNjb3VudAdBY2NvdW50AJMBINGdA6RyqwjAFP2cXRokfP3YJqHHNb55lM2GQFYwd6a7AAAAAAAAAAAEAAAAAAAAAAEAAAAAAAAAAAAAAAAAAADRnQOkcqsIwBT9nF0aJHz92CahxzW+eZTNhkBWMHemuwAAAAAAAAAAAQAAAAAAAADRnQOkcqsIwBT9nF0aJHz92CahxzW+eZTNhkBWMHemuwAA" + blob, err := decodeB64(b64text) + assert.NoError(t, err) + assert.NotNil(t, blob) + + bcs := NewDeserializer(blob) + //resources := DeserializeSequence[MoveResource](bcs) + //resourceKeys, resourceValues := DeserializeMap[StructTag, []byte](bcs) + // like client.go AccountResourcesBCS + resources := DeserializeSequence[AccountResourceRecord](bcs) + assert.NoError(t, bcs.Error()) + assert.Equal(t, 2, len(resources)) + assert.Equal(t, "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>", resources[0].Tag.String()) + assert.Equal(t, "0x1::account::Account", resources[1].Tag.String()) +} diff --git a/typetag.go b/typetag.go index 2e921a6..076675e 100644 --- a/typetag.go +++ b/typetag.go @@ -1,6 +1,9 @@ package aptos -import "fmt" +import ( + "fmt" + "strings" +) type TypeTagType uint64 @@ -21,6 +24,7 @@ const ( type TypeTagImpl interface { BCSStruct GetType() TypeTagType + String() string } type TypeTag struct { @@ -39,11 +43,35 @@ func (tt *TypeTag) UnmarshalBCS(bcs *Deserializer) { xt := &BoolTag{} xt.UnmarshalBCS(bcs) tt.Value = xt + case TypeTag_U8: + xt := &U8Tag{} + xt.UnmarshalBCS(bcs) + tt.Value = xt + case TypeTag_U16: + xt := &U16Tag{} + xt.UnmarshalBCS(bcs) + tt.Value = xt + case TypeTag_U32: + xt := &U32Tag{} + xt.UnmarshalBCS(bcs) + tt.Value = xt + case TypeTag_U64: + xt := &U64Tag{} + xt.UnmarshalBCS(bcs) + tt.Value = xt + case TypeTag_Struct: + xt := &StructTag{} + xt.UnmarshalBCS(bcs) + tt.Value = xt default: bcs.SetError(fmt.Errorf("unknown TypeTag enum %d", variant)) } } +func (tt *TypeTag) String() string { + return tt.Value.String() +} + func NewTypeTag(v any) *TypeTag { switch tv := v.(type) { case uint8: @@ -58,6 +86,10 @@ type BoolTag struct { Value bool } +func (xt *BoolTag) String() string { + return "bool" +} + func (xt *BoolTag) GetType() TypeTagType { return TypeTag_Bool } @@ -74,6 +106,10 @@ type U8Tag struct { Value uint8 } +func (xt *U8Tag) String() string { + return "u8" +} + func (xt *U8Tag) GetType() TypeTagType { return TypeTag_U8 } @@ -90,6 +126,10 @@ type U16Tag struct { Value uint16 } +func (xt *U16Tag) String() string { + return "u16" +} + func (xt *U16Tag) GetType() TypeTagType { return TypeTag_U16 } @@ -106,6 +146,10 @@ type U32Tag struct { Value uint32 } +func (xt *U32Tag) String() string { + return "u32" +} + func (xt *U32Tag) GetType() TypeTagType { return TypeTag_U32 } @@ -122,6 +166,10 @@ type U64Tag struct { Value uint64 } +func (xt *U64Tag) String() string { + return "u64" +} + func (xt *U64Tag) GetType() TypeTagType { return TypeTag_U64 } @@ -149,3 +197,46 @@ func (xt *AccountAddressTag) MarshalBCS(bcs *Serializer) { func (xt *AccountAddressTag) UnmarshalBCS(bcs *Deserializer) { xt.Value.UnmarshalBCS(bcs) } + +type StructTag struct { + Address AccountAddress + Module string // TODO: IdentifierWrapper ? + Name string // TODO: IdentifierWrapper ? + TypeParams []TypeTag +} + +func (st *StructTag) MarshalBCS(bcs *Serializer) { + st.Address.MarshalBCS(bcs) + bcs.WriteString(st.Module) + bcs.WriteString(st.Name) + SerializeSequence(st.TypeParams, bcs) +} +func (st *StructTag) UnmarshalBCS(bcs *Deserializer) { + st.Address.UnmarshalBCS(bcs) + st.Module = bcs.ReadString() + st.Name = bcs.ReadString() + st.TypeParams = DeserializeSequence[TypeTag](bcs) +} +func (st *StructTag) GetType() TypeTagType { + return TypeTag_Struct +} + +func (st *StructTag) String() string { + out := strings.Builder{} + out.WriteString(st.Address.String()) + out.WriteString("::") + out.WriteString(st.Module) + out.WriteString("::") + out.WriteString(st.Name) + if len(st.TypeParams) != 0 { + out.WriteRune('<') + for i, tp := range st.TypeParams { + if i != 0 { + out.WriteRune(',') + } + out.WriteString(tp.String()) + } + out.WriteRune('>') + } + return out.String() +} diff --git a/typetag_test.go b/typetag_test.go new file mode 100644 index 0000000..4540130 --- /dev/null +++ b/typetag_test.go @@ -0,0 +1,33 @@ +package aptos + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStructTag(t *testing.T) { + st := StructTag{ + Address: Account0x1, + Module: "coin", + Name: "CoinStore", + TypeParams: []TypeTag{ + TypeTag{Value: &StructTag{ + Address: Account0x1, + Module: "aptos_coin", + Name: "AptosCoin", + TypeParams: nil, + }}, + }, + } + assert.Equal(t, "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin>", st.String()) + var aa3 AccountAddress + aa3.ParseStringRelaxed("0x3") + st.TypeParams = append(st.TypeParams, TypeTag{Value: &StructTag{ + Address: aa3, + Module: "other", + Name: "thing", + TypeParams: nil, + }}) + assert.Equal(t, "0x1::coin::CoinStore<0x1::aptos_coin::AptosCoin,0x3::other::thing>", st.String()) +} diff --git a/util.go b/util.go index 02c8560..12b9cf8 100644 --- a/util.go +++ b/util.go @@ -8,7 +8,7 @@ import ( // Move some APT from sender to dest // Amount in Octas (10^-8 APT) -func TransferTransaction(rc *RestClient, sender *Account, dest AccountAddress, amount uint64) (stxn *SignedTransaction, err error) { +func APTTransferTransaction(rc *RestClient, sender *Account, dest AccountAddress, amount uint64) (stxn *SignedTransaction, err error) { // TODO: options for MaxGasAmount, GasUnitPrice, validSeconds, sequenceNumber validSeconds := int64(600_000) var chainId uint8