Skip to content

Commit

Permalink
(Deposit/Withdraw) Track Nomic withdraw transfer (#3953)
Browse files Browse the repository at this point in the history
* feat: add Nomic withdrawal tx tracking

* feat: update nbtc minimal denom

* improvement: update ibc hash

* fix: bump nomic in web app

* feat: display nomic transfer in recent activity

* feat: improve estimated time

* feat: add provider fee, improve address validation and increase estimated time

* fix: revert name

* fix: revert temp change

* feat: wallet acknowledgement messages
  • Loading branch information
JoseRFelix authored Nov 27, 2024
1 parent 794ca5c commit 8427eb3
Show file tree
Hide file tree
Showing 37 changed files with 391 additions and 274 deletions.
1 change: 1 addition & 0 deletions packages/bridge/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ const txSnapshotSchema = z.object({
})
),
estimatedArrivalUnix: z.number(),
nomicCheckpointIndex: z.number().optional(),
});

export type TxSnapshot = z.infer<typeof txSnapshotSchema>;
Expand Down
64 changes: 42 additions & 22 deletions packages/bridge/src/nomic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ import {
deriveCosmosAddress,
getAllBtcMinimalDenom,
getnBTCMinimalDenom,
getNomicRelayerUrl,
isCosmosAddressValid,
timeout,
} from "@osmosis-labs/utils";
import {
BaseDepositOptions,
buildDestination,
Checkpoint,
DepositInfo,
generateDepositAddressIbc,
getCheckpoint,
getPendingDeposits,
IbcDepositOptions,
} from "nomic-bitcoin";
Expand All @@ -43,9 +46,10 @@ import {
} from "../interface";
import { getGasAsset } from "../utils/gas";
import { getLaunchDarklyFlagValue } from "../utils/launchdarkly";
import { NomicProviderId } from "./utils";

