diff --git a/INSCRIPTION.md b/INSCRIPTION.md index de74ad9fc..21b5ab8ba 100644 --- a/INSCRIPTION.md +++ b/INSCRIPTION.md @@ -46,8 +46,8 @@ type Deployment struct { // at the same time as defined by the mode // // For MAO, the ratio will be 0.9, and each collectible will only cost - // 10% of the unit tokens, so only the inscribers have NFTs, but not the - // the treasury tokens, however they can combine to NFT when vacant. + // 10% of the unit tokens, so only the inscribers have NFTs, but not + // the treasury tokens, however they can occupy a vacant NFT. Treasury *struct { Ratio string `json:"ratio"` Recipient string `json:"recipient"` @@ -107,9 +107,9 @@ For an NFT inscription, the owner will release the NFT if they split or combine ## Occupy -Whoever receives a transaction with the exact unit amount of inscription tokens and the following extra will occupy the vacant or released NFT. +Whoever receives a transaction with the exact unit amount of inscription tokens at the first UTXO will occupy the vacant NFT. -The occupation transaction must reference the NFT initial inscription transaction hash. +The occupancy transaction must reference the NFT initial inscription transaction hash and include the following JSON extra. ```golang type Occupation struct { diff --git a/config/config.example.toml b/config/config.example.toml index cb3fcaceb..ac0a0b843 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -38,6 +38,8 @@ metric = false port = 6860 # whether respond the runtime of each RPC call runtime = false +# enable the object server +object-server = false [dev] # enable the pprof web server with a valid TCP port number diff --git a/config/reader.go b/config/reader.go index 05c17b5b2..0edcc126b 100644 --- a/config/reader.go +++ b/config/reader.go @@ -56,8 +56,9 @@ type Custom struct { Metric bool `toml:"metric"` } `toml:"p2p"` RPC struct { - Port int `toml:"port"` - Runtime bool `toml:"runtime"` + Port int `toml:"port"` + Runtime bool `toml:"runtime"` + ObjectServer bool `toml:"object-server"` } `toml:"rpc"` Dev struct { Port int `toml:"port"` diff --git a/rpc/internal/server/http.go b/rpc/internal/server/http.go index 5bcbeec2c..a95e222ae 100644 --- a/rpc/internal/server/http.go +++ b/rpc/internal/server/http.go @@ -82,6 +82,10 @@ func (impl *RPC) ServeHTTP(w http.ResponseWriter, r *http.Request) { impl.renderInfo(rdr) return } + if strings.HasPrefix(r.URL.Path, "/objects/") && impl.custom.RPC.ObjectServer { + impl.handleObject(w, r, rdr) + return + } if r.URL.Path != "/" || r.Method != "POST" { rdr.RenderError(fmt.Errorf("bad request %s %s", r.Method, r.URL.Path)) return diff --git a/rpc/internal/server/object.go b/rpc/internal/server/object.go new file mode 100644 index 000000000..de0cb1fb3 --- /dev/null +++ b/rpc/internal/server/object.go @@ -0,0 +1,103 @@ +package server + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "unicode/utf8" + + "github.com/MixinNetwork/mixin/common" + "github.com/MixinNetwork/mixin/crypto" +) + +func (impl *RPC) handleObject(w http.ResponseWriter, r *http.Request, rdr *Render) { + ps := strings.Split(r.URL.Path, "/") + if len(ps) < 3 || ps[1] != "objects" { + rdr.RenderError(fmt.Errorf("bad request %s %s", r.Method, r.URL.Path)) + return + } + txHash, err := crypto.HashFromString(ps[2]) + if err != nil { + rdr.RenderError(fmt.Errorf("bad request %s %s", r.Method, r.URL.Path)) + return + } + + tx, _, err := impl.Store.ReadTransaction(txHash) + if err != nil { + rdr.RenderError(err) + return + } + if tx == nil || tx.Asset != common.XINAssetId { + rdr.RenderError(fmt.Errorf("not found %s", r.URL.Path)) + return + } + + b := tx.Extra + w.Header().Set("Cache-Control", "max-age=31536000, public") + if len(tx.Extra) == 0 { + w.Header().Set("Content-Type", "text/plain") + } else if m := parseJSON(tx.Extra); m == nil { + w.Header().Set("Content-Type", decideContentType(tx.Extra)) + } else if len(ps) < 4 { + w.Header().Set("Content-Type", "application/json") + } else { + v, mime := parseDataURI(fmt.Sprint(m[ps[3]])) + w.Header().Set("Content-Type", mime) + b = v + } + + w.WriteHeader(http.StatusOK) + w.Write(b) +} + +func parseDataURI(v string) ([]byte, string) { + ds := strings.Split(v, ",") + if len(ds) != 2 { + return []byte(v), "text/plain" + } + s := tryURLQueryUnescape(ds[1]) + + ms := strings.Split(ds[0], ";") + if len(ms) < 2 { + return []byte(s), "text/plain" + } + if ms[len(ms)-1] == "base64" { + b, _ := base64.StdEncoding.DecodeString(s) + s = string(b) + } + if ms[0] == "" || !utf8.ValidString(ms[0]) { + return []byte(s), decideContentType([]byte(s)) + } + return []byte(s), ms[0] +} + +func tryURLQueryUnescape(v string) string { + s, err := url.QueryUnescape(v) + if err != nil { + return v + } + return s +} + +func decideContentType(extra []byte) string { + if utf8.ValidString(string(extra)) { + return "text/plain" + } else { + return "application/octet-stream" + } +} + +func parseJSON(extra []byte) map[string]any { + if extra[0] != '{' && extra[0] != '[' { + return nil + } + var r map[string]any + err := json.Unmarshal(extra, &r) + if err != nil { + return nil + } + return r +}