コンテンツにスキップ

マルチシグトランザクションフローをリッスンする⚓︎

上級

マルチシグアカウント からのトランザクションは、通常のトランザクションより複雑なライフサイクルをたどります。 アナウンスされた後、ネットワークがアカウントの連署人から必要な連署を集める間、未承認トランザクションプール で待機します。 すべての連署が届いた後で初めて、トランザクションは ブロック で承認されます。

このチュートリアルでは、マルチシグアカウントからトランザクションに署名する チュートリアルの送金を再現しますが、ポーリングの代わりに WebSocket チャネルを使って、マルチシグのライフサイクル全体を監視します。

このチュートリアルで使うマルチシグアカウントは 2-of-2 マルチシグとして設定されています。 連署人が 2 人いて、送金を承認するには両方の署名が必要です。

Multisignature TreeMultisignature AccountマルチシグアカウントCosignatory 0連署人 0Cosignatory 0->Multisignature AccountCosignatory 1連署人 1Cosignatory 1->Multisignature Account

連署人 0 がマルチシグトランザクションを構築してアナウンスし、連署人 1 がマルチシグアカウントの WebSocket チャネルをサブスクライブして連署し、承認を待ちます。

別の方法: ポーリング

連署人がノードを照会して承認待ちのトランザクションを見つけるポーリングの方法については、マルチシグアカウントからトランザクションに署名する チュートリアルを参照してください。

前提条件⚓︎

始める前に、次の準備をしてください。

  • 開発環境をセットアップする。 開発環境のセットアップ を参照してください。

  • 2-of-2 マルチシグアカウントを作成する。 作成するには、マルチシグを設定する チュートリアルで 1 から 2 に変更して実行します。 アカウントがすでに別の設定のマルチシグである場合は、まず 無効化 してください。

さらに、NEM は SockJS 上で STOMP メッセージングプロトコルを使って WebSocket を提供するため、STOMP クライアントと WebSocket トランスポートが必要です。

stomperwebsockets ライブラリをインストールします。

pip install stomper websockets

@stomp/stompjssockjs-client ライブラリをインストールします。

npm install @stomp/stompjs sockjs-client

接続プロトコルの詳細については、WebSocket リファレンス を参照してください。

完全なコード⚓︎

import asyncio
import json
import os
import random
import urllib.request
import uuid

import stomper
from symbolchain.CryptoTypes import PrivateKey, PublicKey
from symbolchain.facade.NemFacade import NemFacade
from symbolchain.nc import Amount
from symbolchain.nem.FeeCalculator import calculate_transaction_fee
from symbolchain.nem.Network import NetworkTimestamp
from websockets import connect

NODE_URL = os.getenv('NODE_URL', 'http://libertalia.nemtest.net:7890')
WS_URL = NODE_URL.replace(':7890', ':7778')
print(f'Using node {NODE_URL}')


# SockJS has no Python client library.
# These helpers wrap the raw WebSocket transport to mirror a STOMP client.
def sockjs_url(endpoint_url):
    # SockJS raw WebSocket transport adds a random server and session id
    server = random.randint(100, 999)
    session = uuid.uuid4().hex
    ws_base = endpoint_url.replace('http', 'ws', 1)
    return f'{ws_base}/{server}/{session}/websocket'


async def send_frame(websocket, frame):
    # SockJS wraps each client payload as a JSON array of frame strings
    await websocket.send(json.dumps([frame]))


async def stomp_connect(websocket):
    await websocket.recv()  # consume the SockJS open frame
    await send_frame(
        websocket, stomper.connect('', '', NODE_URL, heartbeats=(0, 0)))


async def stomp_subscribe(websocket, destination, sub_id):
    await send_frame(websocket, stomper.subscribe(destination, sub_id))


async def stomp_send(websocket, destination, body):
    await send_frame(websocket, stomper.send(destination, body))


async def stomp_unsubscribe(websocket, sub_id):
    await send_frame(websocket, stomper.unsubscribe(sub_id))


async def stomp_disconnect(websocket):
    await send_frame(websocket, stomper.disconnect())


def stomp_messages(raw_frame):
    # Yield each STOMP MESSAGE frame in a SockJS data frame
    if 'a' != raw_frame[0]:  # skip 'o' open, 'h' heartbeat, 'c' close
        return
    for payload in json.loads(raw_frame[1:]):
        frame = stomper.unpack_frame(payload)
        if 'MESSAGE' == frame['cmd']:
            yield frame


async def stomp_frames(websocket):
    # Yield each STOMP MESSAGE frame as it arrives
    async for raw_frame in websocket:
        for frame in stomp_messages(raw_frame):
            yield frame


facade = NemFacade('testnet')
# Set up the multisig and cosignatory accounts
MULTISIG_PUBLIC_KEY = os.getenv(
    'MULTISIG_PUBLIC_KEY',
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')
multisig_public_key = PublicKey(MULTISIG_PUBLIC_KEY)
multisig_address = str(facade.network.public_key_to_address(
    multisig_public_key))
print(f'Multisig address: {multisig_address}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory0_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory 0 public key: {cosignatory0_key_pair.public_key}')
COSIGNATORY1_PRIVATE_KEY = os.getenv(
    'COSIGNATORY1_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000003')
cosignatory1_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY1_PRIVATE_KEY))
print(f'Cosignatory 1 public key: {cosignatory1_key_pair.public_key}')



