Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add multisig support to contract deploys #1539

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading