diff --git a/go.mod b/go.mod index 5a4e81c..67ff905 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,46 @@ module github.com/nbd-wtf/go-nostr -go 1.22.5 +go 1.23.0 require ( github.com/bluekeyes/go-gitdiff v0.7.1 github.com/btcsuite/btcd/btcec/v2 v2.3.2 github.com/btcsuite/btcd/btcutil v1.1.3 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 + github.com/fiatjaf/eventstore v0.8.1 + github.com/fiatjaf/generic-ristretto v0.0.1 github.com/gobwas/httphead v0.1.0 - github.com/gobwas/ws v1.2.0 + github.com/gobwas/ws v1.3.1 + github.com/graph-gophers/dataloader/v7 v7.1.0 github.com/mailru/easyjson v0.7.7 github.com/puzpuzpuz/xsync/v3 v3.0.2 github.com/stretchr/testify v1.9.0 - github.com/tidwall/gjson v1.14.4 + github.com/tidwall/gjson v1.17.0 github.com/tyler-smith/go-bip32 v1.0.0 github.com/tyler-smith/go-bip39 v1.1.0 - golang.org/x/crypto v0.7.0 - golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 - golang.org/x/net v0.8.0 - golang.org/x/text v0.8.0 + golang.org/x/crypto v0.14.0 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d + golang.org/x/net v0.17.0 + golang.org/x/text v0.15.0 ) require ( github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gobwas/pool v0.2.1 // indirect + github.com/golang/glog v1.1.2 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - golang.org/x/sys v0.8.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.20.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a5fd901..2bad03a 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,11 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw= github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -42,14 +45,24 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fiatjaf/eventstore v0.8.1 h1:51LchQNy0Hpb0YQHwqYR5pKBpfDs/KjySlWCbbz2pkc= +github.com/fiatjaf/eventstore v0.8.1/go.mod h1:bsp0Ibv0CIcVuFcoM2AEerMWmXRhF8uWXMf+dClhuow= +github.com/fiatjaf/generic-ristretto v0.0.1 h1:LUJSU87X/QWFsBXTwnH3moFe4N8AjUxT+Rfa0+bo6YM= +github.com/fiatjaf/generic-ristretto v0.0.1/go.mod h1:cvV6ANHDA/GrfzVrig7N7i6l8CWnkVZvtQ2/wk9DPVE= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I= -github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gobwas/ws v1.3.1 h1:Qi34dfLMWJbiKaNbDVzM9x27nZBjmkaW6i4+Ku+pGVU= +github.com/gobwas/ws v1.3.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= +github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -61,6 +74,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc= +github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -68,6 +83,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -79,28 +98,29 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew= github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tyler-smith/go-bip32 v1.0.0 h1:sDR9juArbUgX+bO/iblgZnMPeWY1KZMUC2AFUJdv5KE= github.com/tyler-smith/go-bip32 v1.0.0/go.mod h1:onot+eHknzV4BVPwrzqY5OoVpyCvnwD7lMawL5aQupE= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= @@ -109,17 +129,17 @@ golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= -golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -131,13 +151,13 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -147,8 +167,9 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/keyring/bunker.go b/keyring/bunker.go new file mode 100644 index 0000000..b6426f2 --- /dev/null +++ b/keyring/bunker.go @@ -0,0 +1,39 @@ +package keyring + +import ( + "context" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip46" +) + +// BunkerSigner is a signer that asks a bunker using NIP-46 every time it needs to do an operation. +type BunkerSigner struct { + bunker *nip46.BunkerClient +} + +func NewBunkerSignerFromBunkerClient(bc *nip46.BunkerClient) BunkerSigner { + return BunkerSigner{bc} +} + +func (bs BunkerSigner) GetPublicKey(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + pk, _ := bs.bunker.GetPublicKey(ctx) + return pk +} + +func (bs BunkerSigner) SignEvent(ctx context.Context, evt *nostr.Event) error { + ctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + return bs.bunker.SignEvent(ctx, evt) +} + +func (bs BunkerSigner) Encrypt(ctx context.Context, plaintext string, recipient string) (string, error) { + return bs.bunker.NIP44Encrypt(ctx, recipient, plaintext) +} + +func (bs BunkerSigner) Decrypt(ctx context.Context, base64ciphertext string, sender string) (plaintext string, err error) { + return bs.bunker.NIP44Encrypt(ctx, sender, base64ciphertext) +} diff --git a/keyring/encrypted.go b/keyring/encrypted.go new file mode 100644 index 0000000..cd9febc --- /dev/null +++ b/keyring/encrypted.go @@ -0,0 +1,67 @@ +package keyring + +import ( + "context" + "fmt" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip44" + "github.com/nbd-wtf/go-nostr/nip49" +) + +// EncryptedKeySigner is a signer that must always ask the user for a password before every operation. +type EncryptedKeySigner struct { + ncryptsec string + pk string + callback func(context.Context) string +} + +func (es *EncryptedKeySigner) GetPublicKey(ctx context.Context) string { + if es.pk != "" { + return es.pk + } + password := es.callback(ctx) + key, err := nip49.Decrypt(es.ncryptsec, password) + if err != nil { + return "" + } + pk, _ := nostr.GetPublicKey(key) + es.pk = pk + return pk +} + +func (es *EncryptedKeySigner) SignEvent(ctx context.Context, evt *nostr.Event) error { + password := es.callback(ctx) + sk, err := nip49.Decrypt(es.ncryptsec, password) + if err != nil { + return fmt.Errorf("invalid password: %w", err) + } + es.pk = evt.PubKey + return evt.Sign(sk) +} + +func (es EncryptedKeySigner) Encrypt(ctx context.Context, plaintext string, recipient string) (c64 string, err error) { + password := es.callback(ctx) + sk, err := nip49.Decrypt(es.ncryptsec, password) + if err != nil { + return "", fmt.Errorf("invalid password: %w", err) + } + ck, err := nip44.GenerateConversationKey(recipient, sk) + if err != nil { + return "", err + } + return nip44.Encrypt(plaintext, ck) +} + +func (es EncryptedKeySigner) Decrypt(ctx context.Context, base64ciphertext string, sender string) (plaintext string, err error) { + password := es.callback(ctx) + sk, err := nip49.Decrypt(es.ncryptsec, password) + if err != nil { + return "", fmt.Errorf("invalid password: %w", err) + } + ck, err := nip44.GenerateConversationKey(sender, sk) + if err != nil { + return "", err + } + return nip44.Encrypt(plaintext, ck) +} diff --git a/keyring/lib.go b/keyring/lib.go new file mode 100644 index 0000000..3d1801f --- /dev/null +++ b/keyring/lib.go @@ -0,0 +1,90 @@ +package keyring + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip05" + "github.com/nbd-wtf/go-nostr/nip19" + "github.com/nbd-wtf/go-nostr/nip46" + "github.com/nbd-wtf/go-nostr/nip49" +) + +type Keyring interface { + Signer + Cipher +} + +// A Signer provides basic public key signing methods. +type Signer interface { + GetPublicKey(context.Context) string + SignEvent(context.Context, *nostr.Event) error +} + +// A Cipher provides NIP-44 encryption and decryption methods. +type Cipher interface { + Encrypt(ctx context.Context, plaintext string, recipientPublicKey string) (base64ciphertext string, err error) + Decrypt(ctx context.Context, base64ciphertext string, senderPublicKey string) (plaintext string, err error) +} + +type SignerOptions struct { + BunkerClientSecretKey string + BunkerSignTimeout time.Duration + BunkerAuthHandler func(string) + + // if a PasswordHandler is provided the key will be stored encrypted and this function will be called + // every time an operation needs access to the key so the user can be prompted. + PasswordHandler func(context.Context) string + + // if instead a Password is provided along with a ncryptsec, then the key will be decrypted and stored in plaintext. + Password string +} + +func New(ctx context.Context, pool *nostr.SimplePool, input string, opts *SignerOptions) (Keyring, error) { + if opts == nil { + opts = &SignerOptions{} + } + + if strings.HasPrefix(input, "ncryptsec") { + if opts.PasswordHandler != nil { + return &EncryptedKeySigner{input, "", opts.PasswordHandler}, nil + } + sec, err := nip49.Decrypt(input, opts.Password) + if err != nil { + if opts.Password == "" { + return nil, fmt.Errorf("failed to decrypt with blank password: %w", err) + } + return nil, fmt.Errorf("failed to decrypt with given password: %w", err) + } + pk, _ := nostr.GetPublicKey(sec) + return KeySigner{sec, pk, make(map[string][32]byte)}, nil + } else if nip46.IsValidBunkerURL(input) || nip05.IsValidIdentifier(input) { + bcsk := nostr.GeneratePrivateKey() + oa := func(url string) { println("auth_url received but not handled") } + + if opts.BunkerClientSecretKey != "" { + bcsk = opts.BunkerClientSecretKey + } + if opts.BunkerAuthHandler != nil { + oa = opts.BunkerAuthHandler + } + + bunker, err := nip46.ConnectBunker(ctx, bcsk, input, pool, oa) + if err != nil { + return nil, err + } + return BunkerSigner{bunker}, nil + } else if prefix, parsed, err := nip19.Decode(input); err == nil && prefix == "nsec" { + sec := parsed.(string) + pk, _ := nostr.GetPublicKey(sec) + return KeySigner{sec, pk, make(map[string][32]byte)}, nil + } else if nostr.IsValid32ByteHex(input) { + pk, _ := nostr.GetPublicKey(input) + return KeySigner{input, pk, make(map[string][32]byte)}, nil + } + + return nil, fmt.Errorf("unsupported input '%s'", input) +} diff --git a/keyring/manual.go b/keyring/manual.go new file mode 100644 index 0000000..39b5365 --- /dev/null +++ b/keyring/manual.go @@ -0,0 +1,33 @@ +package keyring + +import ( + "context" + + "github.com/nbd-wtf/go-nostr" +) + +// ManualSigner is a signer that doesn't really do anything, it just calls the functions given to it. +// It can be used when an app for some reason wants to ask the user to manually provide a signed event +// by copy-and-paste, for example. +type ManualSigner struct { + ManualGetPublicKey func(context.Context) string + ManualSignEvent func(context.Context, *nostr.Event) error + ManualEncrypt func(ctx context.Context, plaintext string, recipientPublicKey string) (base64ciphertext string, err error) + ManualDecrypt func(ctx context.Context, base64ciphertext string, senderPublicKey string) (plaintext string, err error) +} + +func (ms ManualSigner) SignEvent(ctx context.Context, evt *nostr.Event) error { + return ms.ManualSignEvent(ctx, evt) +} + +func (ms ManualSigner) GetPublicKey(ctx context.Context) string { + return ms.ManualGetPublicKey(ctx) +} + +func (ms ManualSigner) Encrypt(ctx context.Context, plaintext string, recipient string) (c64 string, err error) { + return ms.ManualEncrypt(ctx, plaintext, recipient) +} + +func (ms ManualSigner) Decrypt(ctx context.Context, base64ciphertext string, sender string) (plaintext string, err error) { + return ms.ManualDecrypt(ctx, base64ciphertext, sender) +} diff --git a/keyring/plain.go b/keyring/plain.go new file mode 100644 index 0000000..9f6dea9 --- /dev/null +++ b/keyring/plain.go @@ -0,0 +1,44 @@ +package keyring + +import ( + "context" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip44" +) + +// Keysigner is a signer that holds the private key in memory and can do all the operations instantly and easily. +type KeySigner struct { + sk string + pk string + + conversationKeys map[string][32]byte +} + +func (ks KeySigner) SignEvent(ctx context.Context, evt *nostr.Event) error { return evt.Sign(ks.sk) } +func (ks KeySigner) GetPublicKey(ctx context.Context) string { return ks.pk } + +func (ks KeySigner) Encrypt(ctx context.Context, plaintext string, recipient string) (c64 string, err error) { + ck, ok := ks.conversationKeys[recipient] + if !ok { + ck, err = nip44.GenerateConversationKey(recipient, ks.sk) + if err != nil { + return "", err + } + ks.conversationKeys[recipient] = ck + } + return nip44.Encrypt(plaintext, ck) +} + +func (ks KeySigner) Decrypt(ctx context.Context, base64ciphertext string, sender string) (plaintext string, err error) { + ck, ok := ks.conversationKeys[sender] + if !ok { + var err error + ck, err = nip44.GenerateConversationKey(sender, ks.sk) + if err != nil { + return "", err + } + ks.conversationKeys[sender] = ck + } + return nip44.Encrypt(plaintext, ck) +} diff --git a/nip44/nip44.go b/nip44/nip44.go index 2c3a269..841d4ee 100644 --- a/nip44/nip44.go +++ b/nip44/nip44.go @@ -225,6 +225,7 @@ func calcPadding(sLen int) int { return chunk * int(math.Floor(float64((sLen-1)/chunk))+1) } +// code adapted from nip04.ComputeSharedSecret() func computeSharedSecret(pub string, sk string) (sharedSecret [32]byte, err error) { privKeyBytes, err := hex.DecodeString(sk) if err != nil { @@ -232,7 +233,6 @@ func computeSharedSecret(pub string, sk string) (sharedSecret [32]byte, err erro } privKey, _ := btcec.PrivKeyFromBytes(privKeyBytes) - // adding 02 to signal that this is a compressed public key (33 bytes) pubKeyBytes, err := hex.DecodeString("02" + pub) if err != nil { return sharedSecret, fmt.Errorf("error decoding hex string of receiver public key '%s': %w", "02"+pub, err) diff --git a/sdk/cache/interface.go b/sdk/cache/interface.go new file mode 100644 index 0000000..5153913 --- /dev/null +++ b/sdk/cache/interface.go @@ -0,0 +1,10 @@ +package cache + +import "time" + +type Cache32[V any] interface { + Get(k string) (v V, ok bool) + Delete(k string) + Set(k string, v V) bool + SetWithTTL(k string, v V, d time.Duration) bool +} diff --git a/sdk/cache/memory/cache.go b/sdk/cache/memory/cache.go new file mode 100644 index 0000000..41610da --- /dev/null +++ b/sdk/cache/memory/cache.go @@ -0,0 +1,48 @@ +package cache_memory + +import ( + "encoding/binary" + "encoding/hex" + "time" + + ristretto "github.com/fiatjaf/generic-ristretto" +) + +type RistrettoCache[V any] struct { + Cache *ristretto.Cache[string, V] +} + +func New32[V any](max int64) *RistrettoCache[V] { + cache, _ := ristretto.NewCache(&ristretto.Config[string, V]{ + NumCounters: max * 10, + MaxCost: max, + BufferItems: 64, + KeyToHash: func(key string) (uint64, uint64) { return h32(key), 0 }, + }) + return &RistrettoCache[V]{Cache: cache} +} + +func (s RistrettoCache[V]) Get(k string) (v V, ok bool) { return s.Cache.Get(k) } +func (s RistrettoCache[V]) Delete(k string) { s.Cache.Del(k) } +func (s RistrettoCache[V]) Set(k string, v V) bool { return s.Cache.Set(k, v, 1) } +func (s RistrettoCache[V]) SetWithTTL(k string, v V, d time.Duration) bool { + return s.Cache.SetWithTTL(k, v, 1, d) +} + +func h32(key string) uint64 { + // we get an event id or pubkey as hex, + // so just extract the last 8 bytes from it and turn them into a uint64 + return shortUint64(key) +} + +func shortUint64(idOrPubkey string) uint64 { + length := len(idOrPubkey) + if length < 8 { + return 0 + } + b, err := hex.DecodeString(idOrPubkey[length-8:]) + if err != nil { + return 0 + } + return uint64(binary.BigEndian.Uint32(b)) +} diff --git a/sdk/follows.go b/sdk/follows.go new file mode 100644 index 0000000..f1d4671 --- /dev/null +++ b/sdk/follows.go @@ -0,0 +1,50 @@ +package sdk + +import ( + "context" + "net/url" + "strings" + + "github.com/nbd-wtf/go-nostr" +) + +type FollowList = GenericList[Follow] + +type Follow struct { + Pubkey string + Relay string + Petname string +} + +func (f Follow) Value() string { return f.Pubkey } + +func (sys *System) FetchFollowList(ctx context.Context, pubkey string) FollowList { + fl, _ := fetchGenericList[Follow](sys, ctx, pubkey, 3, parseFollow, sys.FollowListCache, false) + return fl +} + +func parseFollow(tag nostr.Tag) (fw Follow, ok bool) { + if len(tag) < 2 { + return fw, false + } + if tag[0] != "p" { + return fw, false + } + + fw.Pubkey = tag[1] + if !nostr.IsValidPublicKey(fw.Pubkey) { + return fw, false + } + + if len(tag) > 2 { + if _, err := url.Parse(tag[2]); err == nil { + fw.Relay = nostr.NormalizeURL(tag[2]) + } + if len(tag) > 3 { + fw.Petname = strings.TrimSpace(tag[3]) + } + return fw, true + } + + return fw, false +} diff --git a/sdk/helpers.go b/sdk/helpers.go new file mode 100644 index 0000000..ecfcf50 --- /dev/null +++ b/sdk/helpers.go @@ -0,0 +1,21 @@ +package sdk + +import ( + "strings" +) + +// IsVirtualRelay returns true if the given normalized relay URL shouldn't be considered for outbox-model calculations. +func IsVirtualRelay(url string) bool { + if len(url) < 6 { + // this is just invalid + return true + } + + if strings.HasPrefix(url, "wss://feeds.nostr.band") || + strings.HasPrefix(url, "wss://filter.nostr.wine") || + strings.HasPrefix(url, "wss://cache") { + return true + } + + return false +} diff --git a/sdk/hints/interface.go b/sdk/hints/interface.go new file mode 100644 index 0000000..8000911 --- /dev/null +++ b/sdk/hints/interface.go @@ -0,0 +1,8 @@ +package hints + +import "github.com/nbd-wtf/go-nostr" + +type HintsDB interface { + TopN(pubkey string, n int) []string + Save(pubkey string, relay string, key HintKey, score nostr.Timestamp) +} diff --git a/sdk/hints/keys.go b/sdk/hints/keys.go new file mode 100644 index 0000000..226dd09 --- /dev/null +++ b/sdk/hints/keys.go @@ -0,0 +1,49 @@ +package hints + +import "github.com/nbd-wtf/go-nostr" + +const END_OF_WORLD nostr.Timestamp = 2208999600 // 2040-01-01 + +type HintKey int + +const ( + LastFetchAttempt HintKey = iota + MostRecentEventFetched + LastInRelayList + LastInTag + LastInNprofile + LastInNevent + LastInNIP05 +) + +var KeyBasePoints = [7]int64{ + -500, // attempting has negative power because it may fail + 700, // when it succeeds that should cancel the negative effect of trying + 350, // a relay list is a very strong indicator + 5, // tag hints are often autogenerated so we don't care very much about them (that may change) + 22, // it feels like people take nprofiles slightly more seriously so we value these a bit more + 8, // these are also not often too bad + 7, // nip05 hints should be a strong indicator, although in practice they're kinda bad +} + +func (hk HintKey) BasePoints() int64 { return KeyBasePoints[hk] } + +func (hk HintKey) String() string { + switch hk { + case LastFetchAttempt: + return "last_fetch_attempt" + case MostRecentEventFetched: + return "most_recent_event_fetched" + case LastInRelayList: + return "last_in_relay_list" + case LastInTag: + return "last_in_tag" + case LastInNprofile: + return "last_in_nprofile" + case LastInNevent: + return "last_in_nevent" + case LastInNIP05: + return "last_in_nip05" + } + return "" +} diff --git a/sdk/hints/memory/memory_hints.go b/sdk/hints/memory/memory_hints.go new file mode 100644 index 0000000..ed0d773 --- /dev/null +++ b/sdk/hints/memory/memory_hints.go @@ -0,0 +1,142 @@ +package memory + +import ( + "fmt" + "math" + "slices" + "sync" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/sdk/hints" +) + +var _ hints.HintsDB = (*HintDB)(nil) + +type HintDB struct { + RelayBySerial []string + OrderedRelaysByPubKey map[string]RelaysForPubKey + + sync.Mutex +} + +func NewHintDB() *HintDB { + return &HintDB{ + RelayBySerial: make([]string, 0, 100), + OrderedRelaysByPubKey: make(map[string]RelaysForPubKey, 100), + } +} + +func (db *HintDB) Save(pubkey string, relay string, key hints.HintKey, ts nostr.Timestamp) { + now := nostr.Now() + // this is used for calculating what counts as a usable hint + threshold := (now - 60*60*24*180) + if threshold < 0 { + threshold = 0 + } + + relayIndex := slices.Index(db.RelayBySerial, relay) + if relayIndex == -1 { + relayIndex = len(db.RelayBySerial) + db.RelayBySerial = append(db.RelayBySerial, relay) + } + + db.Lock() + defer db.Unlock() + // fmt.Println(" ", relay, "index", relayIndex, "--", "adding", hints.HintKey(key).String(), ts) + + rfpk, _ := db.OrderedRelaysByPubKey[pubkey] + + entries := rfpk.Entries + + entryIndex := slices.IndexFunc(entries, func(re RelayEntry) bool { return re.Relay == relayIndex }) + if entryIndex == -1 { + // we don't have an entry for this relay, so add one + entryIndex = len(entries) + + entry := RelayEntry{ + Relay: relayIndex, + } + entry.Timestamps[key] = ts + + entries = append(entries, entry) + } else { + // just update this entry + if entries[entryIndex].Timestamps[key] < ts { + entries[entryIndex].Timestamps[key] = ts + } else { + // no need to update anything + return + } + } + + rfpk.Entries = entries + + db.OrderedRelaysByPubKey[pubkey] = rfpk +} + +func (db *HintDB) TopN(pubkey string, n int) []string { + db.Lock() + defer db.Unlock() + + urls := make([]string, 0, n) + if rfpk, ok := db.OrderedRelaysByPubKey[pubkey]; ok { + // sort everything from scratch + slices.SortFunc(rfpk.Entries, func(a, b RelayEntry) int { + return int(b.Sum() - a.Sum()) + }) + + for i, re := range rfpk.Entries { + urls = append(urls, db.RelayBySerial[re.Relay]) + if i+1 == n { + break + } + } + } + return urls +} + +func (db *HintDB) PrintScores() { + db.Lock() + defer db.Unlock() + + fmt.Println("= print scores") + for pubkey, rfpk := range db.OrderedRelaysByPubKey { + fmt.Println("== relay scores for", pubkey) + for i, re := range rfpk.Entries { + fmt.Printf(" %3d :: %30s (%3d) ::> %12d\n", i, db.RelayBySerial[re.Relay], re.Relay, re.Sum()) + } + } +} + +type RelaysForPubKey struct { + Entries []RelayEntry +} + +type RelayEntry struct { + Relay int + Timestamps [8]nostr.Timestamp +} + +func (re RelayEntry) Sum() int64 { + now := nostr.Now() + 24*60*60 + var sum int64 + for i, ts := range re.Timestamps { + if ts == 0 { + continue + } + + hk := hints.HintKey(i) + divisor := int64(now - ts) + if divisor == 0 { + divisor = 1 + } else { + divisor = int64(math.Pow(float64(divisor), 1.3)) + } + + multiplier := hk.BasePoints() + value := multiplier * 10000000000 / divisor + // fmt.Println(" ", i, "value:", value) + sum += value + } + return sum +} diff --git a/sdk/hints/memory/memory_test.go b/sdk/hints/memory/memory_test.go new file mode 100644 index 0000000..e85bef4 --- /dev/null +++ b/sdk/hints/memory/memory_test.go @@ -0,0 +1,143 @@ +package memory + +import ( + "testing" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/sdk/hints" + "github.com/stretchr/testify/require" +) + +func TestRelayPicking(t *testing.T) { + hdb := NewHintDB() + + const key1 = "0000000000000000000000000000000000000000000000000000000000000001" + const key2 = "0000000000000000000000000000000000000000000000000000000000000002" + const key3 = "0000000000000000000000000000000000000000000000000000000000000003" + const key4 = "0000000000000000000000000000000000000000000000000000000000000004" + const relayA = "wss://aaa.com" + const relayB = "wss://bbb.online" + const relayC = "wss://ccc.technology" + + hour := nostr.Timestamp((time.Hour).Seconds()) + day := hour * 24 + + // key1: finding out + // add some random parameters things and see what we get + hdb.Save(key1, relayA, hints.LastInTag, nostr.Now()-60*hour) + hdb.Save(key1, relayB, hints.LastInRelayList, nostr.Now()-day*10) + hdb.Save(key1, relayB, hints.LastInNevent, nostr.Now()-day*30) + hdb.Save(key1, relayA, hints.LastInNprofile, nostr.Now()-hour*10) + hdb.PrintScores() + + require.Equal(t, []string{relayB, relayA}, hdb.TopN(key1, 3)) + + hdb.Save(key1, relayA, hints.LastFetchAttempt, nostr.Now()-5*hour) + hdb.Save(key1, relayC, hints.LastInNIP05, nostr.Now()-5*hour) + hdb.PrintScores() + + require.Equal(t, []string{relayB, relayC, relayA}, hdb.TopN(key1, 3)) + + hdb.Save(key1, relayC, hints.LastInTag, nostr.Now()-5*hour) + hdb.Save(key1, relayC, hints.LastFetchAttempt, nostr.Now()-5*hour) + hdb.PrintScores() + + require.Equal(t, []string{relayB, relayA, relayC}, hdb.TopN(key1, 3)) + + hdb.Save(key1, relayA, hints.MostRecentEventFetched, nostr.Now()-day*60) + hdb.PrintScores() + + require.Equal(t, []string{relayB, relayA, relayC}, hdb.TopN(key1, 3)) + + // now let's try a different thing for key2 + // key2 has a relay list with A and B + hdb.Save(key2, relayA, hints.LastInRelayList, nostr.Now()-day*25) + hdb.Save(key2, relayB, hints.LastInRelayList, nostr.Now()-day*25) + + // but it's old, recently we only see hints for relay C + hdb.Save(key2, relayC, hints.LastInTag, nostr.Now()-5*hour) + hdb.Save(key2, relayC, hints.LastInNIP05, nostr.Now()-5*hour) + hdb.Save(key2, relayC, hints.LastInNevent, nostr.Now()-5*hour) + hdb.Save(key2, relayC, hints.LastInNprofile, nostr.Now()-5*hour) + + // at this point we just barely see C coming first + hdb.PrintScores() + require.Equal(t, []string{relayC, relayA, relayB}, hdb.TopN(key2, 3)) + + // yet a different thing for key3 + // it doesn't have relay lists published because it's banned everywhere + // all it has are references to its posts from others + hdb.Save(key3, relayA, hints.LastInTag, nostr.Now()-day*2) + hdb.Save(key3, relayB, hints.LastInNevent, nostr.Now()-day) + hdb.Save(key3, relayB, hints.LastInTag, nostr.Now()-day) + hdb.PrintScores() + require.Equal(t, []string{relayB, relayA}, hdb.TopN(key3, 3)) + + // we try to fetch events for key3 and we get a very recent one for relay A, an older for relay B + hdb.Save(key3, relayA, hints.LastFetchAttempt, nostr.Now()-5*hour) + hdb.Save(key3, relayA, hints.MostRecentEventFetched, nostr.Now()-day) + hdb.Save(key3, relayB, hints.LastFetchAttempt, nostr.Now()-5*hour) + hdb.Save(key3, relayB, hints.MostRecentEventFetched, nostr.Now()-day*30) + hdb.PrintScores() + require.Equal(t, []string{relayA, relayB}, hdb.TopN(key3, 3)) + + // for key4 we'll try the alex jones case + // key4 used to publish normally to a bunch of big relays until it got banned + // then it started publishing only to its personal relay + // how long until clients realize that? + banDate := nostr.Now() - day*10 + hdb.Save(key4, relayA, hints.LastInRelayList, banDate) + hdb.Save(key4, relayA, hints.LastFetchAttempt, banDate) + hdb.Save(key4, relayA, hints.MostRecentEventFetched, banDate) + hdb.Save(key4, relayA, hints.LastInNprofile, banDate+8*day) + hdb.Save(key4, relayA, hints.LastInNIP05, banDate+5*day) + hdb.Save(key4, relayB, hints.LastInRelayList, banDate) + hdb.Save(key4, relayB, hints.LastFetchAttempt, banDate) + hdb.Save(key4, relayB, hints.MostRecentEventFetched, banDate) + hdb.Save(key4, relayB, hints.LastInNevent, banDate+5*day) + hdb.Save(key4, relayB, hints.LastInNIP05, banDate+8*day) + hdb.Save(key4, relayB, hints.LastInNprofile, banDate+5*day) + hdb.PrintScores() + require.Equal(t, []string{relayA, relayB}, hdb.TopN(key4, 3)) + + // information about the new relay starts to spread through relay hints in tags only + hdb.Save(key4, relayC, hints.LastInTag, nostr.Now()-5*day) + hdb.Save(key4, relayC, hints.LastInTag, nostr.Now()-5*day) + hdb.Save(key4, relayC, hints.LastInNevent, nostr.Now()-5*day) + hdb.Save(key4, relayC, hints.LastInNIP05, nostr.Now()-5*day) + + // as long as we see one tag hint the new relay will already be in our map + hdb.PrintScores() + require.Equal(t, []string{relayA, relayB, relayC}, hdb.TopN(key4, 3)) + + // client tries to fetch stuff from the old relays, but gets nothing new + hdb.Save(key4, relayA, hints.LastFetchAttempt, nostr.Now()-5*hour) + hdb.Save(key4, relayB, hints.LastFetchAttempt, nostr.Now()-5*hour) + + // which is enough for us to transition to the new relay as the toppermost of the uppermost + hdb.PrintScores() + require.Equal(t, []string{relayC, relayA, relayB}, hdb.TopN(key4, 3)) + + // what if the big relays are attempting to game this algorithm by allowing some of our + // events from time to time while still shadowbanning us? + hdb.Save(key4, relayA, hints.MostRecentEventFetched, nostr.Now()-5*hour) + hdb.Save(key4, relayB, hints.MostRecentEventFetched, nostr.Now()-5*hour) + hdb.PrintScores() + require.Equal(t, []string{relayA, relayB, relayC}, hdb.TopN(key4, 3)) + + // we'll need overwhelming force from the third relay + // (actually just a relay list with just its name in it will be enough) + hdb.Save(key4, relayC, hints.LastFetchAttempt, nostr.Now()-5*hour) + hdb.Save(key4, relayC, hints.MostRecentEventFetched, nostr.Now()-6*hour) + hdb.Save(key4, relayC, hints.LastInRelayList, nostr.Now()-6*hour) + hdb.PrintScores() + require.Equal(t, []string{relayC, relayA, relayB}, hdb.TopN(key4, 3)) + + // + // + // things remain the same for key1, key2 and key3 + require.Equal(t, []string{relayC, relayA}, hdb.TopN(key2, 2)) + require.Equal(t, []string{relayB, relayA, relayC}, hdb.TopN(key1, 3)) + require.Equal(t, []string{relayA, relayB}, hdb.TopN(key3, 3)) +} diff --git a/sdk/input.go b/sdk/input.go new file mode 100644 index 0000000..bade2a7 --- /dev/null +++ b/sdk/input.go @@ -0,0 +1,65 @@ +package sdk + +import ( + "context" + "encoding/hex" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip05" + "github.com/nbd-wtf/go-nostr/nip19" +) + +// InputToProfile turns any npub/nprofile/hex/nip05 input into a ProfilePointer (or nil). +func InputToProfile(ctx context.Context, input string) *nostr.ProfilePointer { + // handle if it is a hex string + if len(input) == 64 { + if _, err := hex.DecodeString(input); err == nil { + return &nostr.ProfilePointer{PublicKey: input} + } + } + + // handle nip19 codes, if that's the case + prefix, data, _ := nip19.Decode(input) + switch prefix { + case "npub": + input = data.(string) + return &nostr.ProfilePointer{PublicKey: input} + case "nprofile": + pp := data.(nostr.ProfilePointer) + return &pp + } + + // handle nip05 ids, if that's the case + pp, _ := nip05.QueryIdentifier(ctx, input) + if pp != nil { + return pp + } + + return nil +} + +// InputToEventPointer turns any note/nevent/hex input into a EventPointer (or nil). +func InputToEventPointer(input string) *nostr.EventPointer { + // handle if it is a hex string + if len(input) == 64 { + if _, err := hex.DecodeString(input); err == nil { + return &nostr.EventPointer{ID: input} + } + } + + // handle nip19 codes, if that's the case + prefix, data, _ := nip19.Decode(input) + switch prefix { + case "note": + if input, ok := data.(string); ok { + return &nostr.EventPointer{ID: input} + } + case "nevent": + if ep, ok := data.(nostr.EventPointer); ok { + return &ep + } + } + + // handle nip05 ids, if that's the case + return nil +} diff --git a/sdk/list.go b/sdk/list.go new file mode 100644 index 0000000..5d8c74b --- /dev/null +++ b/sdk/list.go @@ -0,0 +1,82 @@ +package sdk + +import ( + "context" + "slices" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/sdk/cache" +) + +type GenericList[I TagItemWithValue] struct { + PubKey string `json:"-"` // must always be set otherwise things will break + Event *nostr.Event `json:"-"` // may be empty if a contact list event wasn't found + + Items []I +} + +type TagItemWithValue interface { + Value() string +} + +func fetchGenericList[I TagItemWithValue]( + sys *System, + ctx context.Context, + pubkey string, + kind int, + parseTag func(nostr.Tag) (I, bool), + cache cache.Cache32[GenericList[I]], + skipFetch bool, +) (fl GenericList[I], fromInternal bool) { + if cache != nil { + if v, ok := cache.Get(pubkey); ok { + return v, true + } + } + + events, _ := sys.StoreRelay.QuerySync(ctx, nostr.Filter{Kinds: []int{kind}, Authors: []string{pubkey}}) + if len(events) != 0 { + items := parseItemsFromEventTags(events[0], parseTag) + v := GenericList[I]{ + PubKey: pubkey, + Event: events[0], + Items: items, + } + cache.SetWithTTL(pubkey, v, time.Hour*6) + return v, true + } + + v := GenericList[I]{PubKey: pubkey} + if !skipFetch { + thunk := sys.replaceableLoaders[kind].Load(ctx, pubkey) + evt, err := thunk() + if err == nil { + items := parseItemsFromEventTags(evt, parseTag) + v.Items = items + if cache != nil { + cache.SetWithTTL(pubkey, v, time.Hour*6) + } + sys.StoreRelay.Publish(ctx, *evt) + } + } + + return v, false +} + +func parseItemsFromEventTags[I TagItemWithValue]( + evt *nostr.Event, + parseTag func(nostr.Tag) (I, bool), +) []I { + result := make([]I, 0, len(evt.Tags)) + for _, tag := range evt.Tags { + item, ok := parseTag(tag) + if ok { + // check if this already exists before adding + if slices.IndexFunc(result, func(i I) bool { return i.Value() == item.Value() }) == -1 { + result = append(result, item) + } + } + } + return result +} diff --git a/sdk/metadata.go b/sdk/metadata.go new file mode 100644 index 0000000..62373e6 --- /dev/null +++ b/sdk/metadata.go @@ -0,0 +1,155 @@ +package sdk + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" + "github.com/nbd-wtf/go-nostr/sdk/hints" +) + +type ProfileMetadata struct { + PubKey string `json:"-"` // must always be set otherwise things will break + Event *nostr.Event `json:"-"` // may be empty if a profile metadata event wasn't found + + // every one of these may be empty + Name string `json:"name,omitempty"` + DisplayName string `json:"display_name,omitempty"` + About string `json:"about,omitempty"` + Website string `json:"website,omitempty"` + Picture string `json:"picture,omitempty"` + Banner string `json:"banner,omitempty"` + NIP05 string `json:"nip05,omitempty"` + LUD16 string `json:"lud16,omitempty"` +} + +func (p ProfileMetadata) Npub() string { + v, _ := nip19.EncodePublicKey(p.PubKey) + return v +} + +func (p ProfileMetadata) NpubShort() string { + npub := p.Npub() + return npub[0:7] + "…" + npub[58:] +} + +func (p ProfileMetadata) Nprofile(ctx context.Context, sys *System, nrelays int) string { + v, _ := nip19.EncodeProfile(p.PubKey, sys.FetchOutboxRelays(ctx, p.PubKey, 2)) + return v +} + +func (p ProfileMetadata) ShortName() string { + if p.Name != "" { + return p.Name + } + if p.DisplayName != "" { + return p.DisplayName + } + return p.NpubShort() +} + +// FetchProfileFromInput takes an nprofile, npub, nip05 or hex pubkey and returns a ProfileMetadata, +// updating the hintsDB in the process with any eventual relay hints +func (sys System) FetchProfileFromInput(ctx context.Context, nip19OrNip05Code string) (ProfileMetadata, error) { + p := InputToProfile(ctx, nip19OrNip05Code) + if p == nil { + return ProfileMetadata{}, fmt.Errorf("couldn't decode profile reference") + } + + hintType := hints.LastInNIP05 + if strings.HasPrefix(nip19OrNip05Code, "nprofile") { + hintType = hints.LastInNprofile + } + for _, r := range p.Relays { + nm := nostr.NormalizeURL(r) + if !IsVirtualRelay(nm) { + sys.Hints.Save(p.PublicKey, nm, hintType, nostr.Now()) + } + } + + pm := sys.FetchProfileMetadata(ctx, p.PublicKey) + return pm, nil +} + +// FetchProfileMetadata fetches metadata for a given user from the local cache, or from the local store, +// or, failing these, from the target user's defined outbox relays -- then caches the result. +func (sys *System) FetchProfileMetadata(ctx context.Context, pubkey string) (pm ProfileMetadata) { + if v, ok := sys.MetadataCache.Get(pubkey); ok { + return v + } + + res, _ := sys.StoreRelay.QuerySync(ctx, nostr.Filter{Kinds: []int{0}, Authors: []string{pubkey}}) + if len(res) != 0 { + if m, err := ParseMetadata(res[0]); err == nil { + m.PubKey = pubkey + m.Event = res[0] + sys.MetadataCache.SetWithTTL(pubkey, m, time.Hour*6) + return m + } + } + + pm.PubKey = pubkey + + thunk0 := sys.replaceableLoaders[0].Load(ctx, pubkey) + evt, err := thunk0() + if err == nil { + pm, _ = ParseMetadata(evt) + + // save on store even if the metadata json is malformed + if sys.StoreRelay != nil && pm.Event != nil { + sys.StoreRelay.Publish(ctx, *pm.Event) + } + } + + // save on cache even if the metadata isn't found (unless the context was canceled) + if err == nil || err != context.Canceled { + sys.MetadataCache.SetWithTTL(pubkey, pm, time.Hour*6) + } + + return pm +} + +// FetchUserEvents fetches events from each users' outbox relays, grouping queries when possible. +func (sys *System) FetchUserEvents(ctx context.Context, filter nostr.Filter) (map[string][]*nostr.Event, error) { + filters, err := sys.ExpandQueriesByAuthorAndRelays(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to expand queries: %w", err) + } + + results := make(map[string][]*nostr.Event) + wg := sync.WaitGroup{} + wg.Add(len(filters)) + for relayURL, filter := range filters { + go func(relayURL string, filter nostr.Filter) { + defer wg.Done() + filter.Limit = filter.Limit * len(filter.Authors) // hack + for ie := range sys.Pool.SubManyEose(ctx, []string{relayURL}, nostr.Filters{filter}) { + results[ie.PubKey] = append(results[ie.PubKey], ie.Event) + } + }(relayURL, filter) + } + wg.Wait() + + return results, nil +} + +func ParseMetadata(event *nostr.Event) (meta ProfileMetadata, err error) { + if event.Kind != 0 { + err = fmt.Errorf("event %s is kind %d, not 0", event.ID, event.Kind) + } else if er := json.Unmarshal([]byte(event.Content), &meta); er != nil { + cont := event.Content + if len(cont) > 100 { + cont = cont[0:99] + } + err = fmt.Errorf("failed to parse metadata (%s) from event %s: %w", cont, event.ID, er) + } + + meta.PubKey = event.PubKey + meta.Event = event + return meta, err +} diff --git a/sdk/mutes.go b/sdk/mutes.go new file mode 100644 index 0000000..5c45953 --- /dev/null +++ b/sdk/mutes.go @@ -0,0 +1,10 @@ +package sdk + +import "context" + +type MuteList = GenericList[Follow] + +func (sys *System) FetchMuteList(ctx context.Context, pubkey string) MuteList { + ml, _ := fetchGenericList[Follow](sys, ctx, pubkey, 10000, parseFollow, nil, false) + return ml +} diff --git a/sdk/outbox.go b/sdk/outbox.go new file mode 100644 index 0000000..4fb9299 --- /dev/null +++ b/sdk/outbox.go @@ -0,0 +1,89 @@ +package sdk + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/nbd-wtf/go-nostr" +) + +func (sys *System) FetchOutboxRelays(ctx context.Context, pubkey string, n int) []string { + if relays, ok := sys.outboxShortTermCache.Get(pubkey); ok { + if len(relays) > n { + relays = relays[0:n] + } + return relays + } + + if rl, ok := sys.RelayListCache.Get(pubkey); !ok || (rl.Event != nil && rl.Event.CreatedAt < nostr.Now()-60*60*24*7) { + // try to fetch relays list again if we don't have one or if ours is a week old + fetchGenericList(sys, ctx, pubkey, 10002, parseRelayFromKind10002, sys.RelayListCache, false) + } + + relays := sys.Hints.TopN(pubkey, 6) + + if len(relays) == 0 { + return []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + sys.outboxShortTermCache.SetWithTTL(pubkey, relays, time.Minute*2) + + if len(relays) > n { + relays = relays[0:n] + } + + return relays +} + +func (sys *System) ExpandQueriesByAuthorAndRelays( + ctx context.Context, + filter nostr.Filter, +) (map[string]nostr.Filter, error) { + n := len(filter.Authors) + if n == 0 { + return nil, fmt.Errorf("no authors in filter") + } + + relaysForPubkey := make(map[string][]string, n) + mu := sync.Mutex{} + + wg := sync.WaitGroup{} + wg.Add(n) + for _, pubkey := range filter.Authors { + go func(pubkey string) { + defer wg.Done() + relayURLs := sys.FetchOutboxRelays(ctx, pubkey, 3) + c := 0 + for _, r := range relayURLs { + relay, err := sys.Pool.EnsureRelay(r) + if err != nil { + continue + } + mu.Lock() + relaysForPubkey[pubkey] = append(relaysForPubkey[pubkey], relay.URL) + mu.Unlock() + c++ + if c == 3 { + return + } + } + }(pubkey) + } + wg.Wait() + + filterForRelay := make(map[string]nostr.Filter, n) // { [relay]: filter } + for pubkey, relays := range relaysForPubkey { + for _, relay := range relays { + flt, ok := filterForRelay[relay] + if !ok { + flt = filter.Clone() + filterForRelay[relay] = flt + } + flt.Authors = append(flt.Authors, pubkey) + } + } + + return filterForRelay, nil +} diff --git a/sdk/references.go b/sdk/references.go new file mode 100644 index 0000000..3c73be5 --- /dev/null +++ b/sdk/references.go @@ -0,0 +1,108 @@ +package sdk + +import ( + "regexp" + "strconv" + "strings" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" +) + +type Reference struct { + Text string + Start int + End int + Profile *nostr.ProfilePointer + Event *nostr.EventPointer + Entity *nostr.EntityPointer +} + +var mentionRegex = regexp.MustCompile(`\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]`) + +// ParseReferences parses both NIP-08 and NIP-27 references in a single unifying interface. +func ParseReferences(evt *nostr.Event) []*Reference { + var references []*Reference + content := evt.Content + + for _, ref := range mentionRegex.FindAllStringSubmatchIndex(evt.Content, -1) { + reference := &Reference{ + Text: content[ref[0]:ref[1]], + Start: ref[0], + End: ref[1], + } + + if ref[6] == -1 { + // didn't find a NIP-10 #[0] reference, so it's a NIP-27 mention + nip19code := content[ref[2]:ref[3]] + + if prefix, data, err := nip19.Decode(nip19code); err == nil { + switch prefix { + case "npub": + reference.Profile = &nostr.ProfilePointer{ + PublicKey: data.(string), Relays: []string{}, + } + case "nprofile": + pp := data.(nostr.ProfilePointer) + reference.Profile = &pp + case "note": + reference.Event = &nostr.EventPointer{ID: data.(string), Relays: []string{}} + case "nevent": + evp := data.(nostr.EventPointer) + reference.Event = &evp + case "naddr": + addr := data.(nostr.EntityPointer) + reference.Entity = &addr + } + } + } else { + // it's a NIP-10 mention. + // parse the number, get data from event tags. + n := content[ref[6]:ref[7]] + idx, err := strconv.Atoi(n) + if err != nil || len(evt.Tags) <= idx { + continue + } + if tag := evt.Tags[idx]; tag != nil && len(tag) >= 2 { + switch tag[0] { + case "p": + relays := make([]string, 0, 1) + if len(tag) > 2 && tag[2] != "" { + relays = append(relays, tag[2]) + } + reference.Profile = &nostr.ProfilePointer{ + PublicKey: tag[1], + Relays: relays, + } + case "e": + relays := make([]string, 0, 1) + if len(tag) > 2 && tag[2] != "" { + relays = append(relays, tag[2]) + } + reference.Event = &nostr.EventPointer{ + ID: tag[1], + Relays: relays, + } + case "a": + if parts := strings.Split(tag[1], ":"); len(parts) == 3 { + kind, _ := strconv.Atoi(parts[0]) + relays := make([]string, 0, 1) + if len(tag) > 2 && tag[2] != "" { + relays = append(relays, tag[2]) + } + reference.Entity = &nostr.EntityPointer{ + Identifier: parts[2], + PublicKey: parts[1], + Kind: kind, + Relays: relays, + } + } + } + } + } + + references = append(references, reference) + } + + return references +} diff --git a/sdk/references_test.go b/sdk/references_test.go new file mode 100644 index 0000000..1743520 --- /dev/null +++ b/sdk/references_test.go @@ -0,0 +1,108 @@ +package sdk + +import ( + "fmt" + "testing" + + "github.com/nbd-wtf/go-nostr" +) + +func TestParseReferences(t *testing.T) { + evt := nostr.Event{ + Tags: nostr.Tags{ + {"p", "c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8", "wss://nostr.com"}, + {"e", "a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33", "wss://other.com", "reply"}, + {"e", "31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8", ""}, + }, + Content: "hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]", + } + + expected := []Reference{ + { + Text: "#[0]", + Start: 6, + End: 10, + Profile: &nostr.ProfilePointer{ + PublicKey: "c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8", + Relays: []string{"wss://nostr.com"}, + }, + }, + { + Text: "#[2]", + Start: 26, + End: 30, + Event: &nostr.EventPointer{ + ID: "31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8", + Relays: []string{}, + }, + }, + { + Text: "nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg", + Start: 47, + End: 123, + Profile: &nostr.ProfilePointer{ + PublicKey: "cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393", + Relays: []string{}, + }, + }, + { + Text: "nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4", + Start: 127, + End: 201, + Event: &nostr.EventPointer{ + ID: "cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393", + Relays: []string{}, + Author: "", + }, + }, + } + + got := ParseReferences(&evt) + + if len(got) != len(expected) { + t.Errorf("got %d references, expected %d", len(got), len(expected)) + } + + for i, g := range got { + e := expected[i] + if g.Text != e.Text { + t.Errorf("%d: got text %s, expected %s", i, g.Text, e.Text) + } + + if g.Start != e.Start { + t.Errorf("%d: got start %d, expected %d", i, g.Start, e.Start) + } + + if g.End != e.End { + t.Errorf("%d: got end %d, expected %d", i, g.End, e.End) + } + + if (g.Entity == nil && e.Entity != nil) || + (g.Event == nil && e.Event != nil) || + (g.Profile == nil && e.Profile != nil) { + t.Errorf("%d: got some unexpected nil", i) + } + + if g.Profile != nil && (g.Profile.PublicKey != e.Profile.PublicKey || + len(g.Profile.Relays) != len(e.Profile.Relays) || + (len(g.Profile.Relays) > 0 && g.Profile.Relays[0] != e.Profile.Relays[0])) { + t.Errorf("%d: profile value is wrong", i) + } + + if g.Event != nil && (g.Event.ID != e.Event.ID || + g.Event.Author != e.Event.Author || + len(g.Event.Relays) != len(e.Event.Relays) || + (len(g.Event.Relays) > 0 && g.Event.Relays[0] != e.Event.Relays[0])) { + fmt.Println(g.Event.ID, g.Event.Relays, len(g.Event.Relays), g.Event.Relays[0] == "") + fmt.Println(e.Event.Relays, len(e.Event.Relays)) + t.Errorf("%d: event value is wrong", i) + } + + if g.Entity != nil && (g.Entity.PublicKey != e.Entity.PublicKey || + g.Entity.Identifier != e.Entity.Identifier || + g.Entity.Kind != e.Entity.Kind || + len(g.Entity.Relays) != len(g.Entity.Relays)) { + t.Errorf("%d: entity value is wrong", i) + } + } +} diff --git a/sdk/relays.go b/sdk/relays.go new file mode 100644 index 0000000..75994a2 --- /dev/null +++ b/sdk/relays.go @@ -0,0 +1,41 @@ +package sdk + +import ( + "github.com/nbd-wtf/go-nostr" +) + +type RelayList = GenericList[Relay] + +type Relay struct { + URL string + Inbox bool + Outbox bool +} + +func (r Relay) Value() string { return r.URL } + +func parseRelayFromKind10002(tag nostr.Tag) (rl Relay, ok bool) { + if u := tag.Value(); u != "" && tag[0] == "r" { + if !nostr.IsValidRelayURL(u) { + return rl, false + } + u := nostr.NormalizeURL(u) + + relay := Relay{ + URL: u, + } + + if len(tag) == 2 { + relay.Inbox = true + relay.Outbox = true + } else if tag[2] == "write" { + relay.Outbox = true + } else if tag[2] == "read" { + relay.Inbox = true + } + + return relay, true + } + + return rl, false +} diff --git a/sdk/replaceable_loader.go b/sdk/replaceable_loader.go new file mode 100644 index 0000000..2ab70c3 --- /dev/null +++ b/sdk/replaceable_loader.go @@ -0,0 +1,194 @@ +package sdk + +import ( + "context" + "fmt" + "strconv" + "sync" + "time" + + "github.com/graph-gophers/dataloader/v7" + "github.com/nbd-wtf/go-nostr" +) + +type EventResult dataloader.Result[*nostr.Event] + +func (sys *System) initializeDataloaders() { + sys.replaceableLoaders = make(map[int]*dataloader.Loader[string, *nostr.Event]) + for _, kind := range []int{0, 3, 10000, 10001, 10002, 10003, 10004, 10005, 10006, 10007, 10015, 10030} { + sys.replaceableLoaders[kind] = sys.createReplaceableDataloader(kind) + } +} + +func (sys *System) createReplaceableDataloader(kind int) *dataloader.Loader[string, *nostr.Event] { + return dataloader.NewBatchedLoader( + func( + ctx context.Context, + pubkeys []string, + ) []*dataloader.Result[*nostr.Event] { + return sys.batchLoadReplaceableEvents(ctx, kind, pubkeys) + }, + dataloader.WithBatchCapacity[string, *nostr.Event](60), + dataloader.WithClearCacheOnBatch[string, *nostr.Event](), + dataloader.WithWait[string, *nostr.Event](time.Millisecond*350), + ) +} + +func (sys *System) batchLoadReplaceableEvents( + ctx context.Context, + kind int, + pubkeys []string, +) []*dataloader.Result[*nostr.Event] { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*4) + defer cancel() + + batchSize := len(pubkeys) + results := make([]*dataloader.Result[*nostr.Event], batchSize) + keyPositions := make(map[string]int) // { [pubkey]: slice_index } + relayFilters := make(map[string]nostr.Filter) // { [relayUrl]: filter } + + wg := sync.WaitGroup{} + wg.Add(len(pubkeys)) + cm := sync.Mutex{} + + for i, pubkey := range pubkeys { + // build batched queries for the external relays + keyPositions[pubkey] = i // this is to help us know where to save the result later + + go func(i int, pubkey string) { + defer wg.Done() + + // if we're attempting this query with a short key (last 8 characters), stop here + if len(pubkey) != 64 { + results[i] = &dataloader.Result[*nostr.Event]{ + Error: fmt.Errorf("won't proceed to query relays with a shortened key (%d)", kind), + } + return + } + + // save attempts here so we don't try the same failed query over and over + if doItNow := DoThisNotMoreThanOnceAnHour("repl:" + strconv.Itoa(kind) + pubkey); !doItNow { + results[i] = &dataloader.Result[*nostr.Event]{ + Error: fmt.Errorf("last attempt failed, waiting more to try again"), + } + return + } + + // gather relays we'll use for this pubkey + relays := sys.determineRelaysToQuery(ctx, pubkey, kind) + + // by default we will return an error (this will be overwritten when we find an event) + results[i] = &dataloader.Result[*nostr.Event]{ + Error: fmt.Errorf("couldn't find a kind %d event anywhere %v", kind, relays), + } + + cm.Lock() + for _, relay := range relays { + // each relay will have a custom filter + filter, ok := relayFilters[relay] + if !ok { + filter = nostr.Filter{ + Kinds: []int{kind}, + Authors: make([]string, 0, batchSize-i /* this and all pubkeys after this can be added */), + } + } + filter.Authors = append(filter.Authors, pubkey) + relayFilters[relay] = filter + } + cm.Unlock() + }(i, pubkey) + } + + // query all relays with the prepared filters + wg.Wait() + multiSubs := sys.batchReplaceableRelayQueries(ctx, relayFilters) + for { + select { + case evt, more := <-multiSubs: + if !more { + return results + } + + // insert this event at the desired position + pos := keyPositions[evt.PubKey] // @unchecked: it must succeed because it must be a key we passed + if results[pos].Data == nil || results[pos].Data.CreatedAt < evt.CreatedAt { + results[pos] = &dataloader.Result[*nostr.Event]{Data: evt} + } + case <-ctx.Done(): + return results + } + } +} + +func (sys *System) determineRelaysToQuery(ctx context.Context, pubkey string, kind int) []string { + relays := make([]string, 0, 10) + + // search in specific relays for user + if kind == 10002 { + // prevent infinite loops by jumping directly to this + relays = sys.Hints.TopN(pubkey, 3) + } else if kind == 0 { + // leave room for one hardcoded relay because people are stupid + relays = sys.FetchOutboxRelays(ctx, pubkey, 2) + } else { + relays = sys.FetchOutboxRelays(ctx, pubkey, 3) + } + + // use a different set of extra relays depending on the kind + for len(relays) < 3 { + switch kind { + case 0: + relays = append(relays, pickNext(sys.MetadataRelays)) + case 3: + relays = append(relays, pickNext(sys.FollowListRelays)) + case 10002: + relays = append(relays, pickNext(sys.RelayListRelays)) + default: + relays = append(relays, pickNext(sys.FallbackRelays)) + } + } + + return relays +} + +// batchReplaceableRelayQueries subscribes to multiple relays using a different filter for each and returns +// a single channel with all results. it closes on EOSE or when all the expected events were returned. +// +// the number of expected events is given by the number of pubkeys in the .Authors filter field. +// because of that, batchReplaceableRelayQueries is only suitable for querying replaceable events -- and +// care must be taken to not include the same pubkey more than once in the filter .Authors array. +func (sys *System) batchReplaceableRelayQueries( + ctx context.Context, + relayFilters map[string]nostr.Filter, +) <-chan *nostr.Event { + all := make(chan *nostr.Event) + + wg := sync.WaitGroup{} + wg.Add(len(relayFilters)) + for url, filter := range relayFilters { + go func(url string, filter nostr.Filter) { + defer wg.Done() + n := len(filter.Authors) + + ctx, cancel := context.WithTimeout(ctx, time.Millisecond*450+time.Millisecond*50*time.Duration(n)) + defer cancel() + + received := 0 + for ie := range sys.Pool.SubManyEose(ctx, []string{url}, nostr.Filters{filter}) { + all <- ie.Event + received++ + if received >= n { + // we got all events we asked for, unless the relay is shitty and sent us two from the same + return + } + } + }(url, filter) + } + + go func() { + wg.Wait() + close(all) + }() + + return all +} diff --git a/sdk/search.go b/sdk/search.go new file mode 100644 index 0000000..3dd6ee7 --- /dev/null +++ b/sdk/search.go @@ -0,0 +1,24 @@ +package sdk + +import ( + "context" + + "github.com/nbd-wtf/go-nostr" +) + +func (sys *System) SearchUsers(ctx context.Context, query string) []ProfileMetadata { + limit := 10 + profiles := make([]ProfileMetadata, 0, limit*len(sys.UserSearchRelays)) + + for ie := range sys.Pool.SubManyEose(ctx, sys.UserSearchRelays, nostr.Filters{ + { + Search: query, + Limit: limit, + }, + }) { + m, _ := ParseMetadata(ie.Event) + profiles = append(profiles, m) + } + + return profiles +} diff --git a/sdk/system.go b/sdk/system.go new file mode 100644 index 0000000..8bee234 --- /dev/null +++ b/sdk/system.go @@ -0,0 +1,158 @@ +package sdk + +import ( + "context" + + "github.com/fiatjaf/eventstore" + "github.com/fiatjaf/eventstore/slicestore" + "github.com/graph-gophers/dataloader/v7" + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/sdk/cache" + cache_memory "github.com/nbd-wtf/go-nostr/sdk/cache/memory" + "github.com/nbd-wtf/go-nostr/sdk/hints" + memory_hints "github.com/nbd-wtf/go-nostr/sdk/hints/memory" +) + +type System struct { + RelayListCache cache.Cache32[RelayList] + FollowListCache cache.Cache32[FollowList] + MetadataCache cache.Cache32[ProfileMetadata] + Hints hints.HintsDB + Pool *nostr.SimplePool + RelayListRelays []string + FollowListRelays []string + MetadataRelays []string + FallbackRelays []string + UserSearchRelays []string + NoteSearchRelays []string + Store eventstore.Store + + StoreRelay nostr.RelayStore + + replaceableLoaders map[int]*dataloader.Loader[string, *nostr.Event] + outboxShortTermCache cache.Cache32[[]string] +} + +type SystemModifier func(sys *System) + +func NewSystem(mods ...SystemModifier) *System { + sys := &System{ + RelayListCache: cache_memory.New32[RelayList](1000), + FollowListCache: cache_memory.New32[FollowList](1000), + MetadataCache: cache_memory.New32[ProfileMetadata](1000), + RelayListRelays: []string{"wss://purplepag.es", "wss://user.kindpag.es", "wss://relay.nos.social"}, + FollowListRelays: []string{"wss://purplepag.es", "wss://user.kindpag.es", "wss://relay.nos.social"}, + MetadataRelays: []string{"wss://purplepag.es", "wss://user.kindpag.es", "wss://relay.nos.social"}, + FallbackRelays: []string{ + "wss://relay.primal.net", + "wss://relay.damus.io", + "wss://nostr.wine", + "wss://nostr.mom", + "wss://offchain.pub", + "wss://nos.lol", + "wss://mostr.pub", + "wss://relay.nostr.band", + "wss://nostr21.com", + }, + UserSearchRelays: []string{ + "wss://nostr.wine", + "wss://relay.nostr.band", + "wss://relay.noswhere.com", + }, + NoteSearchRelays: []string{ + "wss://nostr.wine", + "wss://relay.nostr.band", + "wss://relay.noswhere.com", + }, + Hints: memory_hints.NewHintDB(), + + outboxShortTermCache: cache_memory.New32[[]string](1000), + } + + sys.Pool = nostr.NewSimplePool(context.Background(), + nostr.WithEventMiddleware(sys.trackEventHints), + nostr.WithPenaltyBox(), + ) + + for _, mod := range mods { + mod(sys) + } + + if sys.Store == nil { + sys.Store = &slicestore.SliceStore{} + sys.Store.Init() + } + sys.StoreRelay = eventstore.RelayWrapper{Store: sys.Store} + + sys.initializeDataloaders() + + return sys +} + +func (sys *System) Close() {} + +func WithHintsDB(hdb hints.HintsDB) SystemModifier { + return func(sys *System) { + sys.Hints = hdb + } +} + +func WithRelayListRelays(list []string) SystemModifier { + return func(sys *System) { + sys.RelayListRelays = list + } +} + +func WithMetadataRelays(list []string) SystemModifier { + return func(sys *System) { + sys.MetadataRelays = list + } +} + +func WithFollowListRelays(list []string) SystemModifier { + return func(sys *System) { + sys.FollowListRelays = list + } +} + +func WithFallbackRelays(list []string) SystemModifier { + return func(sys *System) { + sys.FallbackRelays = list + } +} + +func WithUserSearchRelays(list []string) SystemModifier { + return func(sys *System) { + sys.UserSearchRelays = list + } +} + +func WithNoteSearchRelays(list []string) SystemModifier { + return func(sys *System) { + sys.NoteSearchRelays = list + } +} + +func WithStore(store eventstore.Store) SystemModifier { + return func(sys *System) { + sys.Store = store + } +} + +func WithRelayListCache(cache cache.Cache32[RelayList]) SystemModifier { + return func(sys *System) { + sys.RelayListCache = cache + } +} + +func WithFollowListCache(cache cache.Cache32[FollowList]) SystemModifier { + return func(sys *System) { + sys.FollowListCache = cache + } +} + +func WithMetadataCache(cache cache.Cache32[ProfileMetadata]) SystemModifier { + return func(sys *System) { + sys.MetadataCache = cache + } +} diff --git a/sdk/tracker.go b/sdk/tracker.go new file mode 100644 index 0000000..2955b34 --- /dev/null +++ b/sdk/tracker.go @@ -0,0 +1,86 @@ +package sdk + +import ( + "net/url" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/sdk/hints" +) + +func (sys *System) trackEventHints(ie nostr.IncomingEvent) { + if IsVirtualRelay(ie.Relay.URL) { + return + } + + switch ie.Kind { + case nostr.KindRelayListMetadata: + for _, tag := range ie.Tags { + if len(tag) < 2 || tag[0] != "r" { + continue + } + if len(tag) == 2 || (tag[2] == "" || tag[2] == "write") { + sys.Hints.Save(ie.PubKey, tag[1], hints.LastInRelayList, ie.CreatedAt) + } + } + case nostr.KindContactList: + sys.Hints.Save(ie.PubKey, ie.Relay.URL, hints.MostRecentEventFetched, ie.CreatedAt) + + for _, tag := range ie.Tags { + if len(tag) < 3 { + continue + } + if IsVirtualRelay(tag[2]) { + continue + } + if p, err := url.Parse(tag[2]); err != nil || (p.Scheme != "wss" && p.Scheme != "ws") { + continue + } + if tag[0] == "p" && nostr.IsValidPublicKey(tag[1]) { + sys.Hints.Save(tag[1], tag[2], hints.LastInTag, ie.CreatedAt) + } + } + case nostr.KindTextNote: + sys.Hints.Save(ie.PubKey, ie.Relay.URL, hints.MostRecentEventFetched, ie.CreatedAt) + + for _, tag := range ie.Tags { + if len(tag) < 3 { + continue + } + if IsVirtualRelay(tag[2]) { + continue + } + if p, err := url.Parse(tag[2]); err != nil || (p.Scheme != "wss" && p.Scheme != "ws") { + continue + } + if tag[0] == "p" && nostr.IsValidPublicKey(tag[1]) { + sys.Hints.Save(tag[1], tag[2], hints.LastInTag, ie.CreatedAt) + } + } + + for _, ref := range ParseReferences(ie.Event) { + if ref.Profile != nil { + for _, relay := range ref.Profile.Relays { + if IsVirtualRelay(relay) { + continue + } + if p, err := url.Parse(relay); err != nil || (p.Scheme != "wss" && p.Scheme != "ws") { + continue + } + if nostr.IsValidPublicKey(ref.Profile.PublicKey) { + sys.Hints.Save(ref.Profile.PublicKey, relay, hints.LastInNprofile, ie.CreatedAt) + } + } + } else if ref.Event != nil && nostr.IsValidPublicKey(ref.Event.Author) { + for _, relay := range ref.Event.Relays { + if IsVirtualRelay(relay) { + continue + } + if p, err := url.Parse(relay); err != nil || (p.Scheme != "wss" && p.Scheme != "ws") { + continue + } + sys.Hints.Save(ref.Event.Author, relay, hints.LastInNevent, ie.CreatedAt) + } + } + } + } +} diff --git a/sdk/utils.go b/sdk/utils.go new file mode 100644 index 0000000..65f74a1 --- /dev/null +++ b/sdk/utils.go @@ -0,0 +1,43 @@ +package sdk + +import ( + "sync" + "time" +) + +var ( + _dtnmtoah map[string]time.Time + _dtnmtoahLock sync.Mutex +) + +func DoThisNotMoreThanOnceAnHour(key string) (doItNow bool) { + if _dtnmtoah == nil { + go func() { + _dtnmtoah = make(map[string]time.Time) + for { + time.Sleep(time.Minute * 10) + _dtnmtoahLock.Lock() + now := time.Now() + for k, v := range _dtnmtoah { + if v.Before(now) { + delete(_dtnmtoah, k) + } + } + _dtnmtoahLock.Unlock() + } + }() + } + + _dtnmtoahLock.Lock() + defer _dtnmtoahLock.Unlock() + + _, exists := _dtnmtoah[key] + return !exists +} + +var serial = 0 + +func pickNext(list []string) string { + serial++ + return list[serial%len(list)] +}