async def main():
    # [Cosignatory 0] Build and sign the multisig transaction
    with urllib.request.urlopen(
        f'{NODE_URL}/time-sync/network-time'
    ) as resp:
        network_time = json.loads(
            resp.read().decode())['receiveTimeStamp'] // 1000
    timestamp = NetworkTimestamp(network_time)
    deadline = timestamp.add_hours(2)

    transfer_transaction = facade.transaction_factory.create({
        'type': 'transfer_transaction_v2',
        'signer_public_key': multisig_public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'recipient_address': multisig_address,
        'amount': 1_000_000  # 1 XEM
    })
    transfer_transaction.fee = Amount(
        calculate_transaction_fee(transfer_transaction))

    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        'signer_public_key': cosignatory0_key_pair.public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                transfer_transaction)
    })
    transaction.fee = Amount(calculate_transaction_fee(transaction))

    signature = facade.sign_transaction(
        cosignatory0_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    transaction_hash = str(facade.hash_transaction(transaction)).upper()
    print('[Cosignatory 0] Built multisig transaction '
        f'{transaction_hash[:16]}...')

    # [Cosignatory 1] Connect to the WebSocket
    endpoint = f'{WS_URL}/w/messages'
    async with connect(sockjs_url(endpoint)) as websocket:
        await stomp_connect(websocket)
        print(f'[Cosignatory 1] Connected to {WS_URL}')
        frames = stomp_frames(websocket)

        # [Cosignatory 1] Subscribe to the multisig account channels
        channels = {
            f'/account/{multisig_address}': 'id-0',
            f'/unconfirmed/{multisig_address}': 'id-1',
            f'/transactions/{multisig_address}': 'id-2',
        }
        for channel, sub_id in channels.items():
            await stomp_subscribe(websocket, channel, sub_id)
            print(f'[Cosignatory 1] Subscribed to {channel} channel')

        # [Cosignatory 1] Register the multisig account
        await stomp_send(websocket, '/w/api/account/get',
            json.dumps({'account': multisig_address}))
        async for frame in frames:
            if '/account/' in frame['headers']['destination']:
                balance = json.loads(
                    frame['body'])['account']['balance']
                print(f'Account update: balance={balance}')
                break
        print('[Cosignatory 1] Multisig account registered')

        # [Cosignatory 0] Announce the multisig transaction
        print('[Cosignatory 0] Announcing multisig transaction '
            f'{transaction_hash[:16]}...')
        announce_request = urllib.request.Request(
            f'{NODE_URL}/transaction/announce',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='POST'
        )
        with urllib.request.urlopen(announce_request) as resp:
            result = json.loads(resp.read().decode())
        if 'SUCCESS' != result['message']:
            print(f'Transaction rejected: {result["message"]}')
            return
        # The transaction is now waiting for the second signature

        # [Cosignatory 1] Select the pending multisig transaction
        inner_transaction_hash = None
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/unconfirmed/' not in destination:
                continue
            signer = body['transaction'].get(
                'otherTrans', {}).get('signer', '')
            if signer.upper() != str(multisig_public_key):
                continue
            inner_transaction_hash = body['meta']['innerHash']['data']
            print('unconfirmed: innerHash='
                f'{inner_transaction_hash[:16]}...')

            # [Cosignatory 1] Cosign the pending transaction
            cosignature = facade.transaction_factory.create({
                'type': 'cosignature_v1',
                # This is the cosignatory providing the second signature
                'signer_public_key': cosignatory1_key_pair.public_key,
                'timestamp': timestamp.timestamp,
                'deadline': deadline.timestamp,
                # Hash of the inner transfer transaction
                'other_transaction_hash': inner_transaction_hash,
                # Address of the multisig account
                'multisig_account_address': multisig_address
            })
            cosignature.fee = Amount(
                calculate_transaction_fee(cosignature))
            cosignature_signature = facade.sign_transaction(
                cosignatory1_key_pair, cosignature)
            cosignature_payload = (
                facade.transaction_factory.attach_signature(
                    cosignature, cosignature_signature))
            cosignature_request = urllib.request.Request(
                f'{NODE_URL}/transaction/announce',
                data=cosignature_payload.encode(),
                headers={'Content-Type': 'application/json'},
                method='POST'
            )
            with urllib.request.urlopen(cosignature_request) as resp:
                cosignature_result = json.loads(resp.read().decode())
            if 'SUCCESS' != cosignature_result['message']:
                print('Cosignature rejected: '
                    f'{cosignature_result["message"]}')
                return
            print('[Cosignatory 1] Announced cosignature')
            break

        # [Cosignatory 1] Wait for confirmation
        confirmed = False
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/account/' in destination:
                balance = body['account']['balance']
                print(f'Account update: balance={balance}')
                if confirmed:
                    break
            elif '/transactions/' in destination:
                message_hash = body['meta']['innerHash']['data']
                print(f'confirmed: innerHash={message_hash[:16]}...')
                matched = message_hash == inner_transaction_hash
                if matched and not confirmed:
                    print('Multisig transaction confirmed')
                    confirmed = True

        # [Cosignatory 1] Unsubscribe before closing
        for sub_id in channels.values():
            await stomp_unsubscribe(websocket, sub_id)
        print('[Cosignatory 1] Unsubscribed from all channels')
        await stomp_disconnect(websocket)


try:
    asyncio.run(main())
except Exception as error:
    print(error)

Download source

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
import { PrivateKey, PublicKey } from 'symbol-sdk';
import {
    NemFacade, NetworkTimestamp, calculateTransactionFee, models
} from 'symbol-sdk/nem';

const NODE_URL = process.env.NODE_URL ||
    'http://libertalia.nemtest.net:7890';
const WS_URL = NODE_URL.replace(':7890', ':7778');
console.log(`Using node ${NODE_URL}`);

const facade = new NemFacade('testnet');
// Set up the multisig and cosignatory accounts
const MULTISIG_PUBLIC_KEY = process.env.MULTISIG_PUBLIC_KEY || (
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');
const multisigPublicKey = new PublicKey(MULTISIG_PUBLIC_KEY);
const multisigAddress = facade.network.publicKeyToAddress(
    multisigPublicKey).toString();
console.log(`Multisig address: ${multisigAddress}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000002');
const cosignatory0KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory 0 public key: ${cosignatory0KeyPair.publicKey}`);
const COSIGNATORY1_PRIVATE_KEY = process.env.COSIGNATORY1_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000003');
const cosignatory1KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY1_PRIVATE_KEY));
console.log(`Cosignatory 1 public key: ${cosignatory1KeyPair.publicKey}`);


