Skip to content

Commit

Permalink
feat: Liquid claim covenant (#488)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael1011 committed Feb 26, 2024
1 parent 8f3cf44 commit 436245d
Show file tree
Hide file tree
Showing 16 changed files with 356 additions and 41 deletions.
2 changes: 1 addition & 1 deletion lib/api/Utils.ts
Expand Up @@ -8,7 +8,7 @@ import Errors from './Errors';

type ApiArgument = {
name: string;
type: string;
type: 'string' | 'number' | 'boolean' | 'object';
hex?: boolean;
optional?: boolean;
};
Expand Down
8 changes: 7 additions & 1 deletion lib/api/v2/routers/SwapRouter.ts
Expand Up @@ -722,8 +722,11 @@ class SwapRouter extends RouterBase {
* addressSignature:
* type: string
* description: Signature of the claim public key of the SHA256 hash of the address for the direct payment
* claimCovenant:
* type: boolean
* default: false
* description: If the claim covenant should be added to the Taproot tree. Only possible when "address" is set
*/

/**
* @openapi
* components:
Expand Down Expand Up @@ -1227,6 +1230,7 @@ class SwapRouter extends RouterBase {
claimAddress,
invoiceAmount,
onchainAmount,
claimCovenant,
claimPublicKey,
addressSignature,
} = validateRequest(req.body, [
Expand All @@ -1240,6 +1244,7 @@ class SwapRouter extends RouterBase {
{ name: 'claimAddress', type: 'string', optional: true },
{ name: 'invoiceAmount', type: 'number', optional: true },
{ name: 'onchainAmount', type: 'number', optional: true },
{ name: 'claimCovenant', type: 'boolean', optional: true },
{ name: 'claimPublicKey', type: 'string', hex: true, optional: true },
{ name: 'addressSignature', type: 'string', hex: true, optional: true },
]);
Expand All @@ -1257,6 +1262,7 @@ class SwapRouter extends RouterBase {
claimAddress,
invoiceAmount,
onchainAmount,
claimCovenant,
claimPublicKey,

userAddress: address,
Expand Down
4 changes: 4 additions & 0 deletions lib/service/Errors.ts
Expand Up @@ -157,4 +157,8 @@ export default {
message: 'no chain for symbol',
code: concatErrorCode(ErrorCodePrefix.Service, 40),
}),
INVALID_PARTIAL_SIGNATURE: (): Error => ({
message: 'invalid partial signature',
code: concatErrorCode(ErrorCodePrefix.Service, 41),
}),
};
3 changes: 3 additions & 0 deletions lib/service/Service.ts
Expand Up @@ -1367,6 +1367,8 @@ class Service {
// Address of the user to encode in the invoice memo
userAddress?: string;
userAddressSignature?: Buffer;

claimCovenant?: boolean;
}): Promise<{
id: string;
invoice: string;
Expand Down Expand Up @@ -1612,6 +1614,7 @@ class Service {
claimAddress: args.claimAddress,
preimageHash: args.preimageHash,
claimPublicKey: args.claimPublicKey,
claimCovenant: args.claimCovenant || false,
userAddressSignature: args.userAddressSignature,
});

Expand Down
4 changes: 4 additions & 0 deletions lib/service/cooperative/DeferredClaimer.ts
Expand Up @@ -240,6 +240,10 @@ class DeferredClaimer extends TypedEventEmitter<{
musig.initializeSession(
await hashForWitnessV1(chainCurrency, toClaim.cooperative.transaction, 0),
);
if (!musig.verifyPartial(theirPublicKey, theirPartialSignature)) {
throw Errors.INVALID_PARTIAL_SIGNATURE();
}

musig.addPartial(theirPublicKey, theirPartialSignature);
musig.signPartial();

Expand Down
14 changes: 9 additions & 5 deletions lib/swap/ReverseRoutingHints.ts
Expand Up @@ -11,6 +11,7 @@ import Errors from './Errors';

type SwapHints = {
invoiceMemo: string;
receivedAmount: number;
bip21?: string;
routingHint?: HopHint[][];
};
Expand Down Expand Up @@ -39,12 +40,17 @@ class ReverseRoutingHints {
},
): SwapHints => {
const invoiceMemo = getSwapMemo(sendingCurrency.symbol, true);
const receivedAmount =
args.onchainAmount -
this.rateProvider.feeProvider.minerFees.get(sendingCurrency.symbol)![
args.version
].reverse.claim;

if (
args.userAddress === undefined ||
args.userAddressSignature === undefined
) {
return { invoiceMemo };
return { invoiceMemo, receivedAmount };
}

try {
Expand All @@ -58,10 +64,7 @@ class ReverseRoutingHints {
const bip21 = this.paymentRequestUtils.encodeBip21(
sendingCurrency.symbol,
args.userAddress,
args.onchainAmount -
this.rateProvider.feeProvider.minerFees.get(sendingCurrency.symbol)![
args.version
].reverse.claim,
receivedAmount,
);

const routingHint = this.encodeRoutingHint(
Expand All @@ -74,6 +77,7 @@ class ReverseRoutingHints {
bip21,
routingHint,
invoiceMemo,
receivedAmount,
};
};

Expand Down
60 changes: 52 additions & 8 deletions lib/swap/SwapManager.ts
Expand Up @@ -8,7 +8,12 @@ import {
swapScript,
swapTree,
} from 'boltz-core';
import {
Feature,
reverseSwapTree as reverseSwapTreeLiquid,
} from 'boltz-core/dist/lib/liquid';
import { randomBytes } from 'crypto';
import { Network as LiquidNetwork } from 'liquidjs-lib/src/networks';
import { Op } from 'sequelize';
import { createMusig, tweakMusig } from '../Core';
import Logger from '../Logger';
Expand Down Expand Up @@ -597,6 +602,8 @@ class SwapManager {

userAddress?: string;
userAddressSignature?: Buffer;

claimCovenant: boolean;
}): Promise<CreatedReverseSwap> => {
const { sendingCurrency, receivingCurrency } = this.getCurrencies(
args.baseCurrency,
Expand Down Expand Up @@ -692,6 +699,7 @@ class SwapManager {
if (isBitcoinLike) {
const { keys, index } = sendingCurrency.wallet.getNewKeys();
const { blocks } = await sendingCurrency.chainClient!.getBlockchainInfo();

result.timeoutBlockHeight = blocks + args.onchainTimeoutBlockDelta;

let outputScript: Buffer;
Expand All @@ -701,13 +709,50 @@ class SwapManager {
case SwapVersion.Taproot: {
result.refundPublicKey = getHexString(keys.publicKey);

tree = reverseSwapTree(
sendingCurrency.type === CurrencyType.Liquid,
args.preimageHash,
args.claimPublicKey!,
keys.publicKey,
result.timeoutBlockHeight,
);
if (args.claimCovenant) {
if (sendingCurrency.type !== CurrencyType.Liquid) {
throw 'claim covenant only supported on Liquid';
}

if (args.userAddress === undefined) {
throw 'userAddress for covenant not specified';
}

try {
sendingCurrency.wallet.decodeAddress(args.userAddress);
} catch (e) {
throw Errors.INVALID_ADDRESS();
}

tree = reverseSwapTreeLiquid(
args.preimageHash,
args.claimPublicKey!,
keys.publicKey,
result.timeoutBlockHeight,
[
{
expectedAmount: hints.receivedAmount,
type: Feature.ClaimCovenant,
assetHash: (
this.walletManager.wallets.get(sendingCurrency.symbol)!
.network as LiquidNetwork
).assetHash,
outputScript: this.walletManager.wallets
.get(sendingCurrency.symbol)!
.decodeAddress(args.userAddress!),
},
],
);
} else {
tree = reverseSwapTree(
sendingCurrency.type === CurrencyType.Liquid,
args.preimageHash,
args.claimPublicKey!,
keys.publicKey,
result.timeoutBlockHeight,
);
}

result.swapTree = SwapTreeSerializer.serializeSwapTree(tree);

const musig = createMusig(keys, args.claimPublicKey!);
Expand Down Expand Up @@ -750,7 +795,6 @@ class SwapManager {
minerFeeInvoice,
node: nodeType,
keyIndex: index,

version: args.version,
fee: args.percentageFee,
invoice: paymentRequest,
Expand Down
40 changes: 20 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -86,7 +86,7 @@
"cors": "^2.8.5",
"cross-os": "^1.5.0",
"csv-parse": "^5.5.3",
"discord.js": "^14.14.1",
"discord.js": "^14.12.1",
"ecpair": "^2.1.0",
"ethers": "^6.11.1",
"express": "^4.18.2",
Expand Down
5 changes: 5 additions & 0 deletions swagger-spec.json
Expand Up @@ -1903,6 +1903,11 @@
"addressSignature": {
"type": "string",
"description": "Signature of the claim public key of the SHA256 hash of the address for the direct payment"
},
"claimCovenant": {
"type": "boolean",
"default": false,
"description": "If the claim covenant should be added to the Taproot tree. Only possible when \"address\" is set"
}
}
},
Expand Down
20 changes: 20 additions & 0 deletions test/integration/service/cooperative/DeferredClaimer.spec.ts
Expand Up @@ -521,6 +521,26 @@ describe('DeferredClaimer', () => {
expect(claimTx.outs).toHaveLength(1);
});

test('should throw when cooperatively broadcasting a submarine swap with invalid partial signature', async () => {
await bitcoinClient.generate(1);
const { swap, preimage, refundKeys } = await createClaimableOutput();

await expect(claimer.deferClaim(swap, preimage)).resolves.toEqual(true);
await claimer.getCooperativeDetails(swap);

const musig = new Musig(secp, refundKeys, randomBytes(32), [
btcWallet.getKeysByIndex(swap.keyIndex!).publicKey,
refundKeys.publicKey,
]);
await expect(
claimer.broadcastCooperative(
swap,
Buffer.from(musig.getPublicNonce()),
randomBytes(32),
),
).rejects.toEqual(Errors.INVALID_PARTIAL_SIGNATURE());
});

test('should throw when cooperatively broadcasting a submarine swap that does not exist', async () => {
await expect(
claimer.broadcastCooperative(
Expand Down
32 changes: 32 additions & 0 deletions test/unit/api/v2/routers/SwapRouter.spec.ts
Expand Up @@ -721,6 +721,8 @@ describe('SwapRouter', () => {
${'could not parse hex string: preimageHash'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: 'notHex' }}
${'could not parse hex string: claimPublicKey'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: 'notHex' }}
${'could not parse hex string: addressSignature'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', addressSignature: 'notHex' }}
${'invalid parameter: claimCovenant'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', addressSignature: '0011', claimCovenant: 123 }}
${'invalid parameter: claimCovenant'} | ${{ to: 'L-BTC', from: 'BTC', preimageHash: '00', claimPublicKey: '0011', addressSignature: '0011', claimCovenant: 'notBool' }}
`(
'should not create reverse swaps with invalid parameters ($error)',
async ({ body, error }) => {
Expand Down Expand Up @@ -928,6 +930,36 @@ describe('SwapRouter', () => {
});
});

test('should create reverse swaps with claimCovenant', async () => {
const reqBody = {
to: 'L-BTC',
from: 'BTC',
address: 'bc1',
onchainAmount: 123,
claimCovenant: true,
claimPublicKey: '21',
addressSignature: '0011',
preimageHash: getHexString(randomBytes(32)),
};
const res = mockResponse();

await swapRouter['createReverse'](mockRequest(reqBody), res);

expect(service.createReverseSwap).toHaveBeenCalledTimes(1);
expect(service.createReverseSwap).toHaveBeenCalledWith({
pairId: 'L-BTC/BTC',
prepayMinerFee: false,
orderSide: OrderSide.BUY,
version: SwapVersion.Taproot,
userAddress: reqBody.address,
claimCovenant: reqBody.claimCovenant,
onchainAmount: reqBody.onchainAmount,
preimageHash: getHexBuffer(reqBody.preimageHash),
claimPublicKey: getHexBuffer(reqBody.claimPublicKey),
userAddressSignature: getHexBuffer(reqBody.addressSignature),
});
});

test('should get BIP-21 of reverse swaps', async () => {
const invoice = 'bip21Swap';

Expand Down

0 comments on commit 436245d

Please sign in to comment.