Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions protocol/api/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package api

import (
"encoding/json"

"github.com/pkg/errors"
)

// AuthorityWeight is the weight assigned to a key or account in a Steem
// authority. Steem encodes it as a 16-bit integer.
type AuthorityWeight uint16

// KeyAuth is a single (public key, weight) entry in an account authority.
//
// On the wire (condenser_api.get_accounts), key_auths is serialized as a JSON
// array of two-element arrays: [["STMxxx", 1], ...]. KeyAuth implements a
// custom unmarshaler that flattens each [key, weight] pair into its fields,
// so consumers can work with a typed slice rather than nested raw arrays.
type KeyAuth struct {
PubKey string
Weight AuthorityWeight
}

// UnmarshalJSON parses the wire form ["STMxxx", 1] into KeyAuth.
func (k *KeyAuth) UnmarshalJSON(data []byte) error {
// A JSON null leaves the value at its zero value (standard json behavior).
if string(data) == "null" {
return nil
}
var pair []json.RawMessage
if err := json.Unmarshal(data, &pair); err != nil {
return errors.Wrap(err, "key_auths entry must be a [key, weight] array")
}
if len(pair) != 2 {
return errors.Errorf("key_auths entry must have exactly 2 elements, got %d", len(pair))
}
var key string
if err := json.Unmarshal(pair[0], &key); err != nil {
return errors.Wrap(err, "invalid public key in key_auths")
}
var weight AuthorityWeight
if err := json.Unmarshal(pair[1], &weight); err != nil {
return errors.Wrap(err, "invalid weight in key_auths")
}
k.PubKey = key
k.Weight = weight
return nil
}

// MarshalJSON emits the wire form ["STMxxx", 1], the inverse of UnmarshalJSON,
// so a round-trip through JSON preserves the condenser_api array-of-pairs shape.
func (k KeyAuth) MarshalJSON() ([]byte, error) {
return json.Marshal([2]interface{}{k.PubKey, k.Weight})
}

// AccountAuthEntry is a weighted account name in an account authority. On the
// wire, account_auths is [["name", weight], ...], parsed the same way as
// key_auths.
type AccountAuthEntry struct {
Name string
Weight AuthorityWeight
}

// UnmarshalJSON parses the wire form ["name", 1] into AccountAuthEntry.
func (a *AccountAuthEntry) UnmarshalJSON(data []byte) error {
// A JSON null leaves the value at its zero value (standard json behavior).
if string(data) == "null" {
return nil
}
var pair []json.RawMessage
if err := json.Unmarshal(data, &pair); err != nil {
return errors.Wrap(err, "account_auths entry must be a [name, weight] array")
}
if len(pair) != 2 {
return errors.Errorf("account_auths entry must have exactly 2 elements, got %d", len(pair))
}
var name string
if err := json.Unmarshal(pair[0], &name); err != nil {
return errors.Wrap(err, "invalid account name in account_auths")
}
var weight AuthorityWeight
if err := json.Unmarshal(pair[1], &weight); err != nil {
return errors.Wrap(err, "invalid weight in account_auths")
}
a.Name = name
a.Weight = weight
return nil
}

// MarshalJSON emits the wire form ["name", 1], the inverse of UnmarshalJSON.
func (a AccountAuthEntry) MarshalJSON() ([]byte, error) {
return json.Marshal([2]interface{}{a.Name, a.Weight})
}

// Authority models a Steem account authority (owner / active / posting).
// weight_threshold is the total weight required to authorize an action under
// this authority.
type Authority struct {
WeightThreshold uint32 `json:"weight_threshold"`
AccountAuths []AccountAuthEntry `json:"account_auths"`
KeyAuths []KeyAuth `json:"key_auths"`
}

// ExtendedAccount models the subset of a condenser_api.get_accounts response
// entry that conveyor reads (see conveyor/src/user-search/user.ts UserAccount).
//
// Reputation is decoded as json.RawMessage because the chain returns it
// inconsistently as either a JSON string (legacy, large number) or a number,
// and consumers must handle both.
type ExtendedAccount struct {
Name string `json:"name"`
Created string `json:"created"`
Reputation json.RawMessage `json:"reputation"`
VotingPower int16 `json:"voting_power"`
Balance string `json:"balance"`
Posting Authority `json:"posting"`
Active Authority `json:"active"`
Owner Authority `json:"owner"`
}
167 changes: 167 additions & 0 deletions protocol/api/accounts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package api