try {
    // [Cosignatory 0] Build and sign the multisig transaction
    const timeResponse = await fetch(
        `${NODE_URL}/time-sync/network-time`);
    const networkTime = Math.floor(
        (await timeResponse.json()).receiveTimeStamp / 1000);
    const timestamp = new NetworkTimestamp(networkTime);
    const deadline = timestamp.addHours(2);

    const transferTransaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v2',
        signerPublicKey: multisigPublicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        recipientAddress: multisigAddress,
        amount: 1_000_000n // 1 XEM
    });
    transferTransaction.fee = new models.Amount(
        calculateTransactionFee(transferTransaction));

    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        signerPublicKey: cosignatory0KeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(transferTransaction)
    });
    transaction.fee = new models.Amount(
        calculateTransactionFee(transaction));

    const signature = facade.signTransaction(
        cosignatory0KeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash =
        facade.hashTransaction(transaction).toString().toUpperCase();
    const shortHash = transactionHash.substring(0, 16);
    console.log(
        `[Cosignatory 0] Built multisig transaction ${shortHash}...`);

    // [Cosignatory 1] Connect to the WebSocket
    const client = new Client({
        webSocketFactory: () => new SockJS(`${WS_URL}/w/messages`)
    });
    await new Promise(resolve => {
        client.onConnect = resolve;
        client.activate();
    });
    console.log(`[Cosignatory 1] Connected to ${WS_URL}`);

    // [Cosignatory 1] Select the pending multisig transaction
    let innerTransactionHash = null;
    let resolveCosigned;
    const cosigned = new Promise(resolve => {
        resolveCosigned = resolve;
    });
    const onUnconfirmed = async message => {
        if (null !== innerTransactionHash)
            return;
        const body = JSON.parse(message.body);
        const signer = (body.transaction.otherTrans?.signer ?? '')
            .toUpperCase();
        if (multisigPublicKey.toString() !== signer)
            return;
        innerTransactionHash = body.meta.innerHash.data;
        console.log(
            'unconfirmed: innerHash=' +
            `${innerTransactionHash.substring(0, 16)}...`);

        // [Cosignatory 1] Cosign the pending transaction
        const cosignature = facade.transactionFactory.create({
            type: 'cosignature_v1',
            // This is the cosignatory providing the second signature
            signerPublicKey: cosignatory1KeyPair.publicKey.toString(),
            timestamp: timestamp.timestamp,
            deadline: deadline.timestamp,
            // Hash of the inner transfer transaction
            otherTransactionHash: innerTransactionHash,
            // Address of the multisig account
            multisigAccountAddress: multisigAddress
        });
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        const cosignatureResponse = await fetch(
            `${NODE_URL}/transaction/announce`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: cosignaturePayload
            });
        const cosignatureResult = await cosignatureResponse.json();
        if ('SUCCESS' !== cosignatureResult.message) {
            console.log(
                `Cosignature rejected: ${cosignatureResult.message}`);
            resolveCosigned(false);
            return;
        }
        console.log('[Cosignatory 1] Announced cosignature');
        resolveCosigned(true);

    };
    // [Cosignatory 1] Wait for confirmation
    let confirmed = false;
    let resolveRegistered;
    let resolveDone;
    const registered = new Promise(resolve => {
        resolveRegistered = resolve;
    });
    const done = new Promise(resolve => {
        resolveDone = resolve;
    });
    const onConfirmed = message => {
        const messageHash = JSON.parse(message.body).meta.innerHash.data;
        console.log(
            `confirmed: innerHash=${messageHash.substring(0, 16)}...`);
        if (messageHash === innerTransactionHash && !confirmed) {
            console.log('Multisig transaction confirmed');
            confirmed = true;
        }
    };
    const onAccountUpdate = message => {
        const { balance } = JSON.parse(message.body).account;
        console.log(`Account update: balance=${balance}`);
        resolveRegistered();
        if (confirmed)
            resolveDone();
    };

    // [Cosignatory 1] Subscribe to the multisig account channels
    const subscriptions = [
        {
            channel: `/account/${multisigAddress}`,
            handler: onAccountUpdate,
            id: 'id-0'
        },
        {
            channel: `/unconfirmed/${multisigAddress}`,
            handler: onUnconfirmed,
            id: 'id-1'
        },
        {
            channel: `/transactions/${multisigAddress}`,
            handler: onConfirmed,
            id: 'id-2'
        }
    ];
    for (const { channel, handler, id } of subscriptions) {
        client.subscribe(channel, handler, { id });
        console.log(`[Cosignatory 1] Subscribed to ${channel} channel`);
    }

    // [Cosignatory 1] Register the multisig account
    client.publish({
        destination: '/w/api/account/get',
        body: JSON.stringify({ account: multisigAddress })
    });
    await registered;
    console.log('[Cosignatory 1] Multisig account registered');

    // [Cosignatory 0] Announce the multisig transaction
    console.log(
        '[Cosignatory 0] Announcing multisig transaction ' +
        `${shortHash}...`);
    const response = await fetch(`${NODE_URL}/transaction/announce`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    const announceResult = await response.json();
    if ('SUCCESS' === announceResult.message) {
        // The transaction is now waiting for the second signature

        // Wait for the cosignature to be announced and the
        // transaction to confirm
        if (await cosigned)
            await done;
    } else {
        console.log(`Transaction rejected: ${announceResult.message}`);
    }
    // [Cosignatory 1] Unsubscribe before closing
    for (const { id } of subscriptions)
        client.unsubscribe(id);
    console.log('[Cosignatory 1] Unsubscribed from all channels');
    client.deactivate();
} catch (error) {
    console.error(error);
}

