Repository: symbol/symbol (language: python)

Issue:
[client/rest]: add remaining construction apis
/construction/payloads
/construction/combine
/construction/parse
/construction/hash
/construction/submit



Discussion / hints:

Fix patch:
diff --git a/client/rest/src/plugins/rosetta/constructionRoutes.js b/client/rest/src/plugins/rosetta/constructionRoutes.js
index ada3602279..6ab5271159 100644
--- a/client/rest/src/plugins/rosetta/constructionRoutes.js
+++ b/client/rest/src/plugins/rosetta/constructionRoutes.js
@@ -20,37 +20,61 @@
  */
 
 import AccountIdentifier from './openApi/model/AccountIdentifier.js';
+import Amount from './openApi/model/Amount.js';
+import ConstructionCombineRequest from './openApi/model/ConstructionCombineRequest.js';
+import ConstructionCombineResponse from './openApi/model/ConstructionCombineResponse.js';
 import ConstructionDeriveRequest from './openApi/model/ConstructionDeriveRequest.js';
 import ConstructionDeriveResponse from './openApi/model/ConstructionDeriveResponse.js';
+import ConstructionHashRequest from './openApi/model/ConstructionHashRequest.js';
 import ConstructionMetadataRequest from './openApi/model/ConstructionMetadataRequest.js';
 import ConstructionMetadataResponse from './openApi/model/ConstructionMetadataResponse.js';
+import ConstructionParseRequest from './openApi/model/ConstructionParseRequest.js';
+import ConstructionParseResponse from './openApi/model/ConstructionParseResponse.js';
+import ConstructionPayloadsRequest from './openApi/model/ConstructionPayloadsRequest.js';
+import ConstructionPayloadsResponse from './openApi/model/ConstructionPayloadsResponse.js';
 import ConstructionPreprocessRequest from './openApi/model/ConstructionPreprocessRequest.js';
 import ConstructionPreprocessResponse from './openApi/model/ConstructionPreprocessResponse.js';
+import ConstructionSubmitRequest from './openApi/model/ConstructionSubmitRequest.js';
+import Currency from './openApi/model/Currency.js';
+import CurveType from './openApi/model/CurveType.js';
+import Operation from './openApi/model/Operation.js';
+import OperationIdentifier from './openApi/model/OperationIdentifier.js';
+import SignatureType from './openApi/model/SignatureType.js';
+import SigningPayload from './openApi/model/SigningPayload.js';
+import TransactionIdentifier from './openApi/model/TransactionIdentifier.js';
+import TransactionIdentifierResponse from './openApi/model/TransactionIdentifierResponse.js';
 import { RosettaErrorFactory, rosettaPostRouteWithNetwork } from './rosettaUtils.js';