import (
"encoding/json"
"testing"
)

// TestUnmarshal_ExtendedAccount verifies a realistic condenser_api.get_accounts
// response entry deserializes into ExtendedAccount, including:
// - reputation as both a string and a number (kept as RawMessage)
// - the posting authority with key_auths and weight_threshold
func TestUnmarshal_ExtendedAccount(t *testing.T) {
// reputation as a JSON string (legacy large-number form)
srcStr := `{
"name": "alice",
"created": "2016-03-24T17:00:21",
"reputation": "6217887123456",
"voting_power": 9800,
"balance": "1234.567 STEEM",
"posting": {
"weight_threshold": 1,
"account_auths": [],
"key_auths": [["STM7jNh5ejQoqHqWcGWFJ1v4F5CzsG3EiBuz1VooCng1cH5QpJD27", 1]]
},
"active": {"weight_threshold": 1, "account_auths": [], "key_auths": []},
"owner": {"weight_threshold": 1, "account_auths": [], "key_auths": []}
}`

var acct ExtendedAccount
if err := json.Unmarshal([]byte(srcStr), &acct); err != nil {
t.Fatalf("unmarshal (reputation as string) failed: %v", err)
}
if acct.Name != "alice" {
t.Errorf("name: want alice, got %q", acct.Name)
}
if acct.VotingPower != 9800 {
t.Errorf("voting_power: want 9800, got %d", acct.VotingPower)
}
if acct.Balance != "1234.567 STEEM" {
t.Errorf("balance mismatch: %q", acct.Balance)
}
// reputation raw message retains the quoted string
if string(acct.Reputation) != `"6217887123456"` {
t.Errorf("reputation raw: %q", string(acct.Reputation))
}
if acct.Posting.WeightThreshold != 1 || len(acct.Posting.KeyAuths) != 1 {
t.Errorf("posting authority wrong: %+v", acct.Posting)
}
if acct.Posting.KeyAuths[0].PubKey != "STM7jNh5ejQoqHqWcGWFJ1v4F5CzsG3EiBuz1VooCng1cH5QpJD27" {
t.Errorf("posting key wrong: %q", acct.Posting.KeyAuths[0].PubKey)
}
if acct.Posting.KeyAuths[0].Weight != 1 {
t.Errorf("posting key weight wrong: %d", acct.Posting.KeyAuths[0].Weight)
}

// reputation as a JSON number must also decode without error
srcNum := `{
"name": "bob",
"created": "2020-01-01T00:00:00",
"reputation": 6217887123456,
"voting_power": 0,
"balance": "0.000 STEEM",
"posting": {"weight_threshold": 1, "account_auths": [], "key_auths": []}
}`
var acct2 ExtendedAccount
if err := json.Unmarshal([]byte(srcNum), &acct2); err != nil {
t.Fatalf("unmarshal (reputation as number) failed: %v", err)
}
if string(acct2.Reputation) != "6217887123456" {
t.Errorf("reputation numeric raw: %q", string(acct2.Reputation))
}
}

// TestKeyAuth_MarshalRoundTrip verifies MarshalJSON is the inverse of
// UnmarshalJSON: the nested-array wire shape survives a round trip.
func TestKeyAuth_MarshalRoundTrip(t *testing.T) {
in := KeyAuth{PubKey: "STM7jNh5ejQoqHqWcGWFJ1v4F5CzsG3EiBuz1VooCng1cH5QpJD27", Weight: 3}

data, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
// wire shape must be the nested array, not a flat object
const want = `["STM7jNh5ejQoqHqWcGWFJ1v4F5CzsG3EiBuz1VooCng1cH5QpJD27",3]`
if string(data) != want {
t.Errorf("marshal shape\nwant: %s\ngot: %s", want, string(data))
}

var out KeyAuth
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out != in {
t.Errorf("round-trip mismatch: want %+v, got %+v", in, out)
}
}