Download source

スニペットでは、NODE_URL 環境変数を使って NEM ノード を指定します。 値が指定されていない場合は、デフォルト値を使用します。

WS_URL は同じノードの WebSocket エンドポイントを定義します。 NODE_URL のポート 7890(デフォルトの HTTP API ポート)を 7778(デフォルトの NIS WebSocket ポート)に置き換えて導出します。

Python の SockJS ヘルパー

Python 用の SockJS クライアントライブラリはないため、便宜上、ファイルの先頭に小さなヘルパーメソッドをいくつか定義しています。

コードの説明⚓︎

マルチシグトランザクションには 2 つの異なる役割があります。マルチシグトランザクションを構築、署名、アナウンスする 開始者(連署人 0)と、WebSocket チャネルを監視してトランザクションを確認した後に連署する 1 人以上の 連署人(このチュートリアルでは連署人 1)です。 マルチシグアカウントの連署人なら、どちらの役割も担当できます。

実際には、各役割を別々のマシン上の別々のプログラムで実行し、それぞれ自分の秘密鍵だけを保持します。 このチュートリアルでは簡単にするため、両方の役割を 1 つのスクリプトにまとめています。

アカウントをセットアップする⚓︎

# Set up the multisig and cosignatory accounts
MULTISIG_PUBLIC_KEY = os.getenv(
    'MULTISIG_PUBLIC_KEY',
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2')
multisig_public_key = PublicKey(MULTISIG_PUBLIC_KEY)
multisig_address = str(facade.network.public_key_to_address(
    multisig_public_key))
print(f'Multisig address: {multisig_address}')
COSIGNATORY0_PRIVATE_KEY = os.getenv(
    'COSIGNATORY0_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000002')
cosignatory0_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY0_PRIVATE_KEY))
print(f'Cosignatory 0 public key: {cosignatory0_key_pair.public_key}')
COSIGNATORY1_PRIVATE_KEY = os.getenv(
    'COSIGNATORY1_PRIVATE_KEY',
    '0000000000000000000000000000000000000000000000000000000000000003')
cosignatory1_key_pair = NemFacade.KeyPair(
    PrivateKey(COSIGNATORY1_PRIVATE_KEY))
print(f'Cosignatory 1 public key: {cosignatory1_key_pair.public_key}')
// Set up the multisig and cosignatory accounts
const MULTISIG_PUBLIC_KEY = process.env.MULTISIG_PUBLIC_KEY || (
    'D656155B48D4E71E4C59EC6FAEB5EB4F214DE8BC3C65D5BF6A3D9931B4E5ACF2');
const multisigPublicKey = new PublicKey(MULTISIG_PUBLIC_KEY);
const multisigAddress = facade.network.publicKeyToAddress(
    multisigPublicKey).toString();
console.log(`Multisig address: ${multisigAddress}`);
const COSIGNATORY0_PRIVATE_KEY = process.env.COSIGNATORY0_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000002');
const cosignatory0KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY0_PRIVATE_KEY));
console.log(`Cosignatory 0 public key: ${cosignatory0KeyPair.publicKey}`);
const COSIGNATORY1_PRIVATE_KEY = process.env.COSIGNATORY1_PRIVATE_KEY || (
    '0000000000000000000000000000000000000000000000000000000000000003');
const cosignatory1KeyPair = new NemFacade.KeyPair(
    new PrivateKey(COSIGNATORY1_PRIVATE_KEY));
console.log(`Cosignatory 1 public key: ${cosignatory1KeyPair.publicKey}`);

このチュートリアルでは、環境変数で設定する別々の 3 アカウントが必要です。 設定されていない場合は、デフォルト値を使用します。

環境変数 デフォルト値 用途
MULTISIG_PUBLIC_KEY D656..ACF2 2-of-2 マルチシグアカウント
COSIGNATORY0_PRIVATE_KEY 0000..0002 1 人目の連署人、開始者
COSIGNATORY1_PRIVATE_KEY 0000..0003 2 人目の連署人

各キーは 64 文字の 16 進数文字列です。