-import { NetworkLocator, PublicKey } from 'symbol-sdk';
-import { Network } from 'symbol-sdk/symbol';
+import { NetworkLocator, PublicKey, utils } from 'symbol-sdk';
+import {
+	Address, Network, NetworkTimestamp, SymbolFacade, generateMosaicAliasId, models
+} from 'symbol-sdk/symbol';
 
 export default {
 	register: (server, db, services) => {
 		const networkName = services.config.network.name;
 		const network = NetworkLocator.findByName(Network.NETWORKS, networkName);
+		const facade = new SymbolFacade(network);
 
 		const restUrl = `${services.config.rest.protocol}://localhost:${services.config.rest.port}`;
 
-		server.post('/construction/derive', rosettaPostRouteWithNetwork(networkName, ConstructionDeriveRequest, typedRequest => {
-			if ('edwards25519' !== typedRequest.public_key.curve_type)
+		const parsePublicKey = rosettaPublicKey => {
+			if (new CurveType().edwards25519 !== rosettaPublicKey.curve_type)
 				throw RosettaErrorFactory.UNSUPPORTED_CURVE;
 
 			try {
-				const publicKey = new PublicKey(typedRequest.public_key.hex_bytes);
-				const address = network.publicKeyToAddress(publicKey);
-
-				const response = new ConstructionDeriveResponse();
-				response.account_identifier = new AccountIdentifier(address.toString());
-				return response;
+				return new PublicKey(rosettaPublicKey.hex_bytes);
 			} catch (err) {
 				throw RosettaErrorFactory.INVALID_PUBLIC_KEY;
 			}
+		};
+
+		server.post('/construction/derive', rosettaPostRouteWithNetwork(networkName, ConstructionDeriveRequest, typedRequest => {
+			const publicKey = parsePublicKey(typedRequest.public_key);
+			const address = network.publicKeyToAddress(publicKey);
+
+			const response = new ConstructionDeriveResponse();
+			response.account_identifier = new AccountIdentifier(address.toString());
+			return response;
 		}));
 
 		server.post('/construction/preprocess', rosettaPostRouteWithNetwork(networkName, ConstructionPreprocessRequest, typedRequest => {
@@ -70,9 +94,9 @@ export default {
 			return response;
 		}));
 
-		const fetchWrapper = async (urlPath, jsonProjection) => {
+		const fetchWrapper = async (urlPath, jsonProjection, requestOptions = {}) => {
 			try {
-				const response = await fetch(`${restUrl}/${urlPath}`);
+				const response = await fetch(`${restUrl}/${urlPath}`, requestOptions);
 				if (!response.ok)
 					throw RosettaErrorFactory.CONNECTION_ERROR;
 
@@ -101,5 +125,231 @@ export default {
 			};
 			return response;
 		}));
+
+		const buildAddressToPublicKeyMap = rosettaPublicKeys => {
+			const addressToPublicKeyMap = {};
+			for (let i = 0; i < rosettaPublicKeys.length; ++i) {
+				const publicKey = parsePublicKey(rosettaPublicKeys[i]);
+				const address = facade.network.publicKeyToAddress(publicKey);
+				addressToPublicKeyMap[address.toString()] = publicKey;
+			}
+
+			return addressToPublicKeyMap;
+		};
+
+		const processTransfer = (addressToPublicKeyMap, subOperation1, subOperation2) => {
+			const makeDescriptor = (senderAccount, recipientAccount, amount) => ({
+				type: 'transfer_transaction_v1',
+				signerPublicKey: addressToPublicKeyMap[senderAccount.address],
+				recipientAddress: recipientAccount.address,
+				mosaics: [
+					{ mosaicId: generateMosaicAliasId('symbol.xym'), amount }
+				]
+			});
+
+			const amount = BigInt(subOperation1.amount.value);
+			if (0n !== amount + BigInt(subOperation2.amount.value))
+				throw RosettaErrorFactory.INVALID_REQUEST_DATA;
+
+			if (0n < amount)
+				return makeDescriptor(subOperation2.account, subOperation1.account, amount);
+
+			return makeDescriptor(subOperation1.account, subOperation2.account, -amount);
+		};
+
+		const createSigningPayload = (signingPayload, signerAddress) => {
+			const result = new SigningPayload(utils.uint8ToHex(signingPayload));
+			result.account_identifier = new AccountIdentifier(signerAddress.toString());
+			result.signature_type = new SignatureType().ed25519;
+			return result;
+		};
+
+		const extractCosignerPublicKeys = (allSignerPublicKeys, aggregateSignerPublicKey) => {
+			const cosignerPublicKeyStringSet = new Set(allSignerPublicKeys.map(publicKey => publicKey.toString()));
+			cosignerPublicKeyStringSet.delete(aggregateSignerPublicKey.toString());
+			return [...cosignerPublicKeyStringSet].sort().map(publicKeyString => new models.PublicKey(publicKeyString));
+		};
+
+		server.post('/construction/payloads', rosettaPostRouteWithNetwork(networkName, ConstructionPayloadsRequest, async typedRequest => {
+			const addressToPublicKeyMap = buildAddressToPublicKeyMap(typedRequest.public_keys);
+
+			const embeddedTransactions = [];
+			const allSignerPublicKeys = [];
+			for (let i = 0; i < typedRequest.operations.length; ++i) {
+				const operation = typedRequest.operations[i];
+
+				if ('transfer' === operation.type) {
+					if (i + 1 === typedRequest.operations.length)
+						throw RosettaErrorFactory.INVALID_REQUEST_DATA;
+
+					const nextOperation = typedRequest.operations[i + 1];
+					if ('transfer' !== nextOperation.type)
+						throw RosettaErrorFactory.INVALID_REQUEST_DATA;
+
+					const embeddedTransaction = facade.transactionFactory.createEmbedded(processTransfer(
+						addressToPublicKeyMap,
+						operation,
+						nextOperation
+					));
+
+					embeddedTransactions.push(embeddedTransaction);
+					allSignerPublicKeys.push(embeddedTransaction.signerPublicKey);
+					++i;
+				} else if ('multisig' === operation.type) {
+					const embeddedTransaction = facade.transactionFactory.createEmbedded({
+						type: 'multisig_account_modification_transaction_v1',
+						signerPublicKey: addressToPublicKeyMap[operation.account.address],
+						...operation.metadata
+					});
+
+					embeddedTransactions.push(embeddedTransaction);
+					allSignerPublicKeys.push(embeddedTransaction.signerPublicKey);
+				} else if ('cosign' === operation.type) {
+					allSignerPublicKeys.push(new models.PublicKey(addressToPublicKeyMap[operation.account.address].bytes));
+				}
+			}
+
+			const aggregateSignerPublicKey = new PublicKey(embeddedTransactions[0].signerPublicKey.bytes);
+			const cosignerPublicKeys = extractCosignerPublicKeys(allSignerPublicKeys, aggregateSignerPublicKey);
+
+			const merkleHash = facade.static.hashEmbeddedTransactions(embeddedTransactions);
+			const aggregateTransaction = facade.transactionFactory.create({
+				type: 'aggregate_complete_transaction_v2',
+				signerPublicKey: aggregateSignerPublicKey,
+				deadline: new NetworkTimestamp(typedRequest.metadata.networkTime).addHours(1).timestamp,
+				transactionsHash: merkleHash,
+				transactions: embeddedTransactions,
+
+				cosignatures: cosignerPublicKeys.map(cosignerPublicKey => {
+					const cosignature = new models.Cosignature();
+					cosignature.signerPublicKey = cosignerPublicKey;
+					return cosignature;
+				})
+			});
+
+			const transactionSize = aggregateTransaction.size;
+			aggregateTransaction.fee = new models.Amount(transactionSize * typedRequest.metadata.feeMultiplier);
+
+			const transactionHash = facade.hashTransaction(aggregateTransaction);
+			const transactionPayload = aggregateTransaction.serialize();
+			const signingPayload = facade.extractSigningPayload(aggregateTransaction);
+
+			const response = new ConstructionPayloadsResponse();
+			response.unsigned_transaction = utils.uint8ToHex(transactionPayload);
+			response.payloads = [
+				createSigningPayload(signingPayload, network.publicKeyToAddress(aggregateTransaction.signerPublicKey)),
+				...cosignerPublicKeys.map(publicKey => createSigningPayload(
+					transactionHash.bytes,
+					network.publicKeyToAddress(publicKey)
+				))
+			];
+
+			return response;
+		}));
+
+		server.post('/construction/combine', rosettaPostRouteWithNetwork(networkName, ConstructionCombineRequest, async typedRequest => {
+			const findSignature = publicKey => typedRequest.signatures.find(rosettaSignature =>
+				0 === utils.deepCompare(publicKey.bytes, utils.hexToUint8(rosettaSignature.public_key.hex_bytes))).hex_bytes;
+
+			const aggregateTransaction = facade.transactionFactory.static.deserialize(utils.hexToUint8(typedRequest.unsigned_transaction));
+			aggregateTransaction.signature = new models.Signature(findSignature(aggregateTransaction.signerPublicKey));
+
+			aggregateTransaction.cosignatures.forEach(cosignature => {
+				cosignature.signature = new models.Signature(findSignature(cosignature.signerPublicKey));
+			});
+
+			const transactionPayload = aggregateTransaction.serialize();
+
+			const response = new ConstructionCombineResponse();
+			response.signed_transaction = utils.uint8ToHex(transactionPayload);
+			return response;
+		}));
+
+		server.post('/construction/parse', rosettaPostRouteWithNetwork(networkName, ConstructionParseRequest, async typedRequest => {
+			const xymCurrency = new Currency('symbol.xym', 6);
+
+			const modelAddressToString = address => new Address(address.bytes).toString();
+			const publicKeyToAccountIdentifier = publicKey =>
+				new AccountIdentifier(facade.network.publicKeyToAddress(publicKey).toString());
+
+			let id = 0;
+			const operations = [];
+			const allSignerPublicKeyStringSet = new Set();
+			const aggregateTransaction = facade.transactionFactory.static.deserialize(utils.hexToUint8(typedRequest.transaction));
+			aggregateTransaction.transactions.forEach(transaction => {
+				allSignerPublicKeyStringSet.add(transaction.signerPublicKey.toString());
+
+				if (models.TransactionType.TRANSFER.value === transaction.type.value) {
+					const amount = transaction.mosaics[0].amount.value;
+
+					const sendOperation = new Operation(new OperationIdentifier(id++), 'transfer');
+					sendOperation.account = publicKeyToAccountIdentifier(transaction.signerPublicKey);
+					sendOperation.amount = new Amount((-amount).toString(), xymCurrency);
+					operations.push(sendOperation);
+
+					const receiveOperation = new Operation(new OperationIdentifier(id++), 'transfer');
+					receiveOperation.account = new AccountIdentifier(modelAddressToString(transaction.recipientAddress));
+					receiveOperation.amount = new Amount(amount.toString(), xymCurrency);
+					operations.push(receiveOperation);
+				} else if (models.TransactionType.MULTISIG_ACCOUNT_MODIFICATION.value === transaction.type.value) {
+					const operation = new Operation(new OperationIdentifier(id++), 'multisig');
+					operation.account = publicKeyToAccountIdentifier(transaction.signerPublicKey);
+					operation.metadata = {
+						minApprovalDelta: transaction.minApprovalDelta,
+						minRemovalDelta: transaction.minRemovalDelta,
+						addressAdditions: transaction.addressAdditions.map(modelAddressToString),
+						addressDeletions: transaction.addressDeletions.map(modelAddressToString)
+					};
+					operations.push(operation);
+				} else {
+					throw RosettaErrorFactory.NOT_SUPPORTED_ERROR;
+				}
+			});
+
+			aggregateTransaction.cosignatures.forEach(cosignature => {
+				if (allSignerPublicKeyStringSet.has(cosignature.signerPublicKey.toString()))
+					return;
+
+				allSignerPublicKeyStringSet.add(cosignature.signerPublicKey.toString());
+
+				const operation = new Operation(new OperationIdentifier(id++), 'cosign');
+				operation.account = publicKeyToAccountIdentifier(cosignature.signerPublicKey);
+				operations.push(operation);
+			});
+
+			const response = new ConstructionParseResponse();
+			response.operations = operations;
+			response.account_identifier_signers = [];
+			response.signers = [];
+
+			if (typedRequest.signed) {
+				const cosignerPublicKeys = extractCosignerPublicKeys([...allSignerPublicKeyStringSet], '');
+				response.account_identifier_signers = cosignerPublicKeys.map(publicKeyToAccountIdentifier);
+			}
+
+			return response;
+		}));
+
+		const processTransactionHashRequest = typedRequest => {
+			const aggregateTransaction = facade.transactionFactory.static.deserialize(utils.hexToUint8(typedRequest.signed_transaction));
+			const transactionHash = facade.hashTransaction(aggregateTransaction);
+
+			const response = new TransactionIdentifierResponse();
+			response.transaction_identifier = new TransactionIdentifier(transactionHash.toString());
+			return response;
+		};
+
+		server.post('/construction/hash', rosettaPostRouteWithNetwork(networkName, ConstructionHashRequest, async typedRequest =>
+			processTransactionHashRequest(typedRequest)));
+
+		server.post('/construction/submit', rosettaPostRouteWithNetwork(networkName, ConstructionSubmitRequest, async typedRequest => {
+			await fetchWrapper('transactions', jsonObject => jsonObject.message, {
+				method: 'PUT',
+				headers: { 'Content-Type': 'application/json' },
+				body: JSON.stringify({ payload: typedRequest.signed_transaction })
+			});
+
+			return processTransactionHashRequest(typedRequest);
+		}));
 	}
 };
diff --git a/client/rest/src/plugins/rosetta/rosettaUtils.js b/client/rest/src/plugins/rosetta/rosettaUtils.js
index b19110f997..36966c8314 100644
--- a/client/rest/src/plugins/rosetta/rosettaUtils.js
+++ b/client/rest/src/plugins/rosetta/rosettaUtils.js
@@ -51,6 +51,10 @@ export class RosettaErrorFactory {
 		return new RosettaError(5, 'unable to connect to network', true);
 	}
 
+	static get NOT_SUPPORTED_ERROR() {
+		return new RosettaError(99, 'operation is not supported in rosetta', false);
+	}
+
 	static get INTERNAL_SERVER_ERROR() {
 		return new RosettaError(100, 'internal server error', false);
 	}


Test patch:
diff --git a/client/rest/test/plugins/rosetta/constructionRoutes_spec.js b/client/rest/test/plugins/rosetta/constructionRoutes_spec.js
index 212451e94e..040515b14e 100644
--- a/client/rest/test/plugins/rosetta/constructionRoutes_spec.js
+++ b/client/rest/test/plugins/rosetta/constructionRoutes_spec.js
@@ -21,14 +21,22 @@
 
 import constructionRoutes from '../../../src/plugins/rosetta/constructionRoutes.js';
 import AccountIdentifier from '../../../src/plugins/rosetta/openApi/model/AccountIdentifier.js';
+import ConstructionCombineResponse from '../../../src/plugins/rosetta/openApi/model/ConstructionCombineResponse.js';
 import ConstructionDeriveResponse from '../../../src/plugins/rosetta/openApi/model/ConstructionDeriveResponse.js';
 import ConstructionMetadataResponse from '../../../src/plugins/rosetta/openApi/model/ConstructionMetadataResponse.js';
+import ConstructionParseResponse from '../../../src/plugins/rosetta/openApi/model/ConstructionParseResponse.js';
+import ConstructionPayloadsResponse from '../../../src/plugins/rosetta/openApi/model/ConstructionPayloadsResponse.js';
 import ConstructionPreprocessResponse from '../../../src/plugins/rosetta/openApi/model/ConstructionPreprocessResponse.js';
 import RosettaApiError from '../../../src/plugins/rosetta/openApi/model/Error.js';
+import SigningPayload from '../../../src/plugins/rosetta/openApi/model/SigningPayload.js';
+import TransactionIdentifier from '../../../src/plugins/rosetta/openApi/model/TransactionIdentifier.js';
+import TransactionIdentifierResponse from '../../../src/plugins/rosetta/openApi/model/TransactionIdentifierResponse.js';
 import { RosettaErrorFactory } from '../../../src/plugins/rosetta/rosettaUtils.js';
 import MockServer from '../../routes/utils/MockServer.js';
 import { expect } from 'chai';
 import sinon from 'sinon';
+import { utils } from 'symbol-sdk';
+import { SymbolFacade, generateMosaicAliasId, models } from 'symbol-sdk/symbol';
 
 describe('construction routes', () => {
 	const createMockServer = () => {
@@ -64,7 +72,7 @@ describe('construction routes', () => {
 		expect(() => RosettaApiError.validateJSON(response)).to.not.throw();
 	};
 
-	const assertRosettaSuccessBasic = async (routeName, request, expectedResponse) => {
+	const assertRosettaSuccessBasic = async (routeName, request, expectedResponse, compareOptions = {}) => {
 		// Arrange:
 		const mockServer = createMockServer();
 		const route = mockServer.getRoute(routeName).post();
@@ -78,7 +86,11 @@ describe('construction routes', () => {
 		expect(mockServer.res.statusCode).to.equal(undefined);
 
 		const response = mockServer.send.firstCall.args[0];
-		expect(response).to.deep.equal(expectedResponse);
+		if (compareOptions.roundtripJson)
+			expect(JSON.parse(JSON.stringify(response))).to.deep.equal(JSON.parse(JSON.stringify(expectedResponse)));
+		else
+			expect(response).to.deep.equal(expectedResponse);
+
 		expect(() => expectedResponse.constructor.validateJSON(response)).to.not.throw();
 	};
 
@@ -92,10 +104,15 @@ describe('construction routes', () => {
 	});
 
 	const createRosettaCurrency = () => ({
-		symbol: 'xym',
+		symbol: 'symbol.xym',
 		decimals: 6
 	});
 
+	const createRosettaPublicKey = hexBytes => ({
+		hex_bytes: hexBytes,
+		curve_type: 'edwards25519'
+	});
+
 	const createRosettaTransfer = (index, address, amount) => ({
 		operation_identifier: { index },
 		type: 'transfer',
@@ -110,7 +127,11 @@ describe('construction routes', () => {
 		operation_identifier: { index },
 		type: 'multisig',
 		account: { address },
-		metadata
+		metadata: {
+			addressAdditions: [],
+			addressDeletions: [],
+			...metadata
+		}
 	});
 
 	const createRosettaCosignatory = (index, address) => ({
@@ -121,15 +142,266 @@ describe('construction routes', () => {
 
 	// endregion
 
+	// region utils - PayloadResultVerifier
+
+	class PayloadResultVerifier {
+		constructor() {
+			this.facade = new SymbolFacade('testnet');
+			this.embeddedTransactions = [];
+			this.aggregateTransaction = undefined;
+		}
+
+		addTransfer(signerPublicKey, recipientAddress, amount) {
+			this.embeddedTransactions.push(this.facade.transactionFactory.createEmbedded({
+				type: 'transfer_transaction_v1',
+				signerPublicKey,
+				recipientAddress,
+				mosaics: [
+					{ mosaicId: generateMosaicAliasId('symbol.xym'), amount }
+				]
+			}));
+		}
+
+		addMultisigModification(signerPublicKey, metadata) {
+			this.embeddedTransactions.push(this.facade.transactionFactory.createEmbedded({
+				type: 'multisig_account_modification_transaction_v1',
+				signerPublicKey,
+				...metadata
+			}));
+		}
+
+		addUnsupported(signerPublicKey) {
+			this.embeddedTransactions.push(this.facade.transactionFactory.createEmbedded({
+				type: 'mosaic_definition_transaction_v1',
+				signerPublicKey,
+				duration: 1n,
+				nonce: 123,
+				flags: 'transferable restrictable',
+				divisibility: 2
+			}));
+		}
+
+		buildAggregate(signerPublicKey, deadline, cosignatureCount = 0) {
+			const merkleHash = this.facade.static.hashEmbeddedTransactions(this.embeddedTransactions);
+			this.aggregateTransaction = this.facade.transactionFactory.create({
+				type: 'aggregate_complete_transaction_v2',
+				signerPublicKey,
+				deadline,
+				transactionsHash: merkleHash,
+				transactions: this.embeddedTransactions
+			});
+
+			const transactionSize = this.aggregateTransaction.size + (104 * cosignatureCount);
+			this.aggregateTransaction.fee = new models.Amount(transactionSize * 102);
+		}
+
+		addCosignature(cosignerPublicKey) {
+			const cosignature = new models.Cosignature();
+			cosignature.version = 0n;
+			cosignature.signerPublicKey = new models.PublicKey(cosignerPublicKey);
+			cosignature.signature = new models.Signature(new Uint8Array(64));
+			this.aggregateTransaction.cosignatures.push(cosignature);
+		}
+
+		makeSigningPayload(address) {
+			const signingPayload = new SigningPayload(utils.uint8ToHex(this.facade.extractSigningPayload(this.aggregateTransaction)));
+			signingPayload.account_identifier = new AccountIdentifier(address);
+			signingPayload.signature_type = 'ed25519';
+			return signingPayload;
+		}
+
+		makeCosigningPayload(address) {
+			const aggregateTransactionHash = this.facade.hashTransaction(this.aggregateTransaction);
+			const signingPayload = new SigningPayload(utils.uint8ToHex(aggregateTransactionHash.bytes));
+			signingPayload.account_identifier = new AccountIdentifier(address);
+			signingPayload.signature_type = 'ed25519';
+			return signingPayload;
+		}
+
+		toHexString() {
+			return utils.uint8ToHex(this.aggregateTransaction.serialize());
+		}
+	}
+
+	// endregion
+
+	// region utils - TestCases
+
+	// address => public key mapping (sorted by public key)
+	// TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ => 3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623
+	// TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI => 527068DA90B142D98D27FF9BA2103A54230E3C8FAC8529E804123D986CACDCC9
+	// TCJEJJBKDI62U4ZMO4VI7YAUVJE4STVCOBDSHXQ => 8A18D72A3D90547C9A2503C8513BC2A54D52B96119F4A5DF6E65CDA813EE5D9F
+	// TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ => 93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7
+	// TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI => ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6
+
+	const createSingleTransferCreditFirstTestCase = () => {
+		const verifier = new PayloadResultVerifier();
+		verifier.addTransfer(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI',
+			100n
+		);
+
+		verifier.buildAggregate(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			1001n + (60n * 60n * 1000n)
+		);
+
+		return {
+			verifier,
+			operations: [
+				createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaTransfer(1, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100')
+			],
+			orderedOperations: [
+				createRosettaTransfer(0, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100'),
+				createRosettaTransfer(1, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100')
+			]
+		};
+	};
+
+	const createSingleTransferDebitFirstTestCase = () => {
+		const testCase = createSingleTransferCreditFirstTestCase();
+		testCase.operations.reverse();
+		testCase.operations.forEach((operation, i) => {
+			operation.operation_identifier.index = i;
+		});
+
+		return testCase;
+	};
+
+	const createMultipleTransferTestCase = () => {
+		const verifier = new PayloadResultVerifier();
+		verifier.addTransfer(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI',
+			100n
+		);
+		verifier.addTransfer(
+			'93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7',
+			'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI',
+			50n
+		);
+		verifier.addTransfer(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ',
+			33n
+		);
+
+		verifier.buildAggregate(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			1001n + (60n * 60n * 1000n),
+			1
+		);
+
+		verifier.addCosignature('93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7');
+
+		return {
+			verifier,
+			operations: [
+				createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaTransfer(1, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100'),
+				createRosettaTransfer(2, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '50'),
+				createRosettaTransfer(3, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '-50'),
+				createRosettaTransfer(4, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '33'),
+				createRosettaTransfer(5, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-33')
+			],
+			orderedOperations: [
+				createRosettaTransfer(0, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100'),
+				createRosettaTransfer(1, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaTransfer(2, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '-50'),
+				createRosettaTransfer(3, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '50'),
+				createRosettaTransfer(4, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-33'),
+				createRosettaTransfer(5, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '33')
+			]
+		};
+	};
+
+	const createMultipleTransferExplicitCosignerTestCase = () => {
+		const verifier = new PayloadResultVerifier();
+		verifier.addTransfer(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI',
+			100n
+		);
+		verifier.addTransfer(
+			'93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7',
+			'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI',
+			50n
+		);
+		verifier.addTransfer(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ',
+			33n
+		);
+
+		verifier.buildAggregate(
+			'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+			1001n + (60n * 60n * 1000n),
+			2
+		);
+
+		verifier.addCosignature('3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623');
+		verifier.addCosignature('93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7');
+
+		return {
+			verifier,
+			operations: [
+				createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaTransfer(1, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100'),
+				createRosettaTransfer(2, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '50'),
+				createRosettaTransfer(3, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '-50'),
+				createRosettaTransfer(4, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '33'),
+				createRosettaTransfer(5, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-33'),
+				createRosettaCosignatory(6, 'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ'),
+				createRosettaCosignatory(7, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI'), // redundant
+				createRosettaCosignatory(8, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ') // redundant
+			],
+			orderedOperations: [
+				createRosettaTransfer(0, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100'),
+				createRosettaTransfer(1, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaTransfer(2, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '-50'),
+				createRosettaTransfer(3, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '50'),
+				createRosettaTransfer(4, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-33'),
+				createRosettaTransfer(5, 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ', '33'),
+				createRosettaCosignatory(6, 'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ')
+			]
+		};
+	};
+
+	const createSingleValidMultisigModificationTestCase = propertyName => {
+		const metadata = {
+			minApprovalDelta: 1,
+			minRemovalDelta: 2,
+			[propertyName]: ['TCIO5J4WTXCVC76XZPBWHHNAD2NU52U2MOOVN4Q', 'TBO3SFA2XM3HY5QPBT7C6CCY4K6VXTHIQGPG6OQ']
+		};
+
+		const verifier = new PayloadResultVerifier();
+		verifier.addMultisigModification('3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623', { ...metadata });
+
+		verifier.buildAggregate(
+			'3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623',
+			1001n + (60n * 60n * 1000n)
+		);
+
+		const result = {
+			verifier,
+			operations: [
+				createRosettaMultisig(0, 'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ', { ...metadata })
+			]
+		};
+		result.orderedOperations = result.operations;
+		return result;
+	};
+
+	// endregion
+
 	// region derive
 
 	describe('derive', () => {
 		const createValidRequest = () => ({
 			network_identifier: createRosettaNetworkIdentifier(),
-			public_key: {
-				hex_bytes: 'E85D10BF47FFBCE2230F70CB43ED2DDE04FCF342524B383972F86EA1FF773C79',
-				curve_type: 'edwards25519'
-			}
+			public_key: createRosettaPublicKey('E85D10BF47FFBCE2230F70CB43ED2DDE04FCF342524B383972F86EA1FF773C79')
 		});
 
 		const assertRosettaErrorRaised = (expectedError, malformRequest) =>
@@ -139,16 +411,19 @@ describe('construction routes', () => {
 			delete request.network_identifier.network;
 		}));
 
-		it('fails when curve type is unsupported', () => assertRosettaErrorRaised(RosettaErrorFactory.UNSUPPORTED_CURVE, request => {
-			request.public_key.curve_type = 'secp256k1';
-		}));
+		it('fails when public key curve type is unsupported', () => assertRosettaErrorRaised(
+			RosettaErrorFactory.UNSUPPORTED_CURVE,
+			request => {
+				request.public_key.curve_type = 'secp256k1';
+			}
+		));
 
 		it('fails when public key is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_PUBLIC_KEY, request => {
 			request.public_key.hex_bytes += '0';
 		}));
 
 		it('returns valid response on success', async () => {
-			// Arrange:
+			// Arrange: create expected response
 			const expectedResponse = new ConstructionDeriveResponse();
 			expectedResponse.account_identifier = new AccountIdentifier('TCHEST3QRQS4JZGOO64TH7NFJ2A63YA7TM2K5EQ');
 
@@ -191,7 +466,7 @@ describe('construction routes', () => {
 		}));
 
 		it('returns valid response on success (transfer)', async () => {
-			// Arrange:
+			// Arrange: create expected response
 			const expectedResponse = new ConstructionPreprocessResponse();
 			expectedResponse.options = {};
 			expectedResponse.required_public_keys = [
@@ -203,7 +478,7 @@ describe('construction routes', () => {
 		});
 
 		it('returns valid response on success (multisig)', async () => {
-			// Arrange:
+			// Arrange: create expected response
 			const expectedResponse = new ConstructionPreprocessResponse();
 			expectedResponse.options = {};
 			expectedResponse.required_public_keys = [
@@ -304,6 +579,7 @@ describe('construction routes', () => {
 			stubFetchResult('node/time', true, createNodeTimeResult());
 			stubFetchResult('network/fees/transaction', true, createNetworkFeesTransactionResult());
 
+			// - create expected response
 			const expectedResponse = new ConstructionMetadataResponse();
 			expectedResponse.metadata = {
 				networkTime: 1001,
@@ -317,9 +593,464 @@ describe('construction routes', () => {
 
 	// endregion
 
-	// address => public key (might be needed in later tests)
-	// TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI => 527068DA90B142D98D27FF9BA2103A54230E3C8FAC8529E804123D986CACDCC9
-	// TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI => ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6
-	// TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ => 93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7
-	// TCJEJJBKDI62U4ZMO4VI7YAUVJE4STVCOBDSHXQ => 8A18D72A3D90547C9A2503C8513BC2A54D52B96119F4A5DF6E65CDA813EE5D9F
+	// region payloads
+
+	describe('payloads', () => {
+		const createValidRequest = () => ({
+			network_identifier: createRosettaNetworkIdentifier(),
+			operations: [
+				createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaTransfer(1, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-100')
+			],
+			public_keys: [
+				createRosettaPublicKey('527068DA90B142D98D27FF9BA2103A54230E3C8FAC8529E804123D986CACDCC9'),
+				createRosettaPublicKey('ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6'),
+				createRosettaPublicKey('93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7'),
+				createRosettaPublicKey('3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623')
+			],
+			metadata: {
+				networkTime: 1001,
+				feeMultiplier: 102
+			}
+		});
+
+		const assertRosettaErrorRaised = (expectedError, malformRequest) =>
+			assertRosettaErrorRaisedBasic('/construction/payloads', createValidRequest(), expectedError, malformRequest);
+
+		it('fails when request is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			delete request.network_identifier.network;
+		}));
+
+		it('fails when any public key curve type is unsupported', () => assertRosettaErrorRaised(
+			RosettaErrorFactory.UNSUPPORTED_CURVE,
+			request => {
+				request.public_keys[1].curve_type = 'secp256k1';
+			}
+		));
+
+		it('fails when any public key is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_PUBLIC_KEY, request => {
+			request.public_keys[1].hex_bytes += '0';
+		}));
+
+		it('fails when any transfer is hanging', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			request.operations = [
+				createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100')
+			];
+		}));
+
+		it('fails when any transfer is unpaired', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			request.operations = [
+				createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+				createRosettaCosignatory(1, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI')
+			];
+		}));
+
+		it('fails when any transfer has mismatched amounts', () => assertRosettaErrorRaised(
+			RosettaErrorFactory.INVALID_REQUEST_DATA,
+			request => {
+				request.operations = [
+					createRosettaTransfer(0, 'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI', '100'),
+					createRosettaTransfer(1, 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI', '-99')
+				];
+			}
+		));
+
+		const assertSingleValidTransfer = async testCase => {
+			// Arrange:
+			const { verifier } = testCase;
+			const request = createValidRequest();
+			request.operations = testCase.operations;
+
+			// - create expected response
+			const expectedResponse = new ConstructionPayloadsResponse();
+			expectedResponse.unsigned_transaction = verifier.toHexString();
+
+			expectedResponse.payloads = [
+				verifier.makeSigningPayload('TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI')
+			];
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/payloads', request, expectedResponse);
+		};
+
+		it('succeeds when transfer has matched amounts (credit first)', () =>
+			assertSingleValidTransfer(createSingleTransferCreditFirstTestCase()));
+
+		it('succeeds when transfer has matched amounts (debit first)', () =>
+			assertSingleValidTransfer(createSingleTransferDebitFirstTestCase()));
+
+		it('succeeds when multiple transfers', async () => {
+			// Arrange:
+			const { verifier, operations } = createMultipleTransferTestCase();
+			const request = createValidRequest();
+			request.operations = operations;
+
+			// - create expected response
+			const expectedResponse = new ConstructionPayloadsResponse();
+			expectedResponse.unsigned_transaction = verifier.toHexString();
+
+			expectedResponse.payloads = [
+				verifier.makeSigningPayload('TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI'),
+				verifier.makeCosigningPayload('TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ')
+			];
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/payloads', request, expectedResponse);
+		});
+
+		it('succeeds when multiple transfers with explicit cosigners', async () => {
+			// Arrange:
+			const { verifier, operations } = createMultipleTransferExplicitCosignerTestCase();
+			const request = createValidRequest();
+			request.operations = operations;
+
+			// - create expected response
+			const expectedResponse = new ConstructionPayloadsResponse();
+			expectedResponse.unsigned_transaction = verifier.toHexString();
+
+			expectedResponse.payloads = [
+				verifier.makeSigningPayload('TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI'),
+				verifier.makeCosigningPayload('TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ'),
+				verifier.makeCosigningPayload('TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ')
+			];
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/payloads', request, expectedResponse);
+		});
+
+		const assertSingleValidMultisigModification = async propertyName => {
+			// Arrange:
+			const { verifier, operations } = createSingleValidMultisigModificationTestCase(propertyName);
+			const request = createValidRequest();
+			request.operations = operations;
+
+			// - create expected response
+			const expectedResponse = new ConstructionPayloadsResponse();
+			expectedResponse.unsigned_transaction = verifier.toHexString();
+
+			expectedResponse.payloads = [
+				verifier.makeSigningPayload('TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ')
+			];
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/payloads', request, expectedResponse);
+		};
+
+		it('succeeds when multisig has additions', () => assertSingleValidMultisigModification('addressAdditions'));
+
+		it('succeeds when multisig has deletions', () => assertSingleValidMultisigModification('addressDeletions'));
+	});
+
+	// endregion
+
+	// region combine
+
+	describe('combine', () => {
+		const createValidRequest = () => ({
+			network_identifier: createRosettaNetworkIdentifier(),
+			signatures: [
+				{
+					signing_payload: '',
+					account_identifier: { address: 'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI' },
+					public_key: createRosettaPublicKey('ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6'),
+					hex_bytes: '11'.repeat(64),
+					signature_type: 'ed25519'
+				}
+			],
+			unsigned_transaction: ''
+		});
+
+		const assertRosettaErrorRaised = (expectedError, malformRequest) =>
+			assertRosettaErrorRaisedBasic('/construction/combine', createValidRequest(), expectedError, malformRequest);
+
+		it('fails when request is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			delete request.network_identifier.network;
+		}));
+
+		it('succeeds when no cosignatures are required', async () => {
+			// Arrange:
+			const { verifier } = createSingleTransferCreditFirstTestCase();
+
+			const request = createValidRequest();
+			request.unsigned_transaction = verifier.toHexString();
+
+			// - add expected signatures
+			verifier.aggregateTransaction.signature = new models.Signature('11'.repeat(64));
+
+			// - create expected response
+			const expectedResponse = new ConstructionCombineResponse();
+			expectedResponse.signed_transaction = verifier.toHexString();
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/combine', request, expectedResponse);
+		});
+
+		it('succeeds when cosignatures are required', async () => {
+			// Arrange:
+			const { verifier } = createSingleTransferCreditFirstTestCase();
+			verifier.addCosignature('93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7');
+			verifier.addCosignature('3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623');
+
+			const request = createValidRequest();
+			request.signatures.push({
+				signing_payload: '',
+				account_identifier: { address: 'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ' },
+				public_key: createRosettaPublicKey('93A62514605D7DE3BDF699C54AE850CA3DACDC8CCA41A69C786CE97FA5F690D7'),
+				hex_bytes: '22'.repeat(64),
+				signature_type: 'ed25519'
+			});
+			request.signatures.push({
+				signing_payload: '',
+				account_identifier: { address: 'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ' },
+				public_key: createRosettaPublicKey('3119DA3BFF57385BB6F051B8A454F219CE519D28E50D5653F5F457486E9E8623'),
+				hex_bytes: '33'.repeat(64),
+				signature_type: 'ed25519'
+			});
+			request.unsigned_transaction = verifier.toHexString();
+
+			// - add expected signatures
+			verifier.aggregateTransaction.signature = new models.Signature('11'.repeat(64));
+
+			verifier.aggregateTransaction.cosignatures[0].signature = new models.Signature('22'.repeat(64));
+			verifier.aggregateTransaction.cosignatures[1].signature = new models.Signature('33'.repeat(64));
+
+			// - create expected response
+			const expectedResponse = new ConstructionCombineResponse();
+			expectedResponse.signed_transaction = verifier.toHexString();
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/combine', request, expectedResponse);
+		});
+	});
+
+	// endregion
+
+	// region parse
+
+	describe('parse', () => {
+		const createValidRequest = (signed = false) => {
+			const { verifier } = createSingleTransferCreditFirstTestCase();
+			const signedTransactionHex = verifier.toHexString();
+
+			return {
+				network_identifier: createRosettaNetworkIdentifier(),
+				signed,
+				transaction: signedTransactionHex
+			};
+		};
+
+		const assertRosettaErrorRaised = (expectedError, malformRequest) =>
+			assertRosettaErrorRaisedBasic('/construction/parse', createValidRequest(), expectedError, malformRequest);
+
+		it('fails when request is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			delete request.network_identifier.network;
+		}));
+
+		it('fails when transaction is unparseable', () => assertRosettaErrorRaised(RosettaErrorFactory.INTERNAL_SERVER_ERROR, request => {
+			request.transaction = '';
+		}));
+
+		it('fails when transaction is unsupported', () => assertRosettaErrorRaised(RosettaErrorFactory.NOT_SUPPORTED_ERROR, request => {
+			const verifier = new PayloadResultVerifier();
+			verifier.addTransfer(
+				'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+				'TARZARAKDFNYFVFANAIAHCYUADHHZWT2WP2I7GI',
+				100n
+			);
+			verifier.addUnsupported('ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6');
+			verifier.addTransfer(
+				'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+				'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ',
+				33n
+			);
+
+			verifier.buildAggregate(
+				'ED7FE5166BDC65D065667630B96362B3E57AFCA2B557B57E02022631C8C8F1A6',
+				1001n + (60n * 60n * 1000n),
+				1
+			);
+
+			request.transaction = verifier.toHexString();
+		}));
+
+		const assertRosettaSuccess = async (testCase, signed, expectedSigners = []) => {
+			// Arrange:
+			const { verifier, orderedOperations } = testCase;
+			const request = createValidRequest(signed);
+			request.transaction = verifier.toHexString();
+
+			// - create expected response
+			const expectedResponse = new ConstructionParseResponse();
+			expectedResponse.operations = orderedOperations;
+			expectedResponse.account_identifier_signers = expectedSigners.map(address => new AccountIdentifier(address));
+			expectedResponse.signers = [];
+
+			// Act + Assert: `orderedOperations` is a simple JS object, but `parse` builds up typed rosetta OpenAPI objects
+			//               they cannot be compared directly, only indirectly via JSON
+			await assertRosettaSuccessBasic('/construction/parse', request, expectedResponse, { roundtripJson: true });
+		};
+
+		it('succeeds when transfer has matched amounts (credit first) [unsigned]', () =>
+			assertRosettaSuccess(createSingleTransferCreditFirstTestCase(), false));
+
+		it('succeeds when transfer has matched amounts (debit first) [unsigned]', () =>
+			assertRosettaSuccess(createSingleTransferDebitFirstTestCase(), false));
+
+		it('succeeds when multiple transfers [unsigned]', () => assertRosettaSuccess(createMultipleTransferTestCase(), false));
+
+		it('succeeds when multiple transfers with explicit cosigners [unsigned]', () =>
+			assertRosettaSuccess(createMultipleTransferExplicitCosignerTestCase(), false));
+
+		it('succeeds when multisig has additions [unsigned]', () =>
+			assertRosettaSuccess(createSingleValidMultisigModificationTestCase('addressAdditions'), false));
+
+		it('succeeds when multisig has deletions [unsigned]', () =>
+			assertRosettaSuccess(createSingleValidMultisigModificationTestCase('addressDeletions'), false));
+
+		it('succeeds when transfer has matched amounts (credit first) [signed]', () =>
+			assertRosettaSuccess(createSingleTransferCreditFirstTestCase(), true, ['TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI']));
+
+		it('succeeds when transfer has matched amounts (debit first) [signed]', () =>
+			assertRosettaSuccess(createSingleTransferDebitFirstTestCase(), true, ['TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI']));
+
+		it('succeeds when multiple transfers [signed]', () => assertRosettaSuccess(createMultipleTransferTestCase(), true, [
+			'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ',
+			'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI'
+		]));
+
+		it('succeeds when multiple transfers with explicit cosigners [signed]', () =>
+			assertRosettaSuccess(createMultipleTransferExplicitCosignerTestCase(), true, [
+				'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ',
+				'TCULEHFGXY7E6TWBXH7CVKNKFSUH43RNWW52NWQ',
+				'TDI2ZPA7U72GHU2ZDP4C4J6T5YMFSLWEW4OZQKI'
+			]));
+
+		it('succeeds when multisig has additions [signed]', () =>
+			assertRosettaSuccess(createSingleValidMultisigModificationTestCase('addressAdditions'), true, [
+				'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ'
+			]));
+
+		it('succeeds when multisig has deletions [signed]', () =>
+			assertRosettaSuccess(createSingleValidMultisigModificationTestCase('addressDeletions'), true, [
+				'TBPXHVTQBGRTSYXP4Q55EEUIV73UFC2D72KCWXQ'
+			]));
+	});
+
+	// endregion
+
+	// region hash
+
+	const createBasicSignedTransaction = () => {
+		const { verifier } = createSingleTransferCreditFirstTestCase();
+
+		// add expected signatures
+		verifier.aggregateTransaction.signature = new models.Signature('11'.repeat(64));
+		return {
+			aggregateTransaction: verifier.aggregateTransaction,
+			transactionHash: verifier.facade.hashTransaction(verifier.aggregateTransaction)
+		};
+	};
+
+	const createValidHashRequest = () => {
+		const { aggregateTransaction } = createBasicSignedTransaction();
+		const signedTransactionHex = utils.uint8ToHex(aggregateTransaction.serialize());
+
+		return {
+			network_identifier: createRosettaNetworkIdentifier(),
+			signed_transaction: signedTransactionHex
+		};
+	};
+
+	describe('hash', () => {
+		const assertRosettaErrorRaised = (expectedError, malformRequest) =>
+			assertRosettaErrorRaisedBasic('/construction/hash', createValidHashRequest(), expectedError, malformRequest);
+
+		it('fails when request is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			delete request.network_identifier.network;
+		}));
+
+		it('succeeds when transaction is valid', async () => {
+			// Arrange:
+			const { transactionHash } = createBasicSignedTransaction();
+
+			// - create expected response
+			const expectedResponse = new TransactionIdentifierResponse();
+			expectedResponse.transaction_identifier = new TransactionIdentifier(transactionHash.toString());
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/hash', createValidHashRequest(), expectedResponse);
+		});
+	});
+
+	// endregion
+
+	// region submit
+
+	describe('submit', () => {
+		const assertRosettaErrorRaised = (expectedError, malformRequest) =>
+			assertRosettaErrorRaisedBasic('/construction/submit', createValidHashRequest(), expectedError, malformRequest);
+
+		const stubFetchResult = (signedTransactionHex, ok, jsonResult) => {
+			if (!global.fetch.restore)
+				sinon.stub(global, 'fetch');
+
+			const fetchOptions = {
+				method: 'PUT',
+				headers: { 'Content-Type': 'application/json' },
+				body: `{"payload":"${signedTransactionHex}"}`
+			};
+			global.fetch.withArgs('http://localhost:3456/transactions', fetchOptions).returns(Promise.resolve({
+				ok,
+				json: () => jsonResult
+			}));
+		};
+
+		afterEach(() => {
+			if (global.fetch.restore)
+				global.fetch.restore();
+		});
+
+		it('fails when request is invalid', () => assertRosettaErrorRaised(RosettaErrorFactory.INVALID_REQUEST_DATA, request => {
+			delete request.network_identifier.network;
+		}));
+
+		it('fails when fetch fails (headers)', async () => {
+			// Arrange:
+			const { aggregateTransaction } = createBasicSignedTransaction();
+			const signedTransactionHex = utils.uint8ToHex(aggregateTransaction.serialize());
+
+			stubFetchResult(signedTransactionHex, false, { message: 'success' });
+
+			// Act + Assert:
+			await assertRosettaErrorRaised(RosettaErrorFactory.CONNECTION_ERROR, () => {});
+		});
+
+		it('fails when fetch fails (body)', async () => {
+			// Arrange:
+			const { aggregateTransaction } = createBasicSignedTransaction();
+			const signedTransactionHex = utils.uint8ToHex(aggregateTransaction.serialize());
+
+			stubFetchResult(signedTransactionHex, true, Promise.reject(Error('fetch failed')));
+
+			// Act + Assert:
+			await assertRosettaErrorRaised(RosettaErrorFactory.CONNECTION_ERROR, () => {});
+		});
+
+		it('returns valid response on success', async () => {
+			// Arrange:
+			const { aggregateTransaction, transactionHash } = createBasicSignedTransaction();
+			const signedTransactionHex = utils.uint8ToHex(aggregateTransaction.serialize());
+
+			stubFetchResult(signedTransactionHex, true, { message: 'success' });
+
+			// - create expected response
+			const expectedResponse = new TransactionIdentifierResponse();
+			expectedResponse.transaction_identifier = new TransactionIdentifier(transactionHash.toString());
+
+			// Act + Assert:
+			await assertRosettaSuccessBasic('/construction/submit', createValidHashRequest(), expectedResponse);
+		});
+	});
+
+	// endregion
 });
diff --git a/client/rest/test/plugins/rosetta/rosetta_spec.js b/client/rest/test/plugins/rosetta/rosetta_spec.js
index 6f6f94aa15..fa6a92620a 100644
--- a/client/rest/test/plugins/rosetta/rosetta_spec.js
+++ b/client/rest/test/plugins/rosetta/rosetta_spec.js
@@ -46,7 +46,12 @@ describe('rosetta plugin', () => {
 			test.assert.assertRoutes(routes, [
 				'/construction/derive',
 				'/construction/preprocess',
-				'/construction/metadata'
+				'/construction/metadata',
+				'/construction/payloads',
+				'/construction/combine',
+				'/construction/parse',
+				'/construction/hash',
+				'/construction/submit'
 			]);
 		});
 	});