// TestAccountAuthEntry_MarshalRoundTrip verifies AccountAuthEntry round-trips
// through its nested-array wire form.
func TestAccountAuthEntry_MarshalRoundTrip(t *testing.T) {
in := AccountAuthEntry{Name: "alice", Weight: 2}

data, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
const want = `["alice",2]`
if string(data) != want {
t.Errorf("marshal shape\nwant: %s\ngot: %s", want, string(data))
}

var out AccountAuthEntry
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out != in {
t.Errorf("round-trip mismatch: want %+v, got %+v", in, out)
}
}

// TestKeyAuth_UnmarshalMalformed verifies that malformed key_auths wire input
// is rejected with an error rather than silently producing a zero value.
func TestKeyAuth_UnmarshalMalformed(t *testing.T) {
cases := map[string]string{
"not an array": `"STMxxx"`,
"single element": `["STMxxx"]`,
"three elements": `["STMxxx", 1, 2]`,
"non-string key": `[123, 1]`,
"non-numeric weight": `["STMxxx", "heavy"]`,
"null": `null`,
}
for name, src := range cases {
t.Run(name, func(t *testing.T) {
var k KeyAuth
// `null` unmarshals to a zero value without error (standard json
// behavior); every other malformed case must error.
if src == `null` {
if err := json.Unmarshal([]byte(src), &k); err != nil {
t.Errorf("expected null to yield zero value, got error: %v", err)
}
return
}
if err := json.Unmarshal([]byte(src), &k); err == nil {
t.Errorf("expected error for malformed input %s", src)
}
})
}
}

// TestAccountAuthEntry_UnmarshalMalformed verifies malformed account_auths
// input is rejected.
func TestAccountAuthEntry_UnmarshalMalformed(t *testing.T) {
cases := map[string]string{
"not an array": `"alice"`,
"single element": `["alice"]`,
"three elements": `["alice", 1, 2]`,
"non-numeric weight": `["alice", "heavy"]`,
}
for name, src := range cases {
t.Run(name, func(t *testing.T) {
var a AccountAuthEntry
if err := json.Unmarshal([]byte(src), &a); err == nil {
t.Errorf("expected error for malformed input %s", src)
}
})
}
}
18 changes: 18 additions & 0 deletions protocol/api/follow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package api

// FollowCountReturn models a condenser_api.get_follow_count response.
// See conveyor/src/user-search/client.ts FollowCountReturn.
type FollowCountReturn struct {
Account string `json:"account"`
FollowerCount int `json:"follower_count"`
FollowingCount int `json:"following_count"`
}

// FollowReturn models a single entry in a condenser_api.get_followers /
// get_following response. The `what` array contains role strings such as
// ["blog"] or ["ignore"]. See conveyor/src/user-search/client.ts FollowReturn.
type FollowReturn struct {
Follower string `json:"follower"`
Following string `json:"following"`
What []string `json:"what"`
}
36 changes: 36 additions & 0 deletions protocol/api/market.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package api

// OrderPrice models the order_price field of a limit order, expressed as a
// ratio of two asset strings (e.g. base "1.000 SBD", quote "1.000 STEEM").
// Used by conveyor/src/price.ts.
type OrderPrice struct {
Base string `json:"base"`
Quote string `json:"quote"`
}

// Order models a single entry in a database_api.get_order_book response (one
// of the asks or bids).
type Order struct {
OrderPrice OrderPrice `json:"order_price"`
}

// OrderBook models a database_api.get_order_book response. conveyor averages
// the order prices across both sides to derive the STEEM<>SBD market price.
type OrderBook struct {
Asks []Order `json:"asks"`
Bids []Order `json:"bids"`
}

// CurrentMedianHistoryPrice models a witness-reported price entry, expressed
// as a base/quote asset ratio. Used by conveyor/src/price.ts to derive the
// STEEM<>USD price.
type CurrentMedianHistoryPrice struct {
Base string `json:"base"`
Quote string `json:"quote"`
}

// FeedHistory models a database_api.get_feed_history response. conveyor reads
// the last entry of price_history as the current witness price feed.
type FeedHistory struct {
PriceHistory []CurrentMedianHistoryPrice `json:"price_history"`
}
Loading
Loading