通常のアカウントとは異なり、マルチシグアカウントは自分でトランザクションを開始できません。 代わりに、連署人がアカウントに代わって署名します。 そのため、マルチシグアカウントの 秘密鍵 は必要なく、アカウントの識別には 公開鍵 だけで十分です。

マルチシグアカウントには、トランザクション手数料を支払うために十分な資金が必要です。 デフォルト値を使用する場合、このアカウントにはすでに資金がある可能性があります。

上記のスニペットでは、後で使用するために各連署人の キーペア とマルチシグアカウントの アドレス を導出して保存します。 後でサブスクライブする WebSocket チャネルは、このアドレスに対応付けられます。

開始者: マルチシグトランザクションを構築する⚓︎

    # [Cosignatory 0] Build and sign the multisig transaction
    with urllib.request.urlopen(
        f'{NODE_URL}/time-sync/network-time'
    ) as resp:
        network_time = json.loads(
            resp.read().decode())['receiveTimeStamp'] // 1000
    timestamp = NetworkTimestamp(network_time)
    deadline = timestamp.add_hours(2)

    transfer_transaction = facade.transaction_factory.create({
        'type': 'transfer_transaction_v2',
        'signer_public_key': multisig_public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'recipient_address': multisig_address,
        'amount': 1_000_000  # 1 XEM
    })
    transfer_transaction.fee = Amount(
        calculate_transaction_fee(transfer_transaction))

    transaction = facade.transaction_factory.create({
        'type': 'multisig_transaction_v1',
        'signer_public_key': cosignatory0_key_pair.public_key,
        'timestamp': timestamp.timestamp,
        'deadline': deadline.timestamp,
        'inner_transaction':
            facade.transaction_factory.to_non_verifiable_transaction(
                transfer_transaction)
    })
    transaction.fee = Amount(calculate_transaction_fee(transaction))

    signature = facade.sign_transaction(
        cosignatory0_key_pair, transaction)
    json_payload = facade.transaction_factory.attach_signature(
        transaction, signature)
    transaction_hash = str(facade.hash_transaction(transaction)).upper()
    print('[Cosignatory 0] Built multisig transaction '
        f'{transaction_hash[:16]}...')
    // [Cosignatory 0] Build and sign the multisig transaction
    const timeResponse = await fetch(
        `${NODE_URL}/time-sync/network-time`);
    const networkTime = Math.floor(
        (await timeResponse.json()).receiveTimeStamp / 1000);
    const timestamp = new NetworkTimestamp(networkTime);
    const deadline = timestamp.addHours(2);

    const transferTransaction = facade.transactionFactory.create({
        type: 'transfer_transaction_v2',
        signerPublicKey: multisigPublicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        recipientAddress: multisigAddress,
        amount: 1_000_000n // 1 XEM
    });
    transferTransaction.fee = new models.Amount(
        calculateTransactionFee(transferTransaction));

    const transaction = facade.transactionFactory.create({
        type: 'multisig_transaction_v1',
        signerPublicKey: cosignatory0KeyPair.publicKey.toString(),
        timestamp: timestamp.timestamp,
        deadline: deadline.timestamp,
        innerTransaction: facade.transactionFactory.static
            .toNonVerifiableTransaction(transferTransaction)
    });
    transaction.fee = new models.Amount(
        calculateTransactionFee(transaction));

    const signature = facade.signTransaction(
        cosignatory0KeyPair, transaction);
    const jsonPayload = facade.transactionFactory.static.attachSignature(
        transaction, signature);
    const transactionHash =
        facade.hashTransaction(transaction).toString().toUpperCase();
    const shortHash = transactionHash.substring(0, 16);
    console.log(
        `[Cosignatory 0] Built multisig transaction ${shortHash}...`);

連署人 0 はネットワーク時刻を取得し、マルチシグアカウントから自身へ 1 XEM を送る 内部トランザクション を構築し、MultisigTransactionV1 にラップして署名します。 実装は、マルチシグアカウントからトランザクションに署名する チュートリアルで説明したパターンに従います。

トランザクションは準備されますが、まだ アナウンス されません。 チャネルのサブスクリプションを確立した後でアナウンスするため、結果の通知を取り逃しません。

連署人: WebSocket に接続する⚓︎

    # [Cosignatory 1] Connect to the WebSocket
    endpoint = f'{WS_URL}/w/messages'
    async with connect(sockjs_url(endpoint)) as websocket:
        await stomp_connect(websocket)
        print(f'[Cosignatory 1] Connected to {WS_URL}')
        frames = stomp_frames(websocket)
    // [Cosignatory 1] Connect to the WebSocket
    const client = new Client({
        webSocketFactory: () => new SockJS(`${WS_URL}/w/messages`)
    });
    await new Promise(resolve => {
        client.onConnect = resolve;
        client.activate();
    });
    console.log(`[Cosignatory 1] Connected to ${WS_URL}`);

連署人 1 は WS_URL/w/messages エンドポイントへ SockJS 接続を開き、その上で STOMP セッション を開始します。

