Skip to content

Commit

Permalink
multi: implement user onboarding game (#2921)
Browse files Browse the repository at this point in the history
* implement geogame

* internationalize

* remove superfluous module
  • Loading branch information
buck54321 authored Aug 23, 2024
1 parent b066c96 commit bce9337
Show file tree
Hide file tree
Showing 15 changed files with 392 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@ server/cmd/validatemarkets
client/cmd/translationsreport/translationsreport
client/cmd/translationsreport/worksheets
server/cmd/dexadm/dexadm
server/cmd/geogame/geogame
153 changes: 152 additions & 1 deletion client/asset/dcr/dcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
neturl "net/url"
Expand Down Expand Up @@ -44,6 +45,7 @@ import (
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/decred/dcrd/dcrutil/v4"
"github.com/decred/dcrd/hdkeychain/v3"
chainjson "github.com/decred/dcrd/rpc/jsonrpc/types/v4"
"github.com/decred/dcrd/txscript/v4"
"github.com/decred/dcrd/txscript/v4/sign"
Expand Down Expand Up @@ -1310,7 +1312,7 @@ func (dcr *ExchangeWallet) feeRate(confTarget uint64) (uint64, error) {
return 0, errors.New("fee rate oracle is in a temporary failing state")
}

dcr.log.Debugf("Retrieving fee rate from external fee oracle for %d target blocks", confTarget)
dcr.log.Tracef("Retrieving fee rate from external fee oracle for %d target blocks", confTarget)
dcrPerKB, err := fetchFeeFromOracle(dcr.ctx, dcr.network, confTarget)
if err != nil {
// Just log it and return zero. If we return an error, it's just logged
Expand Down Expand Up @@ -7205,3 +7207,152 @@ func (dcr *ExchangeWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset
CoinID: coinID,
}, nil
}

var _ asset.GeocodeRedeemer = (*ExchangeWallet)(nil)

// RedeemGeocode redeems funds from a geocode game tx to this wallet.
func (dcr *ExchangeWallet) RedeemGeocode(code []byte, msg string) (dex.Bytes, uint64, error) {
msgLen := len([]byte(msg))
if msgLen > stdscript.MaxDataCarrierSizeV0 {
return nil, 0, fmt.Errorf("message is too long. must be %d > %d", msgLen, stdscript.MaxDataCarrierSizeV0)
}

k, err := hdkeychain.NewMaster(code, dcr.chainParams)
if err != nil {
return nil, 0, fmt.Errorf("error generating key from bond: %w", err)
}
gameKey, err := k.SerializedPrivKey()
if err != nil {
return nil, 0, fmt.Errorf("error serializing private key: %w", err)
}

gamePub := k.SerializedPubKey()
gameAddr, err := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(dcrutil.Hash160(gamePub), dcr.chainParams)
if err != nil {
return nil, 0, fmt.Errorf("error generating address: %w", err)
}

gameTxs, err := getDcrdataTxs(dcr.ctx, gameAddr.String(), dcr.network)
if err != nil {
return nil, 0, fmt.Errorf("error getting tx from dcrdata: %w", err)
}

_, gameScript := gameAddr.PaymentScript()

feeRate, err := dcr.feeRate(2)
if err != nil {
return nil, 0, fmt.Errorf("error getting tx fee rate: %w", err)
}

redeemTx := wire.NewMsgTx()
var redeemable int64
for _, gameTx := range gameTxs {
txHash := gameTx.TxHash()
for vout, txOut := range gameTx.TxOut {
if bytes.Equal(txOut.PkScript, gameScript) {
redeemable += txOut.Value
prevOut := wire.NewOutPoint(&txHash, uint32(vout), wire.TxTreeRegular)
redeemTx.AddTxIn(wire.NewTxIn(prevOut, txOut.Value, gameScript))
}
}
}

if len(redeemTx.TxIn) == 0 {
return nil, 0, fmt.Errorf("no spendable game outputs found in %d txs for address %s", len(gameTxs), gameAddr)
}

var txSize uint64 = dexdcr.MsgTxOverhead + uint64(len(redeemTx.TxIn))*dexdcr.P2PKHInputSize + dexdcr.P2PKHOutputSize
if msgLen > 0 {
txSize += dexdcr.TxOutOverhead + 1 /* opreturn */ + uint64(wire.VarIntSerializeSize(uint64(msgLen))) + uint64(msgLen)
}
fees := feeRate * txSize

if uint64(redeemable) < fees {
return nil, 0, fmt.Errorf("estimated fees %d are less than the redeemable value %d", fees, redeemable)
}
win := uint64(redeemable) - fees
if dexdcr.IsDustVal(dexdcr.P2PKHOutputSize, win, feeRate) {
return nil, 0, fmt.Errorf("received value is dust after fees: %d - %d = %d", redeemable, fees, win)
}

redeemAddr, err := dcr.wallet.ExternalAddress(dcr.ctx, dcr.depositAccount())
if err != nil {
return nil, 0, fmt.Errorf("error getting redeem address: %w", err)
}
_, redeemScript := redeemAddr.PaymentScript()

redeemTx.AddTxOut(wire.NewTxOut(int64(win), redeemScript))
if msgLen > 0 {
msgScript, err := txscript.NewScriptBuilder().AddOp(txscript.OP_RETURN).AddData([]byte(msg)).Script()
if err != nil {
return nil, 0, fmt.Errorf("error building message script: %w", err)
}
redeemTx.AddTxOut(wire.NewTxOut(0, msgScript))
}

for vin, txIn := range redeemTx.TxIn {
redeemInSig, err := sign.RawTxInSignature(redeemTx, vin, gameScript, txscript.SigHashAll,
gameKey, dcrec.STEcdsaSecp256k1)
if err != nil {
return nil, 0, fmt.Errorf("error creating signature for input script: %w", err)
}
txIn.SignatureScript, err = txscript.NewScriptBuilder().AddData(redeemInSig).AddData(gamePub).Script()
if err != nil {
return nil, 0, fmt.Errorf("error building p2pkh sig script: %w", err)
}
}

redeemHash, err := dcr.broadcastTx(redeemTx)
if err != nil {
return nil, 0, fmt.Errorf("error broadcasting tx: %w", err)
}

return toCoinID(redeemHash, 0), win, nil
}

func getDcrdataTxs(ctx context.Context, addr string, net dex.Network) (txs []*wire.MsgTx, _ error) {
apiRoot := "https://dcrdata.decred.org/api/"
switch net {
case dex.Testnet:
apiRoot = "https://testnet.dcrdata.org/api/"
case dex.Simnet:
apiRoot = "http://127.0.0.1:17779/api/"
}

var resp struct {
Txs []struct {
TxID string `json:"txid"`
} `json:"address_transactions"`
}
if err := dexnet.Get(ctx, apiRoot+"address/"+addr, &resp); err != nil {
return nil, fmt.Errorf("error getting address info for address %q: %w", addr, err)
}
for _, tx := range resp.Txs {
txID := tx.TxID

// tx/hex response is a hex string but is not JSON encoded.
r, err := http.DefaultClient.Get(apiRoot + "tx/hex/" + txID)
if err != nil {
return nil, fmt.Errorf("error getting transaction %q: %w", txID, err)
}
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
r.Body.Close()
hexTx := string(b)
txB, err := hex.DecodeString(hexTx)
if err != nil {
return nil, fmt.Errorf("error decoding hex for tx %q: %w", txID, err)
}

tx, err := msgTxFromBytes(txB)
if err != nil {
return nil, fmt.Errorf("error deserializing tx %x: %w", txID, err)
}
txs = append(txs, tx)
}

return
}
5 changes: 5 additions & 0 deletions client/asset/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,11 @@ type MultiOrder struct {
RedeemAssetID uint32
}

// A GeocodeRedeemer redeems funds from a geocode game.
type GeocodeRedeemer interface {
RedeemGeocode(code []byte, msg string) (dex.Bytes, uint64, error)
}

// WalletNotification can be any asynchronous information the wallet needs
// to convey.
type WalletNotification any
Expand Down
56 changes: 56 additions & 0 deletions client/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -10755,3 +10755,59 @@ func (c *Core) checkEpochResolution(host string, mktID string) {

}
}

// RedeemGeocode redeems the provided game code with the wallet and redeems the
// prepaid bond (code is a prepaid bond). If the user is not registered with
// dex.decred.org yet, the dex will be added first.
func (c *Core) RedeemGeocode(appPW, code []byte, msg string) (dex.Bytes, uint64, error) {
const dcrBipID = 42
dcrWallet, found := c.wallet(dcrBipID)
if !found {
return nil, 0, errors.New("no decred wallet")
}
if !dcrWallet.connected() {
return nil, 0, errors.New("decred wallet is not connected")
}

host := "dex.decred.org:7232"
switch c.net {
case dex.Testnet:
host = "bison.exchange:17232"
case dex.Simnet:
host = "127.0.0.1:17273"
}
cert := CertStore[c.net][host]

c.connMtx.RLock()
dc, found := c.conns[host]
c.connMtx.RUnlock()
if !found {
if err := c.AddDEX(appPW, host, cert); err != nil {
return nil, 0, fmt.Errorf("error adding %s: %w", host, err)
}
c.connMtx.RLock()
_, found = c.conns[host]
c.connMtx.RUnlock()
if !found {
return nil, 0, fmt.Errorf("dex not found after adding")
}
} else if dc.status() != comms.Connected {
return nil, 0, fmt.Errorf("not currently connected to %s", host)
}

w, is := dcrWallet.Wallet.(asset.GeocodeRedeemer)
if !is {
return nil, 0, errors.New("decred wallet is not a GeocodeRedeemer?")
}

coinID, win, err := w.RedeemGeocode(code, msg)
if err != nil {
return nil, 0, fmt.Errorf("error redeeming geocode: %w", err)
}

if _, err := c.RedeemPrepaidBond(appPW, code, host, cert); err != nil {
return nil, 0, fmt.Errorf("geocode redeemed, but failed to redeem prepaid bond: %w", err)
}

return coinID, win, nil
}
35 changes: 35 additions & 0 deletions client/webserver/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2016,6 +2016,41 @@ func (s *WebServer) apiTakeAction(w http.ResponseWriter, r *http.Request) {
writeJSON(w, simpleAck())
}

func (s *WebServer) redeemGameCode(w http.ResponseWriter, r *http.Request) {
var form struct {
Code dex.Bytes `json:"code"`
Msg string `json:"msg"`
AppPW encode.PassBytes `json:"appPW"`
}
if !readPost(w, r, &form) {
return
}
defer form.AppPW.Clear()
appPW, err := s.resolvePass(form.AppPW, r)
if err != nil {
s.writeAPIError(w, fmt.Errorf("password error: %w", err))
return
}
coinID, win, err := s.core.RedeemGeocode(appPW, form.Code, form.Msg)
if err != nil {
s.writeAPIError(w, fmt.Errorf("redemption error: %w", err))
return
}
const dcrBipID = 42
coinIDString, _ := asset.DecodeCoinID(dcrBipID, coinID)
writeJSON(w, &struct {
OK bool `json:"ok"`
CoinID dex.Bytes `json:"coinID"`
CoinString string `json:"coinString"`
Win uint64 `json:"win"`
}{
OK: true,
CoinID: coinID,
CoinString: coinIDString,
Win: win,
})
}

// writeAPIError logs the formatted error and sends a standardResponse with the
// error message.
func (s *WebServer) writeAPIError(w http.ResponseWriter, err error) {
Expand Down
2 changes: 2 additions & 0 deletions client/webserver/jsintl.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ const (
completeID = "COMPLETE"
archivedSettingsID = "ARCHIVED_SETTINGS"
idTransparent = "TRANSPARENT"
idNoCodeProvided = "NO_CODE_PROVIDED"
)

var enUS = map[string]*intl.Translation{
Expand Down Expand Up @@ -387,6 +388,7 @@ var enUS = map[string]*intl.Translation{
completeID: {T: "Complete"},
archivedSettingsID: {T: "Archived Settings"},
idTransparent: {T: "Transparent"},
idNoCodeProvided: {T: "no code provided"},
}

var ptBR = map[string]*intl.Translation{
Expand Down
5 changes: 5 additions & 0 deletions client/webserver/live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2025,6 +2025,11 @@ func (c *TCore) TakeAction(assetID uint32, actionID string, actionB json.RawMess
return nil
}

func (c *TCore) RedeemGeocode(appPW, code []byte, msg string) (dex.Bytes, uint64, error) {
coinID, _ := hex.DecodeString("308e9a3675fc3ea3862b7863eeead08c621dcc37ff59de597dd3cdab41450ad900000001")
return coinID, 100e8, nil
}

func newMarketDay() *libxc.MarketDay {
avgPrice := tenToThe(7)
return &libxc.MarketDay{
Expand Down
8 changes: 8 additions & 0 deletions client/webserver/locales/en-us.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,4 +645,12 @@ var EnUS = map[string]*intl.Translation{
"Balance Discovery": {T: "Balance Discovery"},
"Finding Addresses": {T: "Finding Addresses"},
"Hide Mixing Transactions": {T: "Hide Mixing Transactions"},
"Redeem game code": {T: "Redeem game code"},
"Redeem Game Code": {T: "Redeem Game Code"},
"Code": {T: "Code"},
"Message_optional": {T: "Message (optional)"},
"Game code redeemed": {T: "Game code redeemed!"},
"Transaction": {T: "Transaction"},
"Value": {T: "Value"},
"Prepaid bond redeemed": {T: "Prepaid bond redeemed!"},
}
3 changes: 2 additions & 1 deletion client/webserver/site/src/css/forms.scss
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ div[data-handler=register] {
#reconfigForm,
#authorizeSeedDisplay,
#seedBackupForm,
#votingForm {
#votingForm,
#gameCodeForm {
width: 425px;
}

Expand Down
25 changes: 24 additions & 1 deletion client/webserver/site/src/html/settings.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,13 @@
<button id="changeAppPW" class="my-1 {{if not $authed}} d-hide{{end}}">[[[Change App Password]]]</button>
<button id="resetAppPW" class="my-1 {{if or $authed }} d-hide{{end}}">[[[Reset App Password]]]</button>
</div>
<div class="mb-3 py-3 border-bottom {{if not .UserInfo.Authed}}d-hide{{end}}">
<div class="py-3 border-bottom {{if not .UserInfo.Authed}}d-hide{{end}}">
<p class="grey">[[[seed_implore_msg]]]</p>
<button id="exportSeed" class="fs15">[[[View Application Seed]]]</button>
</div>
<div id="gameCodeLink" class="py-3 mb-3 border-bottom pointer hoverbg">
<span class="ico-ticket"></span> [[[Redeem game code]]]
</div>
<p class="grey">[[[Build ID]]]: <span id="commitHash" class="mono"></span></p>
</div>
</section>
Expand Down Expand Up @@ -150,6 +153,26 @@
<form class="d-hide" id="walletWait">
{{template "waitingForWalletForm"}}
</form>

<form id="gameCodeForm" class="d-hide">
<div class="form-closer"><span class="ico-cross"></span></div>
<header><span class="ico-ticket me-2"></span> [[[Redeem Game Code]]]</header>
<div class="px-3 flex-stretch-column">
<label for="gameCodeInput" class="pt-2">[[[Code]]]</label>
<input type="text" id="gameCodeInput">
<label for="gameCodeMsg" class="mt-2">[[[Message_optional]]]</label>
<input type="text" id="gameCodeMsg" maxlength="256">
<button type="button" id="gameCodeSubmit" class="feature mt-2">[[[Redeem]]]</button>
<div id="gameCodeSuccess" class="mt-2 pt-2 border-top flex-stretch-column d-hide">
<span>[[[Game code redeemed]]]</span>
<span class="mt-2">[[[Transaction]]]</span>
<a id="gameRedeemTx" class="mt-1 word-break-all" target="_blank"></a>
<span class="mt-2">[[[Value]]]: <span id="gameRedeemValue"></span> <span class="fs14 grey">DCR</span></span>
<span class="mt-2">[[[Prepaid bond redeemed]]]</span>
</div>
<div id="gameCodeErr" class="mt-2 text-warning d-hide"></div>
</div>
</form>
</div>
</div>
{{template "bottom"}}
Expand Down
1 change: 1 addition & 0 deletions client/webserver/site/src/js/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export const ID_CEXBALANCE_ERR = 'CEXBALANCE_ERR'
export const ID_PENDING = 'PENDING'
export const ID_COMPLETE = 'COMPLETE'
export const ID_ARCHIVED_SETTINGS = 'ARCHIVED_SETTINGS'
export const ID_NO_CODE_PROVIDED = 'NO_CODE_PROVIDED'

let locale: Locale

Expand Down
Loading

0 comments on commit bce9337

Please sign in to comment.