Skip to content

Commit

Permalink
feat: add multisig support to contract deploys (#1539)
Browse files Browse the repository at this point in the history
* feat: multisig support for contract deploy transactions

* fix: contract deploy options type removed

* chore: add backward compatibility

* test: add origin verification to tests

---------

Co-authored-by: janniks <6362150+janniks@users.noreply.github.com>
Co-authored-by: janniks <janniks@users.noreply.github.com>
  • Loading branch information
3 people committed Sep 4, 2023
1 parent c137482 commit 260f2d5
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 35 deletions.
4 changes: 2 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
ClarityAbi,
ClarityValue,
ContractCallPayload,
ContractDeployOptions,
SignedContractDeployOptions,
createStacksPrivateKey,
cvToString,
estimateContractDeploy,
Expand Down Expand Up @@ -766,7 +766,7 @@ async function contractDeploy(network: CLINetworkAdapter, args: string[]): Promi
? new StacksMainnet({ url: network.legacyNetwork.blockstackAPIUrl })
: new StacksTestnet({ url: network.legacyNetwork.blockstackAPIUrl });

const options: ContractDeployOptions = {
const options: SignedContractDeployOptions = {
contractName,
codeBody: source,
senderKey: privateKey,
Expand Down
95 changes: 68 additions & 27 deletions packages/transactions/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import { ClarityAbi, validateContractCall } from './contract-abi';
import { NoEstimateAvailableError } from './errors';
import {
createStacksPrivateKey,
createStacksPublicKey,
getPublicKey,
pubKeyfromPrivKey,
publicKeyFromBytes,
Expand Down Expand Up @@ -707,16 +706,29 @@ export interface BaseContractDeployOptions {
sponsored?: boolean;
}

export interface ContractDeployOptions extends BaseContractDeployOptions {
/** a hex string of the private key of the transaction sender */
senderKey: string;
}

export interface UnsignedContractDeployOptions extends BaseContractDeployOptions {
/** a hex string of the public key of the transaction sender */
publicKey: string;
}

export interface SignedContractDeployOptions extends BaseContractDeployOptions {
senderKey: string;
}

/** @deprecated Use {@link SignedContractDeployOptions} or {@link UnsignedContractDeployOptions} instead. */
export interface ContractDeployOptions extends SignedContractDeployOptions {}

export interface UnsignedMultiSigContractDeployOptions extends BaseContractDeployOptions {
numSignatures: number;
publicKeys: string[];
}

export interface SignedMultiSigContractDeployOptions extends BaseContractDeployOptions {
numSignatures: number;
publicKeys: string[];
signerKeys: string[];
}

/**
* @deprecated Use the new {@link estimateTransaction} function insterad.
*
Expand Down Expand Up @@ -772,31 +784,49 @@ export async function estimateContractDeploy(
/**
* Generates a Clarity smart contract deploy transaction
*
* @param {ContractDeployOptions} txOptions - an options object for the contract deploy
* @param {SignedContractDeployOptions | SignedMultiSigContractDeployOptions} txOptions - an options object for the contract deploy
*
* Returns a signed Stacks smart contract deploy transaction.
*
* @return {StacksTransaction}
*/
export async function makeContractDeploy(
txOptions: ContractDeployOptions
txOptions: SignedContractDeployOptions | SignedMultiSigContractDeployOptions
): Promise<StacksTransaction> {
const privKey = createStacksPrivateKey(txOptions.senderKey);
const stacksPublicKey = getPublicKey(privKey);
const publicKey = publicKeyToString(stacksPublicKey);
const unsignedTxOptions: UnsignedContractDeployOptions = { ...txOptions, publicKey };
const transaction: StacksTransaction = await makeUnsignedContractDeploy(unsignedTxOptions);
if ('senderKey' in txOptions) {
// txOptions is SignedContractDeployOptions
const publicKey = publicKeyToString(getPublicKey(createStacksPrivateKey(txOptions.senderKey)));
const options = omit(txOptions, 'senderKey');
const transaction = await makeUnsignedContractDeploy({ publicKey, ...options });

if (txOptions.senderKey) {
const privKey = createStacksPrivateKey(txOptions.senderKey);
const signer = new TransactionSigner(transaction);
signer.signOrigin(privKey);
}

return transaction;
return transaction;
} else {
// txOptions is SignedMultiSigContractDeployOptions
const options = omit(txOptions, 'signerKeys');
const transaction = await makeUnsignedContractDeploy(options);

const signer = new TransactionSigner(transaction);
let pubKeys = txOptions.publicKeys;
for (const key of txOptions.signerKeys) {
const pubKey = pubKeyfromPrivKey(key);
pubKeys = pubKeys.filter(pk => pk !== bytesToHex(pubKey.data));
signer.signOrigin(createStacksPrivateKey(key));
}

for (const key of pubKeys) {
signer.appendOrigin(publicKeyFromBytes(hexToBytes(key)));
}

return transaction;
}
}

export async function makeUnsignedContractDeploy(
txOptions: UnsignedContractDeployOptions
txOptions: UnsignedContractDeployOptions | UnsignedMultiSigContractDeployOptions
): Promise<StacksTransaction> {
const defaultOptions = {
fee: BigInt(0),
Expand All @@ -815,17 +845,28 @@ export async function makeUnsignedContractDeploy(
options.clarityVersion
);

const addressHashMode = AddressHashMode.SerializeP2PKH;
const pubKey = createStacksPublicKey(options.publicKey);

let authorization: Authorization | null = null;

const spendingCondition = createSingleSigSpendingCondition(
addressHashMode,
publicKeyToString(pubKey),
options.nonce,
options.fee
);
let spendingCondition: SpendingCondition | null = null;

if ('publicKey' in options) {
// single-sig
spendingCondition = createSingleSigSpendingCondition(
AddressHashMode.SerializeP2PKH,
options.publicKey,
options.nonce,
options.fee
);
} else {
// multi-sig
spendingCondition = createMultiSigSpendingCondition(
AddressHashMode.SerializeP2SH,
options.numSignatures,
options.publicKeys,
options.nonce,
options.fee
);
}

if (options.sponsored) {
authorization = createSponsoredAuth(spendingCondition);
Expand Down Expand Up @@ -863,7 +904,7 @@ export async function makeUnsignedContractDeploy(
options.network.version === TransactionVersion.Mainnet
? AddressVersion.MainnetSingleSig
: AddressVersion.TestnetSingleSig;
const senderAddress = publicKeyToAddress(addressVersion, pubKey);
const senderAddress = c32address(addressVersion, transaction.auth.spendingCondition!.signer);
const txNonce = await getNonce(senderAddress, options.network);
transaction.setNonce(txNonce);
}
Expand Down
56 changes: 50 additions & 6 deletions packages/transactions/tests/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ test('Make STX token transfer with set tx fee', async () => {
memo,
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand Down Expand Up @@ -241,10 +242,10 @@ test('Make STX token transfer with fee estimate', async () => {
anchorMode: AnchorMode.Any,
});

expect(() => transaction.verifyOrigin()).not.toThrow();
expect(transaction.auth.spendingCondition?.fee?.toString()).toEqual('180');

const serialized = bytesToHex(transaction.serialize());

const tx =
'0000000001040015c31b8c1c11c515e244b75806bac48d1399c775000000000000000000000000000000b4' +
'0001e5ac1152f6018fbfded102268b22086666150823d0ae57f4023bde058a7ff0b279076db25b358b8833' +
Expand Down Expand Up @@ -275,9 +276,9 @@ test('Make STX token transfer with testnet', async () => {
memo: memo,
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

const tx =
'8080000000040015c31b8c1c11c515e244b75806bac48d1399c77500000000000000000000000000000000' +
'00014199f63f7e010141a36a4624d032758f54e08ff03b24ed2667463eb405b4d81505631b32a1f13b5737' +
Expand All @@ -299,9 +300,9 @@ test('Make STX token transfer with testnet string name', async () => {
memo: 'test memo',
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

const tx =
'8080000000040015c31b8c1c11c515e244b75806bac48d1399c77500000000000000000000000000000000' +
'00014199f63f7e010141a36a4624d032758f54e08ff03b24ed2667463eb405b4d81505631b32a1f13b5737' +
Expand Down Expand Up @@ -395,6 +396,7 @@ test('Make Multi-Sig STX token transfer', async () => {
signer.signOrigin(privKeys[0]);
signer.signOrigin(privKeys[1]);
signer.appendOrigin(pubKeys[2]);
expect(() => transaction.verifyOrigin()).not.toThrow();

const serializedTx = transaction.serialize();
const tx =
Expand Down Expand Up @@ -644,6 +646,7 @@ test('Make Multi-Sig STX token transfer with two transaction signers', async ()

const bytesReader = new BytesReader(serializedTx);
const deserializedTx = deserializeTransaction(bytesReader);
expect(() => deserializedTx.verifyOrigin()).not.toThrow();

expect(deserializedTx.auth.authType).toBe(authType);

Expand Down Expand Up @@ -723,6 +726,7 @@ test('Make versioned smart contract deploy', async () => {
anchorMode: AnchorMode.Any,
clarityVersion: ClarityVersion.Clarity2,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand All @@ -748,6 +752,7 @@ test('Make smart contract deploy (defaults to versioned smart contract, as of 2.
network: new StacksTestnet(),
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand All @@ -767,6 +772,7 @@ test('Make smart contract deploy with network string name (defaults to versioned
network: 'testnet',
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand Down Expand Up @@ -807,6 +813,37 @@ test('Make smart contract deploy unsigned', async () => {
expect(deserializedTx.auth.spendingCondition!.fee!.toString()).toBe(fee.toString());
});

test('make a multi-sig contract deploy', async () => {
const contractName = 'kv-store';
const codeBody = fs.readFileSync('./tests/contracts/kv-store.clar').toString();
const fee = 0;
const nonce = 0;
const privKeyStrings = [
'6d430bb91222408e7706c9001cfaeb91b08c2be6d5ac95779ab52c6b431950e001',
'2a584d899fed1d24e26b524f202763c8ab30260167429f157f1c119f550fa6af01',
'd5200dee706ee53ae98a03fba6cf4fdcc5084c30cfa9e1b3462dcdeaa3e0f1d201',
];

const pubKeys = privKeyStrings.map(pubKeyfromPrivKey);
const pubKeyStrings = pubKeys.map(publicKeyToString);

const transaction = await makeContractDeploy({
codeBody,
contractName,
publicKeys: pubKeyStrings,
numSignatures: 3,
signerKeys: privKeyStrings,
fee,
nonce,
network: new StacksTestnet(),
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();
expect(transaction.auth.spendingCondition!.signer).toEqual(
'04128cacf0764f69b1e291f62d1dcdd8f65be5ab'
);
});

test('Make smart contract deploy signed', async () => {
const contractName = 'kv-store';
const codeBody = fs.readFileSync('./tests/contracts/kv-store.clar').toString();
Expand All @@ -825,6 +862,7 @@ test('Make smart contract deploy signed', async () => {
network: new StacksTestnet(),
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serializedTx = transaction.serialize();

Expand Down Expand Up @@ -857,6 +895,7 @@ test('Make contract-call', async () => {
network: new StacksTestnet(),
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand All @@ -881,6 +920,7 @@ test('Make contract-call with network string', async () => {
network: 'testnet',
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand Down Expand Up @@ -952,6 +992,7 @@ test('Make contract-call with post conditions', async () => {
postConditionMode: PostConditionMode.Deny,
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand Down Expand Up @@ -996,6 +1037,7 @@ test('Make contract-call with post condition allow mode', async () => {
postConditionMode: PostConditionMode.Allow,
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

const serialized = bytesToHex(transaction.serialize());

Expand Down Expand Up @@ -1055,12 +1097,11 @@ test('make a multi-sig contract call', async () => {
'2a584d899fed1d24e26b524f202763c8ab30260167429f157f1c119f550fa6af01',
'd5200dee706ee53ae98a03fba6cf4fdcc5084c30cfa9e1b3462dcdeaa3e0f1d201',
];
// const privKeys = privKeyStrings.map(createStacksPrivateKey);

const pubKeys = privKeyStrings.map(pubKeyfromPrivKey);
const pubKeyStrings = pubKeys.map(publicKeyToString);

const tx = await makeContractCall({
const transaction = await makeContractCall({
contractAddress,
contractName,
functionName,
Expand All @@ -1074,8 +1115,11 @@ test('make a multi-sig contract call', async () => {
postConditionMode: PostConditionMode.Allow,
anchorMode: AnchorMode.Any,
});
expect(() => transaction.verifyOrigin()).not.toThrow();

expect(tx.auth.spendingCondition!.signer).toEqual('04128cacf0764f69b1e291f62d1dcdd8f65be5ab');
expect(transaction.auth.spendingCondition!.signer).toEqual(
'04128cacf0764f69b1e291f62d1dcdd8f65be5ab'
);
});

test('Estimate transaction transfer fee', async () => {
Expand Down

0 comments on commit 260f2d5

Please sign in to comment.