連署人: チャネルをサブスクライブする⚓︎

        # [Cosignatory 1] Subscribe to the multisig account channels
        channels = {
            f'/account/{multisig_address}': 'id-0',
            f'/unconfirmed/{multisig_address}': 'id-1',
            f'/transactions/{multisig_address}': 'id-2',
        }
        for channel, sub_id in channels.items():
            await stomp_subscribe(websocket, channel, sub_id)
            print(f'[Cosignatory 1] Subscribed to {channel} channel')
    // [Cosignatory 1] Subscribe to the multisig account channels
    const subscriptions = [
        {
            channel: `/account/${multisigAddress}`,
            handler: onAccountUpdate,
            id: 'id-0'
        },
        {
            channel: `/unconfirmed/${multisigAddress}`,
            handler: onUnconfirmed,
            id: 'id-1'
        },
        {
            channel: `/transactions/${multisigAddress}`,
            handler: onConfirmed,
            id: 'id-2'
        }
    ];
    for (const { channel, handler, id } of subscriptions) {
        client.subscribe(channel, handler, { id });
        console.log(`[Cosignatory 1] Subscribed to ${channel} channel`);
    }

連署人 1 は、トランザクションフローをリッスンする チュートリアルで使った、アドレスに対応付けられた同じ 3 チャネルをサブスクライブします。

サブスクリプションには id-0id-1id-2 を使います。プログラムが最後にサブスクライブを解除するときに、それぞれを識別します。

マルチシグではない場合と異なり、チャネルは 連署人アカウント ではなく マルチシグアカウント の活動を監視します。

ノードが通知するのは、開始した連署人と内部トランザクションに関係するアカウントだけだからです。この例では連署人 0 とマルチシグアカウントです。 連署人 1 のように承認を待つ連署人は、自分のアドレスでは通知を受け取らないため、代わりにマルチシグアカウントのアドレスをサブスクライブする必要があります。

メッセージ処理の違い

JavaScript では、各チャネルを専用のハンドラー関数でサブスクライブします。関数は下の 連署承認 の手順で定義します。 Python では、接続から到着したメッセージを順番に読み取ります。

3 つのチャネルは、次の手順でアドレスを登録するまで何も送信しません。

連署人: マルチシグアカウントを登録する⚓︎

        # [Cosignatory 1] Register the multisig account
        await stomp_send(websocket, '/w/api/account/get',
            json.dumps({'account': multisig_address}))
        async for frame in frames:
            if '/account/' in frame['headers']['destination']:
                balance = json.loads(
                    frame['body'])['account']['balance']
                print(f'Account update: balance={balance}')
                break
        print('[Cosignatory 1] Multisig account registered')
    // [Cosignatory 1] Register the multisig account
    client.publish({
        destination: '/w/api/account/get',
        body: JSON.stringify({ account: multisigAddress })
    });
    await registered;
    console.log('[Cosignatory 1] Multisig account registered');

アカウントのチャネルから通知を受け取るには、まずアドレスをノードに 登録 する必要があります。

コードは w/api/account/get REQ にリクエストを送信します。マルチシグアドレスを登録するとともに、account/{address} WS チャネルでアカウントの現在の状態を送信するようノードに要求します。

コードは最初のアカウント通知を待ち、登録が有効になったことを確認します。 通知は AccountMetaDataPair スキーマに従います。

