mpurse, mpchain, counterParty, counterBlock APIを使えば送金できるはず。その方法を調査してみる。

結論

トランザクション作成APIと思しきcreate_sendmpurse経由で実行するもエラーに阻まれてしまった。

const cpParams = {
    source: 'MEHCqJbgiNERCH3bRAtNSSD9uxPViEX1nu',
    destination: 'MEHCqJbgiNERCH3bRAtNSSD9uxPViEX1nu',
    asset: 'MONA',
    quantity: 0.00000000,
    memo: null,
    memo_is_hex: false,
    fee_per_kb: 0,
    allow_unconfirmed_inputs: true,
    extended_tx_info: true,
    disable_utxo_locks: true,
};
await window.mpurse.counterParty('create_send', cpParams);
//const unspentTxouts = await window.mpurse.counterParty('create_send', cpParams);

実行したら以下のように怒られた。

{
    "code": -32000,
    "data": {
        "args": [
            "{\"message\": \"Error composing send transaction via API: Destination output is dust.\", \"code\": -32001}"
        ],
        "message": "{\"message\": \"Error composing send transaction via API: Destination output is dust.\", \"code\": -32001}",
        "type": "Exception"
    },
    "message": "Server error"
}

原因不明。以下URLによると手数料が少なすぎるせいかもしれない。仮にそうだとして、公式サイトなど信頼できる情報源からの情報を見つけられなかった。私がちゃんと読めてないのだろう。

おそらくmpchainなりcounterPartyなりが最低手数料を算出しており、それより低いときのリクエストを弾いているのだと思われる。その値がわかれば解決するかもしれないが、どこにその情報があるかわからない。

送金APIについては実際に所持金が減るので、諸々ちゃんと把握してから実行したい。APIテスト環境でもあれば触りながら理解を深められそうだが、そういったものも見つけられなかった。自分のマシンにフルノードをインストールし、そのコードを解析すれば理解できると踏んでいるが、壮大すぎて読み解けると思えない。MasteringBitcoinも読了していないので、今の私にはまだ早すぎたのかもしれない。

送金する方法

MasteringBitcoin日本語訳PDFを少し読んだことで、送金する仕組みを以下のように理解した。

  • 支払先アドレス、支払元アドレス、アセット(MONA)、支払額、手数料、メモを決める
  • 支払額+手数料以上の額になるようUTXO(未使用アウトプットトランザクションデータ)を取得する
  • トランザクションを作成する
    • UXTOをvin(入力)として設定する
    • 支払をvout(出力)として設定する
    • おつりをvout(出力)として設定する
  • トランザクションを送信する
    • 未処理トランザクション用プールに入る
    • マイナーがそれを見つける
    • 手数料が一定値ならマイニングする
    • ブロックチェーンに取り込まれる
    • 未承認から承認済みトランザクションになり支払確定する

予想

問題は、これをどのように実行するか。とりあえず実際に自分で使い、支払った実績のあるmpurseのソースコードを解析すれば、送金する方法がわかるはず。たぶんその中でmpchain, counterBlock, counterParty APIを実行することで送金を実現していると思われる。つまり本体はWebAPIのはず。

目標

送金するAPIはどれで、どのような引数や戻り値か。HTTPヘッダはどうすればいいか。などの情報を知りたい。最終的にJavaScriptのfetch APIでそれをリクエストし、mpurseなしでモナコインの送金を実現したい。

API 物色

mpurse sendAsset

mpurse sendAsset APIを使えば送金できる。Mpurseを使ったモナコイン送金ボタンを生成するツールを作ったとき、まさにこのAPIを使った。

たしかに送金はできるが、これはUIやトランザクション手数料の計算が固定されている。もっと自分好みにカスタマイズしたい。そこで送金処理だけを抽出したいと思ったのが今回の動機である。

mpurse sendRawTransaction

MasteringBitcoin日本語訳PDFにより、送金とはトランザクション情報の送信であると知った。なら、mpurse sendRawTransaction APIで送金できるのでは?

READMEにはAPI以外の情報がなかったので「sendRawTransaction」でググってみると以下がヒット。別のコイン(イーサリアム)の情報っぽい。

署名済みのトランザクションを送信します

ということらしい。

いまだに署名とやらをちゃんと理解できていないのだが、文脈から察するに、送信する前に署名が必要らしい。

そこでREADMEでAPIをさがしてみるとmpurse signRawTransactionがあった。

const signedTx = await window.mpurse.signRawTransaction(tx);
const txHash = await window.mpurse.sendRawTransaction(signedTx);