export class NomicBridgeProvider implements BridgeProvider {
static readonly ID = "Nomic";
static readonly ID = NomicProviderId;
readonly providerName = NomicBridgeProvider.ID;

readonly relayers: string[];
Expand All @@ -54,15 +58,11 @@ export class NomicBridgeProvider implements BridgeProvider {
protected protoRegistry: Registry | null = null;

constructor(protected readonly ctx: BridgeProviderContext) {
this.relayers = getNomicRelayerUrl({ env: this.ctx.env });
this.allBtcMinimalDenom = getAllBtcMinimalDenom({ env: this.ctx.env });
this.nBTCMinimalDenom = getnBTCMinimalDenom({
env: this.ctx.env,
});

this.relayers =
this.ctx.env === "testnet"
? ["https://testnet-relayer.nomic.io:8443"]
: ["https://relayer.nomic.mappum.io:8443"];
}

async getQuote(params: GetBridgeQuoteParams): Promise<BridgeQuote> {
Expand Down Expand Up @@ -182,16 +182,29 @@ export class NomicBridgeProvider implements BridgeProvider {
}),
};

const [ibcTxMessages, estimatedTime] = await Promise.all([
ibcProvider.getTxMessages({
...transactionDataParams,
memo: destMemo,
}),
ibcProvider.estimateTransferTime(
transactionDataParams.fromChain.chainId.toString(),
transactionDataParams.toChain.chainId.toString()
),
]);
const [ibcTxMessages, ibcEstimatedTimeSeconds, nomicCheckpoint] =
await Promise.all([
ibcProvider.getTxMessages({
...transactionDataParams,
memo: destMemo,
}),
ibcProvider.estimateTransferTime(
transactionDataParams.fromChain.chainId.toString(),
transactionDataParams.toChain.chainId.toString()
),
getCheckpoint({
relayers: this.relayers,
bitcoinNetwork: this.ctx.env === "mainnet" ? "bitcoin" : "testnet",
}),
]);

// 4 hours
const nomicEstimatedTimeSeconds = 4 * 60 * 60;

const transferFeeInSats = Math.ceil(
(nomicCheckpoint as Checkpoint & { minerFee: number }).minerFee * 64 + 546
);
const transferFeeInMicroSats = transferFeeInSats * 1e6;

const msgs = [...swapMessages, ...ibcTxMessages];

Expand Down Expand Up @@ -238,22 +251,29 @@ export class NomicBridgeProvider implements BridgeProvider {
...params.fromAsset,
},
expectedOutput: {
amount: !!swapRoute
? swapRoute.amount.toCoin().amount
: params.fromAmount,
amount: (!!swapRoute
? new Dec(swapRoute.amount.toCoin().amount)
: new Dec(params.fromAmount)
)
// Use micro sats because the amount will always be nomic btc which has 14 decimals (micro sats)
.sub(new Dec(transferFeeInMicroSats))
.toString(),
...nomicBridgeAsset,
denom: "BTC",
priceImpact: swapRoute?.priceImpactTokenOut?.toDec().toString() ?? "0",
},
fromChain: params.fromChain,
toChain: params.toChain,
// currently subsidized by relayers, but could be paid by user in future by charging the user the gas cost of
transferFee: {
...params.fromAsset,
denom: "BTC",
chainId: params.fromChain.chainId,
amount: "0",
amount: (params.fromAsset.decimals === 14
? transferFeeInMicroSats
: transferFeeInSats
).toString(),
},
estimatedTime,
estimatedTime: ibcEstimatedTimeSeconds + nomicEstimatedTimeSeconds,
estimatedGasFee: gasFee
? {
address: gasAsset?.address ?? gasFee.denom,
Expand Down
97 changes: 97 additions & 0 deletions packages/bridge/src/nomic/transfer-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Chain } from "@osmosis-labs/types";
import { getNomicRelayerUrl, isNil, poll } from "@osmosis-labs/utils";
import { getCheckpoint } from "nomic-bitcoin";

import type {
BridgeEnvironment,
BridgeTransferStatus,
TransferStatusProvider,
TransferStatusReceiver,
TxSnapshot,
} from "../interface";
import { NomicProviderId } from "./utils";

export class NomicTransferStatusProvider implements TransferStatusProvider {
readonly providerId = NomicProviderId;
readonly sourceDisplayName = "Nomic Bridge";
public statusReceiverDelegate?: TransferStatusReceiver;

constructor(
protected readonly chainList: Chain[],
readonly env: BridgeEnvironment
) {}

/** Request to start polling a new transaction. */
async trackTxStatus(snapshot: TxSnapshot): Promise<void> {
const { sendTxHash } = snapshot;

if (!snapshot.nomicCheckpointIndex) {
throw new Error("Nomic checkpoint index is required. Skipping tracking.");
}

await poll({
fn: async () => {
const checkpoint = await getCheckpoint(
{
relayers: getNomicRelayerUrl({ env: this.env }),
bitcoinNetwork: this.env === "mainnet" ? "bitcoin" : "testnet",
},
snapshot.nomicCheckpointIndex!
);

if (isNil(checkpoint.txid)) {
return;
}

return {
id: snapshot.sendTxHash,
status: "success",
} as BridgeTransferStatus;
},
validate: (incomingStatus) => incomingStatus !== undefined,
interval: 30_000,
maxAttempts: undefined, // unlimited attempts while tab is open or until success/fail
})
.then((s) => {
if (s) this.receiveConclusiveStatus(sendTxHash, s);
})
.catch((e) => console.error(`Polling Nomic has failed`, e));
}

receiveConclusiveStatus(
sendTxHash: string,
txStatus: BridgeTransferStatus | undefined
): void {
if (txStatus && txStatus.id) {
const { status, reason } = txStatus;
this.statusReceiverDelegate?.receiveNewTxStatus(
sendTxHash,
status,
reason
);
} else {
console.error(
"Nomic transfer finished poll but neither succeeded or failed"
);
}
}

makeExplorerUrl(snapshot: TxSnapshot): string {
const {
sendTxHash,
fromChain: { chainId: fromChainId },
} = snapshot;

const chain = this.chainList.find(
(chain) => chain.chain_id === fromChainId
);

if (!chain) throw new Error("Chain not found: " + fromChainId);
if (chain.explorers.length === 0) {
// attempt to link to mintscan since this is an IBC transfer
return `https://www.mintscan.io/${chain.chain_name}/txs/${sendTxHash}`;
}

return chain.explorers[0].tx_page.replace("{txHash}", sendTxHash);
}
}
1 change: 1 addition & 0 deletions packages/bridge/src/nomic/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const NomicProviderId = "Nomic";
11 changes: 11 additions & 0 deletions packages/stores/src/account/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ export class AccountStore<Injects extends Record<string, any>[] = []> {
onBroadcastFailed?: (e?: Error) => void;
onBroadcasted?: (txHash: Uint8Array) => void;
onFulfill?: (tx: DeliverTxResponse) => void;
onSign?: () => Promise<void> | void;
}
) {
runInAction(() => {
Expand Down Expand Up @@ -547,13 +548,15 @@ export class AccountStore<Injects extends Record<string, any>[] = []> {

let onBroadcasted: ((txHash: Uint8Array) => void) | undefined;
let onFulfill: ((tx: DeliverTxResponse) => void) | undefined;
let onSign: (() => Promise<void> | void) | undefined;

if (onTxEvents) {
if (typeof onTxEvents === "function") {
onFulfill = onTxEvents;
} else {
onBroadcasted = onTxEvents?.onBroadcasted;
onFulfill = onTxEvents?.onFulfill;
onSign = onTxEvents?.onSign;
}
}

Expand Down Expand Up @@ -598,6 +601,14 @@ export class AccountStore<Injects extends Record<string, any>[] = []> {
const { TxRaw } = await import("cosmjs-types/cosmos/tx/v1beta1/tx");
const encodedTx = TxRaw.encode(txRaw).finish();

if (this.options.preTxEvents?.onSign) {
await this.options.preTxEvents.onSign();
}

if (onSign) {
await onSign();
}

const restEndpoint = getEndpointString(
await wallet.getRestEndpoint(true)
);
Expand Down
7 changes: 4 additions & 3 deletions packages/stores/src/account/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ export type AccountStoreWallet<Injects extends Record<string, any>[] = []> =
};

export interface TxEvents {
onBroadcastFailed?: (string: string, e?: Error) => void;
onBroadcasted?: (string: string, txHash: Uint8Array) => void;
onFulfill?: (string: string, tx: any) => void;
onSign?: () => Promise<void> | void;
onBroadcastFailed?: (chainNameOrId: string, e?: Error) => void;
onBroadcasted?: (chainNameOrId: string, txHash: Uint8Array) => void;
onFulfill?: (chainNameOrId: string, tx: any) => void;
onExceeds1CTNetworkFeeLimit?: (params: {
// Continue with a wallet like Keplr.
continueTx: () => void;
Expand Down
40 changes: 33 additions & 7 deletions packages/utils/src/__tests__/string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,39 +61,65 @@ describe("shorten", () => {
describe("isBitcoinAddressValid", () => {
it("should return true for a valid Bitcoin address (P2PKH)", () => {
const validAddress = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
expect(isBitcoinAddressValid({ address: validAddress })).toBe(true);
expect(
isBitcoinAddressValid({ address: validAddress, env: "mainnet" })
).toBe(true);
});

it("should return true for a valid Bitcoin address (P2SH)", () => {
const validAddress = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy";
expect(isBitcoinAddressValid({ address: validAddress })).toBe(true);
expect(
isBitcoinAddressValid({ address: validAddress, env: "mainnet" })
).toBe(true);
});

it("should return true for a valid Bitcoin address (P2WPKH)", () => {
const validAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
expect(isBitcoinAddressValid({ address: validAddress })).toBe(true);
expect(
isBitcoinAddressValid({ address: validAddress, env: "mainnet" })
).toBe(true);
});

it("should return true for a valid testnet Bitcoin address ", () => {
const validAddress = "tb1qq9epaj33z79vwz5zu9gw40j00yma7cm7g2ympl";
expect(
isBitcoinAddressValid({ address: validAddress, isTestnet: true })
isBitcoinAddressValid({ address: validAddress, env: "testnet" })
).toBe(true);
});

it("should return false for an invalid Bitcoin address", () => {
const invalidAddress = "invalidBitcoinAddress";
expect(isBitcoinAddressValid({ address: invalidAddress })).toBe(false);
expect(
isBitcoinAddressValid({ address: invalidAddress, env: "mainnet" })
).toBe(false);
});

it("should return false for an empty address", () => {
const emptyAddress = "";
expect(isBitcoinAddressValid({ address: emptyAddress })).toBe(false);
expect(
isBitcoinAddressValid({ address: emptyAddress, env: "mainnet" })
).toBe(false);
});

it("should return false for a malformed address", () => {
const malformedAddress = "12345";
expect(isBitcoinAddressValid({ address: malformedAddress })).toBe(false);
expect(
isBitcoinAddressValid({ address: malformedAddress, env: "mainnet" })
).toBe(false);
});

it("should return false for a testnet address in mainnet", () => {
const testnetAddress = "tb1qq9epaj33z79vwz5zu9gw40j00yma7cm7g2ympl";
expect(
isBitcoinAddressValid({ address: testnetAddress, env: "mainnet" })
).toBe(false);
});

it("should return true for a mainnet address in mainnet", () => {
const mainnetAddress = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
expect(
isBitcoinAddressValid({ address: mainnetAddress, env: "mainnet" })
).toBe(true);
});
});

Expand Down
9 changes: 8 additions & 1 deletion packages/utils/src/bitcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const BitcoinChainInfo = {
chainId: "bitcoin",
chainName: "Bitcoin",
color: "#F7931A",
logoUri: "/networks/bitcoin.svg",
};

export const BitcoinMainnetExplorerUrl =
Expand Down Expand Up @@ -39,5 +40,11 @@ export const getnBTCMinimalDenom = ({
}) => {
return env === "mainnet"
? "ibc/75345531D87BD90BF108BE7240BD721CB2CB0A1F16D4EBA71B09EC3C43E15C8F" // nBTC
: "ibc/72D483F0FD4229DBF3ACC78E648F0399C4ACADDFDBCDD9FE791FEE4443343422"; // Testnet nBTC
: "ibc/8D294CE85345F171AAF6B1FF6E64B5A9EE197C99CDAD64D79EA4ACAB270AC95C"; // Testnet nBTC
};

export function getNomicRelayerUrl({ env }: { env: "mainnet" | "testnet" }) {
return env === "testnet"
? ["https://testnet-relayer.nomic.io:8443"]
: ["https://relayer.nomic.mappum.io:8443"];
}
Loading

0 comments on commit 8427eb3

Please sign in to comment.