However, in this case, the source account is a multisignature account, also called multisig,
and therefore it cannot initiate or sign transactions on its own.
Instead, it relies on one of its cosignatory accounts to create transactions and sign them on its behalf.
This tutorial uses the multisig configuration created in the
Configuring a Multisignature Account tutorial,
with Cosignatory 0 initiating and signing the transaction:
importjsonimportosimporttimeimporturllib.requestfromsymbolchain.CryptoTypesimportPrivateKeyfromsymbolchain.facade.SymbolFacadeimportSymbolFacadefromsymbolchain.scimportAmountfromsymbolchain.symbol.NetworkimportNetworkTimestampfromsymbolchain.symbol.IdGeneratorimportgenerate_mosaic_alias_idNODE_URL='https://reference.symboltest.net:3001'print(f'Using node {NODE_URL}')MULTISIG_PRIVATE_KEY=os.getenv('MULTISIG_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000001')multisig_key_pair=SymbolFacade.KeyPair(PrivateKey(MULTISIG_PRIVATE_KEY))print(f'Multisig public key: {multisig_key_pair.public_key}')COSIGNATORY0_PRIVATE_KEY=os.getenv('COSIGNATORY0_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000002')cosignatory_key_pair=SymbolFacade.KeyPair(PrivateKey(COSIGNATORY0_PRIVATE_KEY))print(f'Cosignatory public key: {cosignatory_key_pair.public_key}')facade=SymbolFacade('testnet')try:# Fetch current network timetime_path='/node/time'print(f'Fetching current network time from {time_path}')withurllib.request.urlopen(f'{NODE_URL}{time_path}')asresponse:response_json=json.loads(response.read().decode())timestamp=NetworkTimestamp(int(response_json['communicationTimestamps']['receiveTimestamp']))print(f' Network time: {timestamp.timestamp} ms since nemesis')# Fetch recommended feesfee_path='/network/fees/transaction'print(f'Fetching recommended fees from {fee_path}')withurllib.request.urlopen(f'{NODE_URL}{fee_path}')asresponse:response_json=json.loads(response.read().decode())median_mult=response_json['medianFeeMultiplier']minimum_mult=response_json['minFeeMultiplier']fee_mult=max(median_mult,minimum_mult)print(f' Fee multiplier: {fee_mult}')# Build the embedded transfer transactiontransfer_transaction=facade.transaction_factory.create_embedded({'type':'transfer_transaction_v1','signer_public_key':multisig_key_pair.public_key,'recipient_address':facade.network.public_key_to_address(multisig_key_pair.public_key),'mosaics':[{'mosaic_id':generate_mosaic_alias_id('symbol.xym'),'amount':1_000_000# 1 XYM}]})# Build the wrapper aggregate transactiontransaction=facade.transaction_factory.create({'type':'aggregate_complete_transaction_v3',# This is the account that will pay for the transaction'signer_public_key':cosignatory_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions([transfer_transaction]),'transactions':[transfer_transaction]})transaction.fee=Amount(fee_mult*transaction.size)# Sign the aggregate transaction using the cosignatory's signaturejson_payload=facade.transaction_factory.attach_signature(transaction,facade.sign_transaction(cosignatory_key_pair,transaction))print('Built transaction:')print(json.dumps(transaction.to_json(),indent=2))# Announce the transactionannounce_path='/transactions'print(f'Announcing transaction to {announce_path}')announce_request=urllib.request.Request(f'{NODE_URL}{announce_path}',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='PUT')withurllib.request.urlopen(announce_request)asresponse:print(f' Response: {response.read().decode()}')# Wait for confirmationstatus_path=(f'/transactionStatus/{facade.hash_transaction(transaction)}')print(f'Waiting for confirmation from {status_path}')forattemptinrange(60):time.sleep(1)try:withurllib.request.urlopen(f'{NODE_URL}{status_path}')asresponse:status=json.loads(response.read().decode())print(f' Transaction status: {status['group']}')ifstatus['group']=='confirmed':print(f'Transaction confirmed in {attempt} seconds')breakifstatus['group']=='failed':print(f'Transaction failed: {status['code']}')breakexcepturllib.error.HTTPErrorase:print(f' Transaction status: unknown | Cause: ({e.msg})')else:print('Confirmation took too long.')excepturllib.error.URLErrorase:print(e.reason)
import{PrivateKey}from'symbol-sdk';import{SymbolFacade,NetworkTimestamp,models,generateMosaicAliasId}from'symbol-sdk/symbol';constNODE_URL='https://reference.symboltest.net:3001';console.log('Using node',NODE_URL);constMULTISIG_PRIVATE_KEY=process.env.MULTISIG_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000001');constmultisigKeyPair=newSymbolFacade.KeyPair(newPrivateKey(MULTISIG_PRIVATE_KEY));console.log(`Multisig public key: ${multisigKeyPair.publicKey}`);constCOSIGNATORY0_PRIVATE_KEY=process.env.COSIGNATORY0_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000002');constcosignatoryKeyPair=newSymbolFacade.KeyPair(newPrivateKey(COSIGNATORY0_PRIVATE_KEY));console.log(`Cosignatory public key: ${cosignatoryKeyPair.publicKey}`);constfacade=newSymbolFacade('testnet');try{// Fetch current network timeconsttimePath='/node/time';console.log('Fetching current network time from',timePath);consttimeResponse=awaitfetch(`${NODE_URL}${timePath}`);consttimeJSON=awaittimeResponse.json();consttimestamp=newNetworkTimestamp(timeJSON.communicationTimestamps.receiveTimestamp);console.log(' Network time:',timestamp.timestamp,'ms since nemesis');// Fetch recommended feesconstfeePath='/network/fees/transaction';console.log('Fetching recommended fees from',feePath);constfeeResponse=awaitfetch(`${NODE_URL}${feePath}`);constfeeJSON=awaitfeeResponse.json();constmedianMult=feeJSON.medianFeeMultiplier;constminimumMult=feeJSON.minFeeMultiplier;constfeeMult=Math.max(medianMult,minimumMult);console.log(' Fee multiplier:',feeMult);// Build the embedded transfer transactionconsttransferTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:multisigKeyPair.publicKey.toString(),recipientAddress:facade.network.publicKeyToAddress(multisigKeyPair.publicKey).toString(),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:1_000_000n// 1 XYM}]});// Build the wrapper aggregate transactionconsttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',// This is the account that will pay for the transactionsignerPublicKey:cosignatoryKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions([transferTransaction]),transactions:[transferTransaction]});transaction.fee=newmodels.Amount(feeMult*transaction.size);// Sign the aggregate transaction using the cosignatory's signatureconstjsonPayload=facade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(cosignatoryKeyPair,transaction));console.log('Built transaction:');console.dir(transaction.toJson(),{colors:true});// Announce the transactionconstannouncePath='/transactions';console.log('Announcing transaction to',announcePath);constannounceResponse=awaitfetch(`${NODE_URL}${announcePath}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:jsonPayload});console.log(' Response:',awaitannounceResponse.text());// Wait for confirmationconsttransactionHash=facade.hashTransaction(transaction).toString();conststatusPath=`/transactionStatus/${transactionHash}`;console.log('Waiting for confirmation from',statusPath);letattempt=0;functionpollStatus(){attempt++;if(attempt>60){console.warn('Confirmation took too long.');return;}returnfetch(`${NODE_URL}${statusPath}`).then(response=>{if(!response.ok){console.log(' Transaction status: unknown | Cause:',response.statusText);// HTTP error: schedule a retryreturnnewPromise(resolve=>setTimeout(resolve,1000)).then(pollStatus);}returnresponse.json();}).then(status=>{// Skip if previous step scheduled a retryif(!status)return;console.log(' Transaction status:',status.group);if(status.group==='confirmed'){console.log('Transaction confirmed in',attempt,'seconds');}elseif(status.group==='failed'){console.log('Transaction failed:',status.code);}else{// Transaction unconfirmed: schedule a retryreturnnewPromise(resolve=>setTimeout(resolve,1000)).then(pollStatus);}});}pollStatus();}catch(e){console.error(e.message,'| Cause:',e.cause?.code??'unknown');}
In general, signing a transaction on behalf of a multisig account only requires wrapping it in an
aggregate transaction that provides the required cosignatures.
This tutorial builds an embedded transaction containing the transfer, using the multisig account as the signer,
since this is the origin of the transfer.
A complete aggregate transaction then wraps the transfer transaction, signed by the cosignatory,
since this is the account that can authorize the transaction.
MULTISIG_PRIVATE_KEY=os.getenv('MULTISIG_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000001')multisig_key_pair=SymbolFacade.KeyPair(PrivateKey(MULTISIG_PRIVATE_KEY))print(f'Multisig public key: {multisig_key_pair.public_key}')COSIGNATORY0_PRIVATE_KEY=os.getenv('COSIGNATORY0_PRIVATE_KEY','0000000000000000000000000000000000000000000000000000000000000002')cosignatory_key_pair=SymbolFacade.KeyPair(PrivateKey(COSIGNATORY0_PRIVATE_KEY))print(f'Cosignatory public key: {cosignatory_key_pair.public_key}')
constMULTISIG_PRIVATE_KEY=process.env.MULTISIG_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000001');constmultisigKeyPair=newSymbolFacade.KeyPair(newPrivateKey(MULTISIG_PRIVATE_KEY));console.log(`Multisig public key: ${multisigKeyPair.publicKey}`);constCOSIGNATORY0_PRIVATE_KEY=process.env.COSIGNATORY0_PRIVATE_KEY||('0000000000000000000000000000000000000000000000000000000000000002');constcosignatoryKeyPair=newSymbolFacade.KeyPair(newPrivateKey(COSIGNATORY0_PRIVATE_KEY));console.log(`Cosignatory public key: ${cosignatoryKeyPair.publicKey}`);
The tutorial requires two separate accounts.
Their private keys can be provided through environment variables.
If not set, default values are used:
Environment Variable
Default value
Purpose
MULTISIG_PRIVATE_KEY
0000..0001
Multisig account
COSIGNATORY0_PRIVATE_KEY
0000..0002
Cosignatory account
Each private key is a 64-character hexadecimal string.
The cosignatory account must hold enough funds to pay the transaction fee.
If the default values are used, these accounts may already be funded.
The snippet above derives and stores the key pair of each account for later use.
# Fetch current network timetime_path='/node/time'print(f'Fetching current network time from {time_path}')withurllib.request.urlopen(f'{NODE_URL}{time_path}')asresponse:response_json=json.loads(response.read().decode())timestamp=NetworkTimestamp(int(response_json['communicationTimestamps']['receiveTimestamp']))print(f' Network time: {timestamp.timestamp} ms since nemesis')# Fetch recommended feesfee_path='/network/fees/transaction'print(f'Fetching recommended fees from {fee_path}')withurllib.request.urlopen(f'{NODE_URL}{fee_path}')asresponse:response_json=json.loads(response.read().decode())median_mult=response_json['medianFeeMultiplier']minimum_mult=response_json['minFeeMultiplier']fee_mult=max(median_mult,minimum_mult)print(f' Fee multiplier: {fee_mult}')
# Build the embedded transfer transactiontransfer_transaction=facade.transaction_factory.create_embedded({'type':'transfer_transaction_v1','signer_public_key':multisig_key_pair.public_key,'recipient_address':facade.network.public_key_to_address(multisig_key_pair.public_key),'mosaics':[{'mosaic_id':generate_mosaic_alias_id('symbol.xym'),'amount':1_000_000# 1 XYM}]})
// Build the embedded transfer transactionconsttransferTransaction=facade.transactionFactory.createEmbedded({type:'transfer_transaction_v1',signerPublicKey:multisigKeyPair.publicKey.toString(),recipientAddress:facade.network.publicKeyToAddress(multisigKeyPair.publicKey).toString(),mosaics:[{mosaicId:generateMosaicAliasId('symbol.xym'),amount:1_000_000n// 1 XYM}]});
# Build the wrapper aggregate transactiontransaction=facade.transaction_factory.create({'type':'aggregate_complete_transaction_v3',# This is the account that will pay for the transaction'signer_public_key':cosignatory_key_pair.public_key,'deadline':timestamp.add_hours(2).timestamp,'transactions_hash':facade.hash_embedded_transactions([transfer_transaction]),'transactions':[transfer_transaction]})transaction.fee=Amount(fee_mult*transaction.size)
// Build the wrapper aggregate transactionconsttransaction=facade.transactionFactory.create({type:'aggregate_complete_transaction_v3',// This is the account that will pay for the transactionsignerPublicKey:cosignatoryKeyPair.publicKey.toString(),deadline:timestamp.addHours(2).timestamp,transactionsHash:facade.static.hashEmbeddedTransactions([transferTransaction]),transactions:[transferTransaction]});transaction.fee=newmodels.Amount(feeMult*transaction.size);
Its most relevant fields are:
signer_public_key: this time this is the public key of the cosignatory that will be authorizing the transaction
and paying its fees.
transactions: the list of embedded transactions.
This example has only one, but there could be any number of them.
# Sign the aggregate transaction using the cosignatory's signaturejson_payload=facade.transaction_factory.attach_signature(transaction,facade.sign_transaction(cosignatory_key_pair,transaction))print('Built transaction:')print(json.dumps(transaction.to_json(),indent=2))
// Sign the aggregate transaction using the cosignatory's signatureconstjsonPayload=facade.transactionFactory.static.attachSignature(transaction,facade.signTransaction(cosignatoryKeyPair,transaction));console.log('Built transaction:');console.dir(transaction.toJson(),{colors:true});
Multiple cosignatories
In other multisig configurations, more signatures might be required.
In that case, they are attached using instead of
.
# Announce the transactionannounce_path='/transactions'print(f'Announcing transaction to {announce_path}')announce_request=urllib.request.Request(f'{NODE_URL}{announce_path}',data=json_payload.encode(),headers={'Content-Type':'application/json'},method='PUT')withurllib.request.urlopen(announce_request)asresponse:print(f' Response: {response.read().decode()}')# Wait for confirmationstatus_path=(f'/transactionStatus/{facade.hash_transaction(transaction)}')print(f'Waiting for confirmation from {status_path}')forattemptinrange(60):time.sleep(1)try:withurllib.request.urlopen(f'{NODE_URL}{status_path}')asresponse:status=json.loads(response.read().decode())print(f' Transaction status: {status['group']}')ifstatus['group']=='confirmed':print(f'Transaction confirmed in {attempt} seconds')breakifstatus['group']=='failed':print(f'Transaction failed: {status['code']}')breakexcepturllib.error.HTTPErrorase:print(f' Transaction status: unknown | Cause: ({e.msg})')else:print('Confirmation took too long.')
// Announce the transactionconstannouncePath='/transactions';console.log('Announcing transaction to',announcePath);constannounceResponse=awaitfetch(`${NODE_URL}${announcePath}`,{method:'PUT',headers:{'Content-Type':'application/json'},body:jsonPayload});console.log(' Response:',awaitannounceResponse.text());// Wait for confirmationconsttransactionHash=facade.hashTransaction(transaction).toString();conststatusPath=`/transactionStatus/${transactionHash}`;console.log('Waiting for confirmation from',statusPath);letattempt=0;functionpollStatus(){attempt++;if(attempt>60){console.warn('Confirmation took too long.');return;}returnfetch(`${NODE_URL}${statusPath}`).then(response=>{if(!response.ok){console.log(' Transaction status: unknown | Cause:',response.statusText);// HTTP error: schedule a retryreturnnewPromise(resolve=>setTimeout(resolve,1000)).then(pollStatus);}returnresponse.json();}).then(status=>{// Skip if previous step scheduled a retryif(!status)return;console.log(' Transaction status:',status.group);if(status.group==='confirmed'){console.log('Transaction confirmed in',attempt,'seconds');}elseif(status.group==='failed'){console.log('Transaction failed:',status.code);}else{// Transaction unconfirmed: schedule a retryreturnnewPromise(resolve=>setTimeout(resolve,1000)).then(pollStatus);}});}pollStatus();
Transactions are rejected if they violate protocol constraints.
The following table summarizes the most common error sources:
Error message
Probable cause
Multisig Operation Prohibited By Account
The multisig account tried to sign the aggregate transaction itself.
Aggregate Ineligible Cosignatories
The signer is not in the cosignatories list.
Consumer Batch Signature Not Verifiable
The signature attached to the aggregate transaction does not match its signer_public_key.