開始者: マルチシグトランザクションをアナウンスする⚓︎

        # [Cosignatory 0] Announce the multisig transaction
        print('[Cosignatory 0] Announcing multisig transaction '
            f'{transaction_hash[:16]}...')
        announce_request = urllib.request.Request(
            f'{NODE_URL}/transaction/announce',
            data=json_payload.encode(),
            headers={'Content-Type': 'application/json'},
            method='POST'
        )
        with urllib.request.urlopen(announce_request) as resp:
            result = json.loads(resp.read().decode())
        if 'SUCCESS' != result['message']:
            print(f'Transaction rejected: {result["message"]}')
            return
        # The transaction is now waiting for the second signature
    // [Cosignatory 0] Announce the multisig transaction
    console.log(
        '[Cosignatory 0] Announcing multisig transaction ' +
        `${shortHash}...`);
    const response = await fetch(`${NODE_URL}/transaction/announce`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: jsonPayload
    });
    const announceResult = await response.json();
    if ('SUCCESS' === announceResult.message) {
        // The transaction is now waiting for the second signature

チャネルをサブスクライブしてからアナウンスしてください

リスナーの準備ができていることを確実にするため、トランザクションは必ず WebSocket チャネルをサブスクライブしたにアナウンスしてください。 そうしないと、WebSocket がリッスンする前に通知が届く可能性があります。

例えばアナウンス後にサブスクライブしたため通知を取り逃した連署人でも、/account/unconfirmedTransactions GET をポーリングして承認待ちのトランザクションを見つけることはできます。

連署人 1 がサブスクライブしたら、連署人 0 は /transaction/announce POST エンドポイントへマルチシグトランザクションをアナウンスし、結果を確認します。 ノードが拒否した場合は、拒否理由を表示して停止します。

有効であればネットワークはトランザクションを受け付けますが、まだ承認されていません。 マルチシグアカウントには 2 つの連署が必要なのに 1 つしか提供されていないため、不足している連署が到着するまでトランザクションは 未承認トランザクションプール で待機します。

連署人: 承認待ちのトランザクションに連署する⚓︎

        # [Cosignatory 1] Select the pending multisig transaction
        inner_transaction_hash = None
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/unconfirmed/' not in destination:
                continue
            signer = body['transaction'].get(
                'otherTrans', {}).get('signer', '')
            if signer.upper() != str(multisig_public_key):
                continue
            inner_transaction_hash = body['meta']['innerHash']['data']
            print('unconfirmed: innerHash='
                f'{inner_transaction_hash[:16]}...')
    // [Cosignatory 1] Select the pending multisig transaction
    let innerTransactionHash = null;
    let resolveCosigned;
    const cosigned = new Promise(resolve => {
        resolveCosigned = resolve;
    });
    const onUnconfirmed = async message => {
        if (null !== innerTransactionHash)
            return;
        const body = JSON.parse(message.body);
        const signer = (body.transaction.otherTrans?.signer ?? '')
            .toUpperCase();
        if (multisigPublicKey.toString() !== signer)
            return;
        innerTransactionHash = body.meta.innerHash.data;
        console.log(
            'unconfirmed: innerHash=' +
            `${innerTransactionHash.substring(0, 16)}...`);

承認待ちのマルチシグトランザクションは、TransactionMetaDataPair として unconfirmed/{address} WS チャネルに届きます。 マルチシグトランザクションでは、meta フィールドに追加の innerHash フィールドが含まれ、内部トランザクション のハッシュ、つまり連署が参照する値を保持します。

連署人は、承認待ちのマルチシグトランザクションを複数持つ可能性があります。 この例では、マルチシグアカウントが発行したトランザクションを選択します。 そのアカウントからの承認待ちトランザクションは 1 件だけであると想定するため、チュートリアルにはこれで十分です。

ただし、実際のアプリケーションでは、このフィルターだけでは不十分です。 承認待ちのトランザクションが期待したものだと保証するものはないため、連署するものを選ぶ前に、タイプ、受取人、金額など、承認待ちの各トランザクションの内容を確認してください。

連署する前に確認してください

連署する前に、必ずトランザクションの内容を確認してください。 連署は拘束力があり、取り消すことはできません。 完全なマルチシグトランザクションは通知の transaction フィールドで確認できます。

            # [Cosignatory 1] Cosign the pending transaction
            cosignature = facade.transaction_factory.create({
                'type': 'cosignature_v1',
                # This is the cosignatory providing the second signature
                'signer_public_key': cosignatory1_key_pair.public_key,
                'timestamp': timestamp.timestamp,
                'deadline': deadline.timestamp,
                # Hash of the inner transfer transaction
                'other_transaction_hash': inner_transaction_hash,
                # Address of the multisig account
                'multisig_account_address': multisig_address
            })
            cosignature.fee = Amount(
                calculate_transaction_fee(cosignature))
            cosignature_signature = facade.sign_transaction(
                cosignatory1_key_pair, cosignature)
            cosignature_payload = (
                facade.transaction_factory.attach_signature(
                    cosignature, cosignature_signature))
            cosignature_request = urllib.request.Request(
                f'{NODE_URL}/transaction/announce',
                data=cosignature_payload.encode(),
                headers={'Content-Type': 'application/json'},
                method='POST'
            )
            with urllib.request.urlopen(cosignature_request) as resp:
                cosignature_result = json.loads(resp.read().decode())
            if 'SUCCESS' != cosignature_result['message']:
                print('Cosignature rejected: '
                    f'{cosignature_result["message"]}')
                return
            print('[Cosignatory 1] Announced cosignature')
            break
        // [Cosignatory 1] Cosign the pending transaction
        const cosignature = facade.transactionFactory.create({
            type: 'cosignature_v1',
            // This is the cosignatory providing the second signature
            signerPublicKey: cosignatory1KeyPair.publicKey.toString(),
            timestamp: timestamp.timestamp,
            deadline: deadline.timestamp,
            // Hash of the inner transfer transaction
            otherTransactionHash: innerTransactionHash,
            // Address of the multisig account
            multisigAccountAddress: multisigAddress
        });
        cosignature.fee = new models.Amount(
            calculateTransactionFee(cosignature));
        const cosignatureSignature = facade.signTransaction(
            cosignatory1KeyPair, cosignature);
        const cosignaturePayload = facade.transactionFactory.static
            .attachSignature(cosignature, cosignatureSignature);
        const cosignatureResponse = await fetch(
            `${NODE_URL}/transaction/announce`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: cosignaturePayload
            });
        const cosignatureResult = await cosignatureResponse.json();
        if ('SUCCESS' !== cosignatureResult.message) {
            console.log(
                `Cosignature rejected: ${cosignatureResult.message}`);
            resolveCosigned(false);
            return;
        }
        console.log('[Cosignatory 1] Announced cosignature');
        resolveCosigned(true);

コードは次に、内部トランザクションのハッシュとマルチシグアカウントのアドレスを参照する CosignatureV1 を構築し、連署人 1 のキーで署名して、/transaction/announce POST エンドポイントを使ってアナウンスします。

連署人: 承認を待つ⚓︎

        # [Cosignatory 1] Wait for confirmation
        confirmed = False
        async for frame in frames:
            destination = frame['headers']['destination']
            body = json.loads(frame['body'])
            if '/account/' in destination:
                balance = body['account']['balance']
                print(f'Account update: balance={balance}')
                if confirmed:
                    break
            elif '/transactions/' in destination:
                message_hash = body['meta']['innerHash']['data']
                print(f'confirmed: innerHash={message_hash[:16]}...')
                matched = message_hash == inner_transaction_hash
                if matched and not confirmed:
                    print('Multisig transaction confirmed')
                    confirmed = True
    // [Cosignatory 1] Wait for confirmation
    let confirmed = false;
    let resolveRegistered;
    let resolveDone;
    const registered = new Promise(resolve => {
        resolveRegistered = resolve;
    });
    const done = new Promise(resolve => {
        resolveDone = resolve;
    });
    const onConfirmed = message => {
        const messageHash = JSON.parse(message.body).meta.innerHash.data;
        console.log(
            `confirmed: innerHash=${messageHash.substring(0, 16)}...`);
        if (messageHash === innerTransactionHash && !confirmed) {
            console.log('Multisig transaction confirmed');
            confirmed = true;
        }
    };
    const onAccountUpdate = message => {
        const { balance } = JSON.parse(message.body).account;
        console.log(`Account update: balance=${balance}`);
        resolveRegistered();
        if (confirmed)
            resolveDone();
    };

アナウンスした連署は独立したトランザクションとして 未承認トランザクションプール に表示されないため、独自の通知は発生しません。 代わりにネットワークが承認待ちのマルチシグトランザクションに付加し、unconfirmed/{address} WS チャネルで新しい通知が発生します。 この更新は連署が追加されたことだけを示すため、コードは無視します。

マルチシグトランザクションに追加の連署が必要なら、必要な連署がすべて集まるまで未承認プールに残ります。 このチュートリアルでは 2 つ目の連署で要件を満たすため、トランザクションは未承認プールを離れ、有効であれば次のブロックで承認されます。

承認は transactions/{address} WS チャネルに届きます。 内部送金の送信者と受取人がどちらもマルチシグアカウントなので、この通知は役割ごとに 1 回、合計 2 回配信されます。 コードは両方の通知を表示しますが、承認は 1 回だけ報告します。

トランザクションを含むブロックは、アカウントの更新後の状態を持つ account/{address} WS チャネルの最後の通知も発生させます。 この最後の通知が届くと、プログラムは後片付けの手順へ進みます。

連署人: チャネルのサブスクライブを解除する⚓︎

        # [Cosignatory 1] Unsubscribe before closing
        for sub_id in channels.values():
            await stomp_unsubscribe(websocket, sub_id)
        print('[Cosignatory 1] Unsubscribed from all channels')
        await stomp_disconnect(websocket)
    // [Cosignatory 1] Unsubscribe before closing
    for (const { id } of subscriptions)
        client.unsubscribe(id);
    console.log('[Cosignatory 1] Unsubscribed from all channels');
    client.deactivate();

承認後、連署人 1 は 3 つのチャネルのサブスクライブを解除し、接続を閉じる前に STOMP セッションを終了します。

出力⚓︎

Using node http://libertalia.nemtest.net:7890
Multisig address: TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6
Cosignatory 0 public key: AC1FC0D95CA3255D20C57C179EE6E694A47A725C48DB362CC4978D7745C6A5C3
Cosignatory 1 public key: 26D999AD34795F20D33886047A8CB7DE1ED0042AB7ED1017C602222C8B2A4C23
[Cosignatory 0] Built multisig transaction 844BBBB420167B0D...
[Cosignatory 1] Connected to http://libertalia.nemtest.net:7778
[Cosignatory 1] Subscribed to /account/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
[Cosignatory 1] Subscribed to /unconfirmed/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
[Cosignatory 1] Subscribed to /transactions/TBLXIOUO4EP5YR74HYXS3BFBGONZBUHP3NIS2HJ6 channel
Account update: balance=9959750000
[Cosignatory 1] Multisig account registered
[Cosignatory 0] Announcing multisig transaction 844BBBB420167B0D...
unconfirmed: innerHash=3e1fba4d39d9f053...
[Cosignatory 1] Announced cosignature
confirmed: innerHash=3e1fba4d39d9f053...
Multisig transaction confirmed
confirmed: innerHash=3e1fba4d39d9f053...
Account update: balance=9959400000
[Cosignatory 1] Unsubscribed from all channels

出力には次の内容が表示されます。

  • アカウント(2~4 行目): マルチシグアカウントのアドレスと、両方の連署人の公開鍵。
  • 構築(5 行目): 連署人 0 がマルチシグトランザクションを構築して署名します。
  • 接続(6 行目): ノードのポート 7778 の WebSocket エンドポイント上で STOMP セッションが確立されます。
  • サブスクリプション(7~9 行目): マルチシグアカウントのアドレスに対応付けられた 3 チャネルをサブスクライブします。
  • 登録(11 行目): マルチシグアカウントの現在の状態がアカウントチャネルに届き、登録を確認します。
  • アナウンス(12 行目): 連署人 0 がマルチシグトランザクションをアナウンスします。
  • 連署(13~14 行目): 内部トランザクションハッシュを含む承認待ちのマルチシグトランザクションが未承認チャネルに届き、連署人 1 が連署をアナウンスします。
  • 承認(15~17 行目): 完了したトランザクションがブロックで承認されます。内部送金の送信者と受取人がどちらもマルチシグアカウントなので、通知は 2 回届きます。
  • アカウント更新(18 行目): トランザクションを含むブロックが最後のアカウント通知を発生させます。送信した 1 XEM が送信者へ戻るため、残高は 手数料 の 0.35 XEM だけ減ります。
  • サブスクライブ解除(19 行目): コードが 3 つのチャネルのサブスクライブを解除します。

まとめ⚓︎

このチュートリアルでは、次の方法を説明しました。

手順 関連ドキュメント
マルチシグアカウントのチャネルをサブスクライブする account/{address} WS
unconfirmed/{address} WS
transactions/{address} WS
マルチシグアカウントを登録する w/api/account/get REQ
承認待ちのマルチシグメッセージを処理する TransactionMetaDataPair
未承認通知に対して連署する CosignatureV1