mpurse sendRawTransactionで返されたtxHashtrezor txのURLの末尾につければ、そのトランザクションの情報がゲットできる、のかな?

問題はmpurse signRawTransactionの引数txに何を渡せばいいのか。無情にもREADMEにはtx: stringとしか書いていない。ならその文字列に何を渡せばいいの? ググったページには以下のように書いてあった。

...
var rawTx = {
  nonce: '0x00',
  gasPrice: '0x09184e72a000', 
  gasLimit: '0x2710',
  to: '0x0000000000000000000000000000000000000000', 
  value: '0x00', 
  data: '0x7f7465737432000000000000000000000000000000000000000000000000000000600057'
}
 
var tx = new Tx(rawTx);
tx.sign(privateKey);
 
var serializedTx = tx.serialize();

web3.eth.sendRawTransaction('0x' + serializedTx.toString('hex'), function(err, hash) {
...

rawTxというオブジェクトをTxなるクラスに渡し、signメソッドにかけ、さらにserializeした結果を16進数化している、のかな?

何がなんだかさっぱり。少なくともこのコードをそのまま使うことはできなさそう。たぶんこのうちいくつかはmpurse signRawTransactionmpurse sendRawTransactionで実現できると思いたい。ただ、トランザクション情報を作成するとき、どんな情報をどのように与えればいいかさっぱりわからない。具体的にいうとmpurse signRawTransactionの引数txがわからない。

これならmpurse sendAssetのソースコードを読んだほうがいいかもしれない。

一応ほかにも以下のようなサイトはあった。別のコイン(イーサリアム)の情報だった。やはりgasPriceなどの情報を入れている。

驚くほど情報が少ない。ググり方が悪いのかな?

counterParty API

sendをキーワードに検索してみた。すると以下のようなコード例があった。

def do_send(source, destination, asset, quantity, fee, encoding):
    validateaddress = bitcoin_api('validateaddress', [source])
    assert validateaddress['ismine']
    pubkey = validateaddress['pubkey']
    unsigned_tx = counterparty_api('create_send', {'source': source, 'destination': destination, 'asset': asset, 'quantity': quantity, 'pubkey': pubkey, 'allow_unconfirmed_inputs': True})
    signed_tx = bitcoin_api('signrawtransaction', [unsigned_tx])['hex']
    tx_hash = bitcoin_api('sendrawtransaction', [signed_tx])
    return tx_hash
  1. create_send APIでsignrawtransaction APIの引数を作成する
  2. signrawtransaction APIでsendrawtransactionの引数を作成する

ということらしい。つまり、以下のような流れかな?

  1. create_send APIを実行する
  2. 1の戻り値をmpurse signRawTransactionの引数として渡して実行する
  3. 2の戻り値をmpurse sendRawTransactionの引数として渡して実行する
  4. 以上で送金完了
  5. 3の戻り値をtrezor txのURL末尾にして実行すると送金トランザクション情報を入手できる

最初のcreate_sendをみてみよう。

create_send(source, destination, asset, quantity)
引数 概要
source string The address that will be sending (must have the necessary quantity of the specified asset).
destination (string, array[string]) The address to receive the asset.
asset (string, array[string]) The asset or subasset to send.
quantity (integer, array[integer]) The quantities of the asset to send.
memo (string, optional) The Memo associated with this transaction.
memo_is_hex (boolean, optional) If this is true, interpret the memo as a hexadecimal value. Defaults to false.
use_enhanced_send (boolean, optional) If this is false, the construct a legacy transaction sending bitcoin dust. Defaults to true.

さらに以下も参照しろとのこと。

するとアホみたいに大量の引数らしきものが。

引数 概要
encoding (string) The encoding method to use, see transaction encodings for more info.
pubkey (string/list) The hexadecimal public key of the source address
allow_unconfirmed_inputs (boolean) Set to true to allow this transaction to utilize unconfirmed UTXOs as inputs. Defaults to false.
fee (integer) If you’d like to specify a custom miners’ fee, specify it here
fee_per_kb (integer) The fee per kilobyte of transaction data constant that the server uses when deciding on the dynamic fee to use
fee_provided (integer) If you would like to specify a maximum fee
custom_inputs (list) Use only these specific UTXOs as inputs for the transaction being created. If specified, this parameter is a list of
unspent_tx_hash (string) When compiling the UTXOs to use as inputs for the transaction being created, only consider unspent outputs from this specific transaction hash. Defaults to null to consider all UTXOs for the address. Do not use this parameter if you are specifying custom_inputs.
regular_dust_size (integer) Specify
multisig_dust_size (integer) Specify
dust_return_pubkey (string) The dust return pubkey is used in multi-sig data outputs
disable_utxo_locks (boolean) By default, UTXO’s utilized when creating a transaction are “locked” for a few seconds, to prevent a case where rapidly generating create_ calls reuse UTXOs due to their spent status not being updated in bitcoind yet. Specify true for this parameter to disable this behavior, and not temporarily lock UTXOs.
op_return_value (integer) The value
extended_tx_info (boolean) When this is not specified or false, the create_ calls return only a hex-encoded string. If this is true, the create_ calls return a data object with the following keys: tx_hex, btc_in, btc_out, btc_change, and btc_fee.
p2sh_pretx_txid (string) The previous transaction txid for a two part P2SH message. This txid must be taken from the signed transaction.

create_send APIにはそれらを受け取る引数がなさそうに見えるけど。どういうこと?

counterBlock APIのほうはsendでググってもヒットしなかった。サイトから得られる情報はこんなところが限界か。

コードリーディング

mpurseのコードからsendをキーワードに検索してみた。

src/app/components/send/send.component.ts

送金フォームの送金ボタンを押したとき、トランザクションデータを作成する処理と思われる。が、さっぱりわからない。

createSendのようなそれっぽいキーワードがあるので関係あるはず。

  createSendObservable(disableUtxoLocks: boolean): Observable<any> {
    return this.backgroundService.getAsset(this.assetControl.value).pipe(
      flatMap(assetInfo => {
        let amount: number;
        if (assetInfo['divisible']) {
          amount = new Decimal(this.amountControl.value)
            .times(new Decimal(100000000))
            .toNumber();
        } else {
          amount = new Decimal(this.amountControl.value).toNumber();
        }
        return this.backgroundService.createSend(
          this.fromControl.value,
          this.toControl.value,
          this.assetControl.value,
          amount,
          this.memoTypeControl.value === 'no'
            ? ''
            : this.memoValueControl.value,
          this.memoTypeControl.value === 'hex',
          new Decimal(this.feeControl.value)
            .times(new Decimal(1000))
            .toNumber(),
          disableUtxoLocks
        );
      })
    );
  }
  createSend(): void {
    this.unsignedTx = '';
    this.calculatedFee = 0;
    this.createSendObservable(true).subscribe({
      next: result => {
        this.zone.run(() => {
          this.unsignedTx = result.tx_hex;
          this.calculatedFee = result.btc_fee;
        });
      },
      error: error => {
        this.zone.run(() => {
          this.snackBar.open(this.backgroundService.interpretError(error), '', {
            duration: 3000
          });
        });
      }
    });
  }
  send(): void {
    if (this.request) {
      this.createSendObservable(false)
        .pipe(
          flatMap(result => this.backgroundService.send(result.tx_hex)),
          flatMap(result =>
            this.backgroundService.shiftRequest(true, this.id, {
              txHash: result.tx_hash
            })
          )
        )
        .subscribe({
          next: () => this.backgroundService.closeWindow(),
          error: error =>
            this.zone.run(() =>
              this.snackBar.open(error.toString(), '', { duration: 3000 })
            )
        });
    } else {
      this.createSendObservable(false)
        .pipe(flatMap(result => this.backgroundService.send(result.tx_hex)))
        .subscribe({
          next: result => {
            this.zone.run(() => {
              this.snackBar.open(
                this.translate.instant('send.sent') + result.tx_hash,
                '',
                { duration: 5000, panelClass: 'break-all' }
              );
              this.router.navigate(['/home']);
            });
          },
          error: error => {
            this.zone.run(() => {
              this.snackBar.open(
                this.backgroundService.interpretError(error),
                '',
                { duration: 3000 }
              );
            });
          }
        });
    }
  }

src/app/services/background.service.ts createSend

  createSend(
    source: string,
    destination: string,
    asset: string,
    quantity: number,
    memo: string,
    memoIsHex: boolean,
    feePerKb: number,
    disableUtxoLocks: boolean
  ): Observable<any> {
    return this.getBackground().pipe(
      flatMap(bg =>
        from<Observable<any>>(
          bg.createSend(
            source,
            destination,
            asset,
            quantity,
            memo,
            memoIsHex,
            feePerKb,
            disableUtxoLocks
          )
        )
      )
    );
  }

おや、この引数の感じ、どこかで見覚えが。counterPartycreate_sendがこんな感じだったような。

extension_scripts/background.ts createSend

  createSend(
    source: string,
    destination: string,
    asset: string,
    quantity: number,
    memo: string,
    memoIsHex: boolean,
    feePerKb: number,
    disableUtxoLocks: boolean
  ): Promise<any> {
    return MpchainUtil.createSend(
      source,
      destination,
      asset,
      quantity,
      memo,
      memoIsHex,
      feePerKb,
      disableUtxoLocks
    );
  }
}

mpchain APIで実行しているっぽい。でもドキュメントにはそれっぽいAPIがない。コードを追ってみよう。

extension_scripts/util.mpchain.ts createSend

  static createSend(
    source: string,
    destination: string,
    asset: string,
    quantity: number,
    memo: string,
    memoIsHex: boolean,
    feePerKb: number,
    disableUtxoLocks: boolean
  ): Promise<any> {
    const cpParams = {
      source: source,
      destination: destination,
      asset: asset,
      quantity: quantity,
      memo: memo,
      memo_is_hex: memoIsHex,
      fee_per_kb: feePerKb,
      allow_unconfirmed_inputs: true,
      extended_tx_info: true,
      disable_utxo_locks: disableUtxoLocks
    };
    return this.cp('create_send', cpParams);
  }

counterParty APIで実行しているっぽい。これが本体か。

extension_scripts/background.ts send

  async send(tx: string): Promise<any> {
    if (!this.isUnlocked) {
      this.resetPreferences();
      this.resetKeyring();
      Promise.reject('Not logged in');
    } else {
      const hex = await this.keyring.signTransaction(
        tx,
        this.preferences.selectedAddress
      );
      return MpchainUtil.sendTx(hex);
    }
  }

mpchain APIのsend_txを使っているっぽい。たぶん引数はcreateSendの戻り値だろう。

どうすればいいのか

counterPartycreate_send(mpursecreateSend)してからmpchainsend_tx(mpursesend)することで送金を実現しているような気がする。

まずはcreate_sendのほうから試してみよう。mpurse API経由で実行すると楽。以下のようにやってみた。

ytyaruのプロフィールのようなHTTPSページでデベロッパツールを開き、コンソールに以下コードを入力することで実行した。

const cpParams = {
    source: 'MEHCqJbgiNERCH3bRAtNSSD9uxPViEX1nu',
    destination: 'MEHCqJbgiNERCH3bRAtNSSD9uxPViEX1nu',
    asset: 'MONA',
    quantity: 0.00000000,
    memo: null,
    memo_is_hex: false,
    fee_per_kb: 0,
    allow_unconfirmed_inputs: true,
    extended_tx_info: true,
    disable_utxo_locks: true,
};
await window.mpurse.counterParty('create_send', cpParams);
//const unspentTxouts = await window.mpurse.counterParty('create_send', cpParams);

実行したら以下のように怒られた。

{
    "code": -32000,
    "data": {
        "args": [
            "{\"message\": \"Error composing send transaction via API: Destination output is dust.\", \"code\": -32001}"
        ],
        "message": "{\"message\": \"Error composing send transaction via API: Destination output is dust.\", \"code\": -32001}",
        "type": "Exception"
    },
    "message": "Server error"
}

以下を読むに、手数料が安すぎてゴミだよってことかもしれない。

なら、適切な手数料というのはどうしたら算出できるの? だれが決めているの?

たぶんこの場合はmpchaincounterPartyが決めているのだろう。せめて弾かれる最低額を知りたい。そもそも、通貨の単位もよくわからない。モナなのかサトシなのかワタナベなのか。

手数料には謎がたくさんあって未だにわからないことが多い。やはり送金の壁は高かった。

APIのテストができる環境とかがあれば触りながら理解を深められそうなのだが。そんな便利なものは見つけられなかった。

先の予想

たぶんこのcreate_sendでトランザクション情報を作成したあと、どうにかしてブロックチェーンに取り込んでくれるよう要求するのだろう。そのときフルノードなサーバのメモリ上にプールされ、未承認状態トランザクションになる。そしてマイナーがそのトランザクションをマイニングし終えた時点でブロックチェーンに取り込まれ、そこで承認済みトランザクションとして取引が確定するのだろう。

今回はトランザクションを作成する時点でエラーになったので、まだブロックチェーンに取り込むよう要求する前の段階だと思われる。仮にcreate_sendが終わっても、まだ先がある。無謀な挑戦だったか。