From b6e32f8601ffee8ba1d6a5fd629e650aa7c62406 Mon Sep 17 00:00:00 2001
From: Gabe Rodriguez <grod220@gmail.com>
Date: Tue, 24 Sep 2024 17:52:58 +0200
Subject: [PATCH] Detect relevant IbcRelay actions (#1805)

* Save relevate ibc relay tx

* changesets

* Support ANY parsing w/ registry

* review updates

* update registry
---
 .changeset/afraid-coins-give.md               |   5 +
 .changeset/clean-lions-carry.md               |   5 +
 .changeset/old-emus-perform.md                |   5 +
 .changeset/rotten-starfishes-watch.md         |   5 +
 .changeset/smooth-trains-reflect.md           |   5 +
 .changeset/wise-papayas-lay.md                |   5 +
 apps/minifront/package.json                   |   2 +-
 .../src/components/tx-details/tx-viewer.tsx   |   2 +-
 packages/bech32m/src/penumbracompat1.ts       |   9 ++
 packages/protobuf/src/registry.ts             |   7 +-
 .../protobuf/src/services/cosmos-ibc-core.ts  |   4 +
 packages/protobuf/src/web.ts                  |  10 +-
 packages/query/src/block-processor.ts         |   1 +
 .../query/src/helpers/identify-txs.test.ts    |  79 ++++++++--
 packages/query/src/helpers/identify-txs.ts    |  51 ++++++-
 packages/storage/package.json                 |   4 +-
 packages/storage/src/indexed-db/index.ts      |   5 +-
 packages/storage/src/indexed-db/stream.ts     |   3 +-
 packages/types/src/address.test.ts            |  24 +++
 packages/types/src/address.ts                 |  10 ++
 packages/types/src/servers.ts                 |   6 +
 packages/ui/components/ui/tx/action-view.tsx  |   3 +-
 .../ui/tx/actions-views/ibc-relay.tsx         | 144 ++++++++++++++++++
 .../ui/tx/actions-views/isc20-withdrawal.tsx  |   3 +-
 packages/wasm/crate/src/keys.rs               |  21 ++-
 packages/wasm/crate/src/view_server.rs        |  12 +-
 packages/wasm/crate/tests/test_keys.rs        |  28 +++-
 packages/wasm/src/address.ts                  |   9 +-
 packages/wasm/src/view-server.ts              |   7 +-
 pnpm-lock.yaml                                |  16 +-
 30 files changed, 430 insertions(+), 60 deletions(-)
 create mode 100644 .changeset/afraid-coins-give.md
 create mode 100644 .changeset/clean-lions-carry.md
 create mode 100644 .changeset/old-emus-perform.md
 create mode 100644 .changeset/rotten-starfishes-watch.md
 create mode 100644 .changeset/smooth-trains-reflect.md
 create mode 100644 .changeset/wise-papayas-lay.md
 create mode 100644 packages/types/src/address.test.ts
 create mode 100644 packages/types/src/address.ts
 create mode 100644 packages/ui/components/ui/tx/actions-views/ibc-relay.tsx

diff --git a/.changeset/afraid-coins-give.md b/.changeset/afraid-coins-give.md
new file mode 100644
index 0000000000..5419ea0c51
--- /dev/null
+++ b/.changeset/afraid-coins-give.md
@@ -0,0 +1,5 @@
+---
+'@penumbra-zone/storage': patch
+---
+
+Bump registry version
diff --git a/.changeset/clean-lions-carry.md b/.changeset/clean-lions-carry.md
new file mode 100644
index 0000000000..b72ab37bca
--- /dev/null
+++ b/.changeset/clean-lions-carry.md
@@ -0,0 +1,5 @@
+---
+'@penumbra-zone/protobuf': minor
+---
+
+Add MsgUpdateClient to typeRegistry
diff --git a/.changeset/old-emus-perform.md b/.changeset/old-emus-perform.md
new file mode 100644
index 0000000000..2125f50d63
--- /dev/null
+++ b/.changeset/old-emus-perform.md
@@ -0,0 +1,5 @@
+---
+'@penumbra-zone/bech32m': patch
+---
+
+Add isCompatAddress() func
diff --git a/.changeset/rotten-starfishes-watch.md b/.changeset/rotten-starfishes-watch.md
new file mode 100644
index 0000000000..86453bf6f6
--- /dev/null
+++ b/.changeset/rotten-starfishes-watch.md
@@ -0,0 +1,5 @@
+---
+'@penumbra-zone/wasm': minor
+---
+
+Add is_controlled_address() support
diff --git a/.changeset/smooth-trains-reflect.md b/.changeset/smooth-trains-reflect.md
new file mode 100644
index 0000000000..b0560d0661
--- /dev/null
+++ b/.changeset/smooth-trains-reflect.md
@@ -0,0 +1,5 @@
+---
+'@penumbra-zone/query': minor
+---
+
+Check for IbcRelays to add to txs
diff --git a/.changeset/wise-papayas-lay.md b/.changeset/wise-papayas-lay.md
new file mode 100644
index 0000000000..250d2a5207
--- /dev/null
+++ b/.changeset/wise-papayas-lay.md
@@ -0,0 +1,5 @@
+---
+'@penumbra-zone/types': minor
+---
+
+Update ViewServerInterface
diff --git a/apps/minifront/package.json b/apps/minifront/package.json
index 70664aaeaa..9b2b227eba 100644
--- a/apps/minifront/package.json
+++ b/apps/minifront/package.json
@@ -22,7 +22,7 @@
     "@cosmos-kit/core": "^2.13.1",
     "@cosmos-kit/react": "^2.18.0",
     "@interchain-ui/react": "^1.23.29",
-    "@penumbra-labs/registry": "^11.2.0",
+    "@penumbra-labs/registry": "^11.3.1",
     "@penumbra-zone/bech32m": "workspace:*",
     "@penumbra-zone/client": "workspace:*",
     "@penumbra-zone/crypto-web": "workspace:*",
diff --git a/apps/minifront/src/components/tx-details/tx-viewer.tsx b/apps/minifront/src/components/tx-details/tx-viewer.tsx
index b2ce0ee03e..84e0e1570f 100644
--- a/apps/minifront/src/components/tx-details/tx-viewer.tsx
+++ b/apps/minifront/src/components/tx-details/tx-viewer.tsx
@@ -48,7 +48,7 @@ export const TxViewer = ({ txInfo }: { txInfo?: TransactionInfo }) => {
 
   // use React-Query to invoke custom hooks that call async translators.
   const { data: receiverView } = useQuery(
-    ['receiverView', txInfo, option],
+    ['receiverView', txInfo?.toJson({ typeRegistry }), option],
     () =>
       fetchReceiverView(
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TODO: justify
diff --git a/packages/bech32m/src/penumbracompat1.ts b/packages/bech32m/src/penumbracompat1.ts
index 1d8116cc76..8da7caec0b 100644
--- a/packages/bech32m/src/penumbracompat1.ts
+++ b/packages/bech32m/src/penumbracompat1.ts
@@ -12,4 +12,13 @@ export const compatAddressFromBech32 = (penumbracompat1: string): { [innerName]:
   [innerName]: fromBech32(penumbracompat1 as `${typeof prefix}1${string}`, prefix),
 });
 
+export const isCompatAddress = (check: string): check is `${typeof prefix}1${string}` => {
+  try {
+    compatAddressFromBech32(check);
+    return true;
+  } catch {
+    return false;
+  }
+};
+
 export { PENUMBRA_BECH32M_ADDRESS_LENGTH, PENUMBRA_BECH32M_ADDRESS_PREFIX } from './index.js';
diff --git a/packages/protobuf/src/registry.ts b/packages/protobuf/src/registry.ts
index 01c9f1ef03..0fa6d4e009 100644
--- a/packages/protobuf/src/registry.ts
+++ b/packages/protobuf/src/registry.ts
@@ -1,4 +1,4 @@
-import { IMessageTypeRegistry, createRegistry } from '@bufbuild/protobuf';
+import { createRegistry, IMessageTypeRegistry } from '@bufbuild/protobuf';
 
 import * as ibcCore from './services/cosmos-ibc-core.js';
 import * as penumbraCnidarium from './services/penumbra-cnidarium.js';
@@ -6,8 +6,6 @@ import * as penumbraCore from './services/penumbra-core.js';
 import * as penumbraCustody from './services/penumbra-custody.js';
 import * as penumbraUtil from './services/penumbra-util.js';
 import * as penumbraView from './services/penumbra-view.js';
-
-import { MsgRecvPacket } from '../gen/ibc/core/channel/v1/tx_pb.js';
 import { ClientState, Header } from '../gen/ibc/lightclients/tendermint/v1/tendermint_pb.js';
 import { DutchAuction } from '../gen/penumbra/core/component/auction/v1/auction_pb.js';
 
@@ -38,9 +36,6 @@ export const typeRegistry: IMessageTypeRegistry = createRegistry(
   ClientState,
   Header,
 
-  // gen/ibc/core/channel/v1/tx_pb
-  MsgRecvPacket,
-
   // penumbra/core/component/auction/v1/auction_pb
   DutchAuction,
 );
diff --git a/packages/protobuf/src/services/cosmos-ibc-core.ts b/packages/protobuf/src/services/cosmos-ibc-core.ts
index 7c6e55ba80..17d3049110 100644
--- a/packages/protobuf/src/services/cosmos-ibc-core.ts
+++ b/packages/protobuf/src/services/cosmos-ibc-core.ts
@@ -1,4 +1,8 @@
 export { Query as IbcChannelService } from '../../gen/ibc/core/channel/v1/query_connect.js';
+export { Msg as IbcChannelMsgService } from '../../gen/ibc/core/channel/v1/tx_connect.js';
+
 export { Query as IbcClientService } from '../../gen/ibc/core/client/v1/query_connect.js';
 export { Msg as IbcClientMsgService } from '../../gen/ibc/core/client/v1/tx_connect.js';
+
 export { Query as IbcConnectionService } from '../../gen/ibc/core/connection/v1/query_connect.js';
+export { Msg as IbcConnectionMsgService } from '../../gen/ibc/core/connection/v1/tx_connect.js';
diff --git a/packages/protobuf/src/web.ts b/packages/protobuf/src/web.ts
index 27e5e837d8..10ec114021 100644
--- a/packages/protobuf/src/web.ts
+++ b/packages/protobuf/src/web.ts
@@ -1,6 +1,9 @@
-import type {
+import {
+  IbcChannelMsgService,
   IbcChannelService,
+  IbcClientMsgService,
   IbcClientService,
+  IbcConnectionMsgService,
   IbcConnectionService,
 } from './services/cosmos-ibc-core.js';
 import type { CustodyService } from './services/penumbra-custody.js';
@@ -11,11 +14,11 @@ import type {
   CommunityPoolService,
   CompactBlockService,
   DexService,
-  SimulationService,
   FeeService,
   GovernanceService,
   SctService,
   ShieldedPoolService,
+  SimulationService,
   StakeService,
 } from './services/penumbra-core.js';
 import type { TendermintProxyService } from './services/penumbra-util.js';
@@ -30,8 +33,11 @@ export type PenumbraService =
   | typeof FeeService
   | typeof GovernanceService
   | typeof IbcChannelService
+  | typeof IbcChannelMsgService
   | typeof IbcClientService
+  | typeof IbcClientMsgService
   | typeof IbcConnectionService
+  | typeof IbcConnectionMsgService
   | typeof SctService
   | typeof ShieldedPoolService
   | typeof SimulationService
diff --git a/packages/query/src/block-processor.ts b/packages/query/src/block-processor.ts
index 74002b2eba..9cf35433d1 100644
--- a/packages/query/src/block-processor.ts
+++ b/packages/query/src/block-processor.ts
@@ -310,6 +310,7 @@ export class BlockProcessor implements BlockProcessorInterface {
         spentNullifiers,
         recordsByCommitment,
         blockTx,
+        addr => this.viewServer.isControlledAddress(addr),
       );
 
       // this simply stores the new records with 'rehydrated' sources to idb
diff --git a/packages/query/src/helpers/identify-txs.test.ts b/packages/query/src/helpers/identify-txs.test.ts
index 943ede38b2..7f93a4f717 100644
--- a/packages/query/src/helpers/identify-txs.test.ts
+++ b/packages/query/src/helpers/identify-txs.test.ts
@@ -1,8 +1,5 @@
 import { describe, expect, test } from 'vitest';
-import {
-  CommitmentSource,
-  Nullifier,
-} from '@penumbra-zone/protobuf/penumbra/core/component/sct/v1/sct_pb';
+import { Nullifier } from '@penumbra-zone/protobuf/penumbra/core/component/sct/v1/sct_pb';
 import { StateCommitment } from '@penumbra-zone/protobuf/penumbra/crypto/tct/v1/tct_pb';
 import {
   Action,
@@ -10,6 +7,7 @@ import {
   TransactionBody,
 } from '@penumbra-zone/protobuf/penumbra/core/transaction/v1/transaction_pb';
 import {
+  BLANK_TX_SOURCE,
   getCommitmentsFromActions,
   getNullifiersFromActions,
   identifyTransactions,
@@ -27,10 +25,15 @@ import {
   SwapClaimBody,
 } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb';
 import { SpendableNoteRecord, SwapRecord } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb';
-
-const BLANK_TX_SOURCE = new CommitmentSource({
-  source: { case: 'transaction', value: { id: new Uint8Array() } },
-});
+import { Any } from '@bufbuild/protobuf';
+import {
+  FungibleTokenPacketData,
+  IbcRelay,
+} from '@penumbra-zone/protobuf/penumbra/core/component/ibc/v1/ibc_pb';
+import { addressFromBech32m } from '@penumbra-zone/bech32m/penumbra';
+import { Address } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
+import { Packet } from '@penumbra-zone/protobuf/ibc/core/channel/v1/channel_pb';
+import { MsgRecvPacket } from '@penumbra-zone/protobuf/ibc/core/channel/v1/tx_pb';
 
 describe('getCommitmentsFromActions', () => {
   test('returns empty array when tx.body.actions is undefined', () => {
@@ -213,7 +216,12 @@ describe('identifyTransactions', () => {
     const spentNullifiers = new Set<Nullifier>();
     const commitmentRecords = new Map<StateCommitment, SpendableNoteRecord | SwapRecord>();
 
-    const result = await identifyTransactions(spentNullifiers, commitmentRecords, blockTx);
+    const result = await identifyTransactions(
+      spentNullifiers,
+      commitmentRecords,
+      blockTx,
+      () => false,
+    );
 
     expect(result.relevantTxs).toEqual([]);
     expect(result.recoveredSourceRecords).toEqual([]);
@@ -311,12 +319,17 @@ describe('identifyTransactions', () => {
 
     const spentNullifiersBeforeSize = spentNullifiers.size;
     const commitmentRecordsBeforeSize = commitmentRecords.size;
-    const result = await identifyTransactions(spentNullifiers, commitmentRecords, [
-      tx1, // relevant
-      tx2, // relevant
-      tx3, // not
-      tx4, // not
-    ]);
+    const result = await identifyTransactions(
+      spentNullifiers,
+      commitmentRecords,
+      [
+        tx1, // relevant
+        tx2, // relevant
+        tx3, // not
+        tx4, // not
+      ],
+      () => false,
+    );
 
     expect(result.relevantTxs.length).toBe(2);
     expect(result.recoveredSourceRecords.length).toBe(1);
@@ -328,4 +341,40 @@ describe('identifyTransactions', () => {
     expect(spentNullifiersBeforeSize).toEqual(spentNullifiers.size);
     expect(commitmentRecordsBeforeSize).toEqual(commitmentRecords.size);
   });
+
+  test('identifies ibc relays', async () => {
+    const knownAddr =
+      'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4';
+    const unknownAddr =
+      'penumbracompat1147mfall0zr6am5r45qkwht7xqqrdsp50czde7empv7yq2nk3z8yyfh9k9520ddgswkmzar22vhz9dwtuem7uxw0qytfpv7lk3q9dp8ccaw2fn5c838rfackazmgf3ahhwqq0da';
+    const tx = new Transaction({
+      body: {
+        actions: [createIbcRelay(knownAddr), createIbcRelay(unknownAddr)],
+      },
+    });
+    const blockTx = [tx];
+    const spentNullifiers = new Set<Nullifier>();
+    const commitmentRecords = new Map<StateCommitment, SpendableNoteRecord | SwapRecord>();
+
+    const result = await identifyTransactions(spentNullifiers, commitmentRecords, blockTx, addr =>
+      addr.equals(new Address(addressFromBech32m(knownAddr))),
+    );
+
+    expect(result.relevantTxs.length).toBe(1);
+    expect(result.relevantTxs[0]?.data.equals(tx)).toBeTruthy();
+    expect(result.recoveredSourceRecords.length).toBe(0);
+  });
 });
+
+const createIbcRelay = (receiver: string): Action => {
+  const tokenPacketData = new FungibleTokenPacketData({ receiver });
+  const encoder = new TextEncoder();
+  const relevantRelay = Any.pack(
+    new MsgRecvPacket({
+      packet: new Packet({ data: encoder.encode(tokenPacketData.toJsonString()) }),
+    }),
+  );
+  return new Action({
+    action: { case: 'ibcRelayAction', value: new IbcRelay({ rawAction: relevantRelay }) },
+  });
+};
diff --git a/packages/query/src/helpers/identify-txs.ts b/packages/query/src/helpers/identify-txs.ts
index 389c121f63..43068ddc20 100644
--- a/packages/query/src/helpers/identify-txs.ts
+++ b/packages/query/src/helpers/identify-txs.ts
@@ -7,11 +7,50 @@ import { SpendableNoteRecord, SwapRecord } from '@penumbra-zone/protobuf/penumbr
 import { Transaction } from '@penumbra-zone/protobuf/penumbra/core/transaction/v1/transaction_pb';
 import { TransactionId } from '@penumbra-zone/protobuf/penumbra/core/txhash/v1/txhash_pb';
 import { sha256Hash } from '@penumbra-zone/crypto-web/sha256';
+import { MsgRecvPacket } from '@penumbra-zone/protobuf/ibc/core/channel/v1/tx_pb';
+import { FungibleTokenPacketData } from '@penumbra-zone/protobuf/penumbra/core/component/ibc/v1/ibc_pb';
+import { ViewServerInterface } from '@penumbra-zone/types/servers';
+import { parseIntoAddr } from '@penumbra-zone/types/address';
 
-const BLANK_TX_SOURCE = new CommitmentSource({
+export const BLANK_TX_SOURCE = new CommitmentSource({
   source: { case: 'transaction', value: { id: new Uint8Array() } },
 });
 
+// Identifies if a tx with a relay action of which the receiver is the user
+const hasRelevantIbcRelay = (
+  tx: Transaction,
+  isControlledAddr: ViewServerInterface['isControlledAddress'],
+) => {
+  return tx.body?.actions.some(action => {
+    if (action.action.case !== 'ibcRelayAction') {
+      return false;
+    }
+
+    if (!action.action.value.rawAction?.is(MsgRecvPacket.typeName)) {
+      return false;
+    }
+
+    const recvPacket = new MsgRecvPacket();
+    const success = action.action.value.rawAction.unpackTo(recvPacket);
+    if (!success) {
+      throw new Error('Error while trying to unpack Any to MsgRecvPacket');
+    }
+
+    if (!recvPacket.packet?.data) {
+      throw new Error('No FungibleTokenPacketData MsgRecvPacket');
+    }
+
+    try {
+      const dataString = new TextDecoder().decode(recvPacket.packet.data);
+      const { receiver } = FungibleTokenPacketData.fromJsonString(dataString);
+      const receivingAddr = parseIntoAddr(receiver);
+      return isControlledAddr(receivingAddr);
+    } catch (e) {
+      return false;
+    }
+  });
+};
+
 // Used as a type-check helper as .filter(Boolean) still results with undefined as a possible value
 const isDefined = <T>(value: T | null | undefined): value is NonNullable<T> =>
   value !== null && value !== undefined;
@@ -70,6 +109,7 @@ const searchRelevant = async (
   tx: Transaction,
   spentNullifiers: Set<Nullifier>,
   commitmentRecords: Map<StateCommitment, SpendableNoteRecord | SwapRecord>,
+  isControlledAddr: ViewServerInterface['isControlledAddress'],
 ): Promise<
   { relevantTx: RelevantTx; recoveredSourceRecords: RecoveredSourceRecords } | undefined
 > => {
@@ -99,6 +139,10 @@ const searchRelevant = async (
     }
   }
 
+  if (hasRelevantIbcRelay(tx, isControlledAddr)) {
+    txId ??= await generateTxId(tx);
+  }
+
   if (txId) {
     return {
       relevantTx: { id: txId, data: tx },
@@ -115,6 +159,7 @@ export const identifyTransactions = async (
   spentNullifiers: Set<Nullifier>,
   commitmentRecords: Map<StateCommitment, SpendableNoteRecord | SwapRecord>,
   blockTx: Transaction[],
+  isControlledAddr: ViewServerInterface['isControlledAddress'],
 ): Promise<{
   relevantTxs: RelevantTx[];
   recoveredSourceRecords: RecoveredSourceRecords;
@@ -122,7 +167,9 @@ export const identifyTransactions = async (
   const relevantTxs: RelevantTx[] = [];
   const recoveredSourceRecords: RecoveredSourceRecords = [];
 
-  const searchPromises = blockTx.map(tx => searchRelevant(tx, spentNullifiers, commitmentRecords));
+  const searchPromises = blockTx.map(tx =>
+    searchRelevant(tx, spentNullifiers, commitmentRecords, isControlledAddr),
+  );
   const results = await Promise.all(searchPromises);
 
   for (const result of results) {
diff --git a/packages/storage/package.json b/packages/storage/package.json
index 1f6df81f40..5fb091d8e3 100644
--- a/packages/storage/package.json
+++ b/packages/storage/package.json
@@ -39,13 +39,13 @@
     "idb": "^8.0.0"
   },
   "devDependencies": {
-    "@penumbra-labs/registry": "^11.2.0",
+    "@penumbra-labs/registry": "^11.3.1",
     "@penumbra-zone/protobuf": "workspace:*",
     "fetch-mock": "^10.0.7"
   },
   "peerDependencies": {
     "@bufbuild/protobuf": "^1.10.0",
-    "@penumbra-labs/registry": "^11.2.0",
+    "@penumbra-labs/registry": "^11.3.1",
     "@penumbra-zone/bech32m": "workspace:*",
     "@penumbra-zone/getters": "workspace:*",
     "@penumbra-zone/protobuf": "workspace:*",
diff --git a/packages/storage/src/indexed-db/index.ts b/packages/storage/src/indexed-db/index.ts
index 00682acf85..67b9227eba 100644
--- a/packages/storage/src/indexed-db/index.ts
+++ b/packages/storage/src/indexed-db/index.ts
@@ -64,6 +64,7 @@ import { PartialMessage, PlainMessage } from '@bufbuild/protobuf';
 import { getAmountFromRecord } from '@penumbra-zone/getters/spendable-note-record';
 import { isZero } from '@penumbra-zone/types/amount';
 import { IDB_VERSION } from './config.js';
+import { typeRegistry } from '@penumbra-zone/protobuf';
 
 const assertBytes = (v?: Uint8Array, expect?: number, name = 'value'): v is Uint8Array => {
   if (expect !== undefined && v?.length !== expect) {
@@ -363,7 +364,7 @@ export class IndexedDb implements IndexedDbInterface {
     const tx = new TransactionInfo({ id, height, transaction });
     await this.u.update({
       table: 'TRANSACTIONS',
-      value: tx.toJson() as Jsonified<TransactionInfo>,
+      value: tx.toJson({ typeRegistry }) as Jsonified<TransactionInfo>,
     });
   }
 
@@ -374,7 +375,7 @@ export class IndexedDb implements IndexedDbInterface {
     if (!jsonRecord) {
       return undefined;
     }
-    return TransactionInfo.fromJson(jsonRecord);
+    return TransactionInfo.fromJson(jsonRecord, { typeRegistry });
   }
 
   async getFmdParams(): Promise<FmdParameters | undefined> {
diff --git a/packages/storage/src/indexed-db/stream.ts b/packages/storage/src/indexed-db/stream.ts
index eb760f708b..60f472958a 100644
--- a/packages/storage/src/indexed-db/stream.ts
+++ b/packages/storage/src/indexed-db/stream.ts
@@ -1,6 +1,7 @@
 import { AnyMessage, JsonValue, Message, MessageType } from '@bufbuild/protobuf';
 import { IDBPCursorWithValue } from 'idb';
 import type { PenumbraDb, PenumbraStoreNames } from '@penumbra-zone/types/indexed-db';
+import { typeRegistry } from '@penumbra-zone/protobuf';
 
 export class IdbCursorSource<N extends PenumbraStoreNames, T extends Message<T> = AnyMessage>
   implements UnderlyingDefaultSource<T>
@@ -15,7 +16,7 @@ export class IdbCursorSource<N extends PenumbraStoreNames, T extends Message<T>
     void (async () => {
       let cursor = await this.cursor;
       while (cursor) {
-        cont.enqueue(this.messageType.fromJson(cursor.value as JsonValue));
+        cont.enqueue(this.messageType.fromJson(cursor.value as JsonValue, { typeRegistry }));
         cursor = await cursor.continue();
       }
       cont.close();
diff --git a/packages/types/src/address.test.ts b/packages/types/src/address.test.ts
new file mode 100644
index 0000000000..9751b2d541
--- /dev/null
+++ b/packages/types/src/address.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from 'vitest';
+import { parseIntoAddr } from './address.js';
+
+describe('parseIntoAddr', () => {
+  test('works with compat', () => {
+    expect(() =>
+      parseIntoAddr(
+        'penumbracompat1147mfall0zr6am5r45qkwht7xqqrdsp50czde7empv7yq2nk3z8yyfh9k9520ddgswkmzar22vhz9dwtuem7uxw0qytfpv7lk3q9dp8ccaw2fn5c838rfackazmgf3ahhwqq0da',
+      ),
+    ).not.toThrow();
+  });
+
+  test('works with normal addresses', () => {
+    expect(() =>
+      parseIntoAddr(
+        'penumbra1e8k5cyds484dxvapeamwveh5khqv4jsvyvaf5wwxaaccgfghm229qw03pcar3ryy8smptevstycch0qk3uu0rgkvtjpxy3cu3rjd0agawqtlz6erev28a6sg69u7cxy0t02nd4',
+      ),
+    ).not.toThrow();
+  });
+
+  test('raises on invalid addresses', () => {
+    expect(() => parseIntoAddr('not_valid_format')).toThrow();
+  });
+});
diff --git a/packages/types/src/address.ts b/packages/types/src/address.ts
new file mode 100644
index 0000000000..97f1182f6c
--- /dev/null
+++ b/packages/types/src/address.ts
@@ -0,0 +1,10 @@
+import { Address } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
+import { addressFromBech32m } from '@penumbra-zone/bech32m/penumbra';
+import { compatAddressFromBech32, isCompatAddress } from '@penumbra-zone/bech32m/penumbracompat1';
+
+export const parseIntoAddr = (addrStr: string): Address => {
+  if (isCompatAddress(addrStr)) {
+    return new Address(compatAddressFromBech32(addrStr));
+  }
+  return new Address(addressFromBech32m(addrStr));
+};
diff --git a/packages/types/src/servers.ts b/packages/types/src/servers.ts
index b90d50a846..8d05ec4977 100644
--- a/packages/types/src/servers.ts
+++ b/packages/types/src/servers.ts
@@ -1,10 +1,16 @@
 import { ScanBlockResult } from './state-commitment-tree.js';
 import { CompactBlock } from '@penumbra-zone/protobuf/penumbra/core/component/compact_block/v1/compact_block_pb';
 import { MerkleRoot } from '@penumbra-zone/protobuf/penumbra/crypto/tct/v1/tct_pb';
+import { Address } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
 
 export interface ViewServerInterface {
   scanBlock(compactBlock: CompactBlock, skipTrialDecrypt: boolean): Promise<boolean>;
+
   flushUpdates(): ScanBlockResult;
+
   resetTreeToStored(): Promise<void>;
+
   getSctRoot(): MerkleRoot;
+
+  isControlledAddress(address: Address): boolean;
 }
diff --git a/packages/ui/components/ui/tx/action-view.tsx b/packages/ui/components/ui/tx/action-view.tsx
index fdecac6e23..b32f040d9c 100644
--- a/packages/ui/components/ui/tx/action-view.tsx
+++ b/packages/ui/components/ui/tx/action-view.tsx
@@ -17,6 +17,7 @@ import { ValidatorVoteComponent } from './actions-views/validator-vote.tsx';
 import { PositionOpenComponent } from './actions-views/position-open.tsx';
 import { PositionCloseComponent } from './actions-views/position-close.tsx';
 import { PositionWithdrawComponent } from './actions-views/position-withdraw.tsx';
+import { IbcRelayComponent } from './actions-views/ibc-relay.tsx';
 
 type Case = Exclude<ActionView['actionView']['case'], undefined>;
 
@@ -112,7 +113,7 @@ export const ActionViewComponent = ({
       return <UnimplementedView label='Validator Definition' />;
 
     case 'ibcRelayAction':
-      return <UnimplementedView label='IBC Relay Action' />;
+      return <IbcRelayComponent value={actionView.value} />;
 
     case 'proposalSubmit':
       return <UnimplementedView label='Proposal Submit' />;
diff --git a/packages/ui/components/ui/tx/actions-views/ibc-relay.tsx b/packages/ui/components/ui/tx/actions-views/ibc-relay.tsx
new file mode 100644
index 0000000000..c911f6e490
--- /dev/null
+++ b/packages/ui/components/ui/tx/actions-views/ibc-relay.tsx
@@ -0,0 +1,144 @@
+import { ViewBox } from '../viewbox';
+import { ActionDetails } from './action-details';
+import {
+  FungibleTokenPacketData,
+  IbcRelay,
+} from '@penumbra-zone/protobuf/penumbra/core/component/ibc/v1/ibc_pb';
+import { MsgRecvPacket } from '@penumbra-zone/protobuf/ibc/core/channel/v1/tx_pb';
+import { MsgUpdateClient } from '@penumbra-zone/protobuf/ibc/core/client/v1/tx_pb';
+import { UnimplementedView } from './unimplemented-view.tsx';
+import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64';
+import { getUtcTime } from './isc20-withdrawal.tsx';
+import { useMemo } from 'react';
+
+// Packet data stored as json string encoded into bytes
+const parsePacket = ({ packet }: MsgRecvPacket): FungibleTokenPacketData | undefined => {
+  if (!packet?.data) {
+    return undefined;
+  }
+
+  try {
+    const dataString = new TextDecoder().decode(packet.data);
+    return FungibleTokenPacketData.fromJsonString(dataString);
+  } catch (e) {
+    return undefined;
+  }
+};
+
+const MsgResvComponent = ({ packet }: { packet: MsgRecvPacket }) => {
+  const packetData = useMemo(() => parsePacket(packet), [packet]);
+
+  return (
+    <ViewBox
+      label='IBC Relay: Msg Received'
+      visibleContent={
+        <ActionDetails>
+          {packetData === undefined && (
+            <ActionDetails.Row label='Data'>Unknown packet data</ActionDetails.Row>
+          )}
+          {!!packetData?.sender && (
+            <ActionDetails.Row label='Sender'>
+              <ActionDetails.TruncatedText>{packetData.sender}</ActionDetails.TruncatedText>
+            </ActionDetails.Row>
+          )}
+          {!!packetData?.receiver && (
+            <ActionDetails.Row label='Receiver'>
+              <ActionDetails.TruncatedText>{packetData.receiver}</ActionDetails.TruncatedText>
+            </ActionDetails.Row>
+          )}
+          {!!packetData?.denom && (
+            <ActionDetails.Row label='Denom'>{packetData.denom}</ActionDetails.Row>
+          )}
+          {!!packetData?.amount && (
+            <ActionDetails.Row label='Amount'>{packetData.amount}</ActionDetails.Row>
+          )}
+          {packetData && 'memo' in packetData && (
+            <ActionDetails.Row label='Memo'>{packetData.memo}</ActionDetails.Row>
+          )}
+          {!!packet.packet?.sequence && (
+            <ActionDetails.Row label='Sequence'>{Number(packet.packet.sequence)}</ActionDetails.Row>
+          )}
+          {!!packet.packet?.sourcePort && (
+            <ActionDetails.Row label='Source Port'>{packet.packet.sourcePort}</ActionDetails.Row>
+          )}
+          {!!packet.packet?.sourceChannel && (
+            <ActionDetails.Row label='Source Channel'>
+              {packet.packet.sourceChannel}
+            </ActionDetails.Row>
+          )}
+          {!!packet.packet?.destinationPort && (
+            <ActionDetails.Row label='Destination Port'>
+              {packet.packet.destinationPort}
+            </ActionDetails.Row>
+          )}
+          {!!packet.packet?.destinationChannel && (
+            <ActionDetails.Row label='Destination Channel'>
+              {packet.packet.destinationChannel}
+            </ActionDetails.Row>
+          )}
+          {!!packet.packet?.timeoutHeight?.revisionHeight && (
+            <ActionDetails.Row label='Timeout revision height'>
+              {Number(packet.packet.timeoutHeight.revisionHeight)}
+            </ActionDetails.Row>
+          )}
+          {!!packet.packet?.timeoutHeight?.revisionNumber && (
+            <ActionDetails.Row label='Timeout revision number'>
+              {Number(packet.packet.timeoutHeight.revisionNumber)}
+            </ActionDetails.Row>
+          )}
+          {!!packet.packet?.timeoutTimestamp && (
+            <ActionDetails.Row label='Timeout timestamp'>
+              {getUtcTime(packet.packet.timeoutTimestamp)}
+            </ActionDetails.Row>
+          )}
+          <ActionDetails.Row label='Signer'>{packet.signer}</ActionDetails.Row>
+          {!!packet.proofHeight?.revisionHeight && (
+            <ActionDetails.Row label='Proof revision height'>
+              {Number(packet.proofHeight.revisionHeight)}
+            </ActionDetails.Row>
+          )}
+          {!!packet.proofHeight?.revisionNumber && (
+            <ActionDetails.Row label='Proof revision number'>
+              {Number(packet.proofHeight.revisionNumber)}
+            </ActionDetails.Row>
+          )}
+          <ActionDetails.Row label='Proof commitment'>
+            <ActionDetails.TruncatedText>
+              {uint8ArrayToBase64(packet.proofCommitment)}
+            </ActionDetails.TruncatedText>
+          </ActionDetails.Row>
+        </ActionDetails>
+      }
+    />
+  );
+};
+
+const UpdateClientComponent = ({ update }: { update: MsgUpdateClient }) => {
+  return (
+    <ViewBox
+      label='IBC Relay: Update Client'
+      visibleContent={
+        <ActionDetails>
+          <ActionDetails.Row label='Client id'>{update.clientId}</ActionDetails.Row>
+          <ActionDetails.Row label='Signer'>{update.signer}</ActionDetails.Row>
+        </ActionDetails>
+      }
+    />
+  );
+};
+
+export const IbcRelayComponent = ({ value }: { value: IbcRelay }) => {
+  if (value.rawAction?.is(MsgRecvPacket.typeName)) {
+    const packet = new MsgRecvPacket();
+    value.rawAction.unpackTo(packet);
+    return <MsgResvComponent packet={packet} />;
+  }
+
+  if (value.rawAction?.is(MsgUpdateClient.typeName)) {
+    const update = new MsgUpdateClient();
+    value.rawAction.unpackTo(update);
+    return <UpdateClientComponent update={update} />;
+  }
+
+  return <UnimplementedView label='IBC Relay' />;
+};
diff --git a/packages/ui/components/ui/tx/actions-views/isc20-withdrawal.tsx b/packages/ui/components/ui/tx/actions-views/isc20-withdrawal.tsx
index 1b3148e93a..8492fa70d5 100644
--- a/packages/ui/components/ui/tx/actions-views/isc20-withdrawal.tsx
+++ b/packages/ui/components/ui/tx/actions-views/isc20-withdrawal.tsx
@@ -4,7 +4,8 @@ import { ActionDetails } from './action-details';
 import { joinLoHiAmount } from '@penumbra-zone/types/amount';
 import { bech32mAddress } from '@penumbra-zone/bech32m/penumbra';
 
-const getUtcTime = (time: bigint) => {
+// Converts nanoseconds timestamp to UTC timestamp string
+export const getUtcTime = (time: bigint) => {
   const formatter = new Intl.DateTimeFormat('en-US', {
     dateStyle: 'medium',
     timeStyle: 'long',
diff --git a/packages/wasm/crate/src/keys.rs b/packages/wasm/crate/src/keys.rs
index 739919d27c..209d26a350 100644
--- a/packages/wasm/crate/src/keys.rs
+++ b/packages/wasm/crate/src/keys.rs
@@ -8,8 +8,7 @@ use penumbra_proof_params::{
     SPEND_PROOF_PROVING_KEY, SWAPCLAIM_PROOF_PROVING_KEY, SWAP_PROOF_PROVING_KEY,
 };
 use penumbra_proto::core::keys::v1 as pb;
-use penumbra_proto::core::keys::v1::WalletId;
-use penumbra_proto::{DomainType, Message};
+use penumbra_proto::DomainType;
 use rand_core::OsRng;
 use wasm_bindgen::prelude::*;
 
@@ -75,9 +74,7 @@ pub fn get_wallet_id(full_viewing_key: &[u8]) -> WasmResult<Vec<u8>> {
     utils::set_panic_hook();
 
     let fvk: FullViewingKey = FullViewingKey::decode(full_viewing_key)?;
-    // Can do `fvk.wallet_id().encode_to_vec()` when Domain impl added to WalletId in core
-    let wallet_id_proto = WalletId::from(fvk.wallet_id());
-    Ok(wallet_id_proto.encode_to_vec())
+    Ok(fvk.wallet_id().encode_to_vec())
 }
 
 /// get address by index using FVK
@@ -125,3 +122,17 @@ pub fn get_index_by_address(full_viewing_key: &[u8], address: &[u8]) -> WasmResu
     let result = serde_wasm_bindgen::to_value(&index)?;
     Ok(result)
 }
+
+/// Checks if address is controlled by full viewing key provided
+#[wasm_bindgen]
+pub fn is_controlled_address(full_viewing_key: &[u8], address: &[u8]) -> WasmResult<bool> {
+    utils::set_panic_hook();
+
+    let address: Address = Address::decode(address)?;
+    let fvk: FullViewingKey = FullViewingKey::decode(full_viewing_key)?;
+    Ok(is_controlled_inner(&fvk, &address))
+}
+
+pub fn is_controlled_inner(fvk: &FullViewingKey, address: &Address) -> bool {
+    fvk.address_index(address).is_some()
+}
diff --git a/packages/wasm/crate/src/view_server.rs b/packages/wasm/crate/src/view_server.rs
index 0e23b2dea2..f2e67a480b 100644
--- a/packages/wasm/crate/src/view_server.rs
+++ b/packages/wasm/crate/src/view_server.rs
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
 use indexed_db_futures::IdbDatabase;
 use penumbra_asset::asset::{Id, Metadata};
 use penumbra_compact_block::{CompactBlock, StatePayload};
-use penumbra_keys::FullViewingKey;
+use penumbra_keys::{Address, FullViewingKey};
 use penumbra_proto::DomainType;
 use penumbra_sct::Nullifier;
 use penumbra_shielded_pool::note;
@@ -17,6 +17,7 @@ use wasm_bindgen::prelude::wasm_bindgen;
 use wasm_bindgen::JsValue;
 
 use crate::error::WasmResult;
+use crate::keys::is_controlled_inner;
 use crate::note_record::SpendableNoteRecord;
 use crate::storage::{init_idb_storage, Storage};
 use crate::swap_record::SwapRecord;
@@ -311,6 +312,15 @@ impl ViewServer {
         let root = self.sct.root();
         Ok(root.encode_to_vec())
     }
+
+    /// Checks if address is controlled by view server full viewing key
+    #[wasm_bindgen]
+    pub fn is_controlled_address(&self, address: &[u8]) -> WasmResult<bool> {
+        utils::set_panic_hook();
+
+        let address: Address = Address::decode(address)?;
+        Ok(is_controlled_inner(&self.fvk, &address))
+    }
 }
 
 pub fn load_tree(stored_tree: StoredTree) -> Tree {
diff --git a/packages/wasm/crate/tests/test_keys.rs b/packages/wasm/crate/tests/test_keys.rs
index 5df81218d8..8f295b5c36 100644
--- a/packages/wasm/crate/tests/test_keys.rs
+++ b/packages/wasm/crate/tests/test_keys.rs
@@ -1,10 +1,13 @@
 extern crate core;
 
+use penumbra_keys::keys::{AddressIndex, Bip44Path, SeedPhrase, SpendKey};
 use penumbra_keys::FullViewingKey;
 use penumbra_proto::core::keys::v1::WalletId;
 use penumbra_proto::{DomainType, Message};
-use penumbra_wasm::keys::get_wallet_id;
+use penumbra_wasm::keys::{get_wallet_id, is_controlled_inner};
+use rand_core::OsRng;
 use std::str::FromStr;
+use wasm_bindgen_test::wasm_bindgen_test;
 
 #[test]
 fn successfully_get_wallet_id() {
@@ -24,11 +27,28 @@ fn successfully_get_wallet_id() {
     assert_eq!(expected_bech32_str, walet_id_str);
 }
 
-#[test]
-#[ignore]
-// revise this test since we cannot call wasm-bindgen imported functions on non-wasm targets
+#[wasm_bindgen_test]
 fn raises_if_fvk_invalid() {
     let fvk = FullViewingKey::from_str("penumbrafullviewingkey1sjeaceqzgaeye2ksnz8q73mp6rpx2ykdtzs8wurrnhwdn8vqwuxhxtjdndrjc74udjh0uch0tatnrd93q50wp9pfk86h3lgpew8lsqsz2a6la").unwrap();
     let err = get_wallet_id(fvk.encode_to_vec().as_slice()).unwrap_err();
     assert_eq!("invalid length", err.to_string());
 }
+
+#[test]
+fn detects_controlled_addr() {
+    let fvk = FullViewingKey::from_str("penumbrafullviewingkey1sjeaceqzgaeye2ksnz8q73mp6rpx2ykdtzs8wurrnhwdn8vqwuxhxtjdndrjc74udjh0uch0tatnrd93q50wp9pfk86h3lgpew8lsqsz2a6la").unwrap();
+    let (addr, _) = fvk.payment_address(AddressIndex::new(0));
+    assert!(is_controlled_inner(&fvk, &addr));
+}
+
+#[test]
+fn returns_false_on_unknown_addr() {
+    let fvk = FullViewingKey::from_str("penumbrafullviewingkey1sjeaceqzgaeye2ksnz8q73mp6rpx2ykdtzs8wurrnhwdn8vqwuxhxtjdndrjc74udjh0uch0tatnrd93q50wp9pfk86h3lgpew8lsqsz2a6la").unwrap();
+    let other_address =
+        SpendKey::from_seed_phrase_bip44(SeedPhrase::generate(OsRng), &Bip44Path::new(0))
+            .full_viewing_key()
+            .incoming()
+            .payment_address(AddressIndex::from(0u32))
+            .0;
+    assert!(!is_controlled_inner(&fvk, &other_address));
+}
diff --git a/packages/wasm/src/address.ts b/packages/wasm/src/address.ts
index 12496436b1..99f8c128f2 100644
--- a/packages/wasm/src/address.ts
+++ b/packages/wasm/src/address.ts
@@ -1,4 +1,4 @@
-import { get_index_by_address } from '../wasm/index.js';
+import { get_index_by_address, is_controlled_address } from '../wasm/index.js';
 import {
   Address,
   AddressIndex,
@@ -19,10 +19,5 @@ export const isControlledAddress = (fullViewingKey: FullViewingKey, address?: Ad
   if (!address) {
     return false;
   }
-
-  const viewableIndex = get_index_by_address(
-    fullViewingKey.toBinary(),
-    address.toBinary(),
-  ) as JsonValue;
-  return Boolean(viewableIndex);
+  return is_controlled_address(fullViewingKey.toBinary(), address.toBinary());
 };
diff --git a/packages/wasm/src/view-server.ts b/packages/wasm/src/view-server.ts
index bde894a12f..232ac86702 100644
--- a/packages/wasm/src/view-server.ts
+++ b/packages/wasm/src/view-server.ts
@@ -10,7 +10,8 @@ import {
 } from '@penumbra-zone/types/state-commitment-tree';
 import type { IdbConstants } from '@penumbra-zone/types/indexed-db';
 import type { ViewServerInterface } from '@penumbra-zone/types/servers';
-import { FullViewingKey } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
+import { Address, FullViewingKey } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb';
+import { isControlledAddress } from './address.js';
 
 declare global {
   // eslint-disable-next-line no-var -- TODO: explain
@@ -92,4 +93,8 @@ export class ViewServer implements ViewServerInterface {
       newSwaps: (new_swaps ?? []).map(s => SwapRecord.fromJson(s)),
     };
   }
+
+  isControlledAddress(address: Address): boolean {
+    return isControlledAddress(this.fullViewingKey, address);
+  }
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3e3798aa9d..03d4d34189 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -180,8 +180,8 @@ importers:
         specifier: ^1.23.29
         version: 1.23.29(@types/react@18.3.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@penumbra-labs/registry':
-        specifier: ^11.2.0
-        version: 11.2.0
+        specifier: ^11.3.1
+        version: 11.3.1
       '@penumbra-zone/bech32m':
         specifier: workspace:*
         version: link:../../packages/bech32m
@@ -539,8 +539,8 @@ importers:
         version: 8.0.0
     devDependencies:
       '@penumbra-labs/registry':
-        specifier: ^11.2.0
-        version: 11.2.0
+        specifier: ^11.3.1
+        version: 11.3.1
       '@penumbra-zone/protobuf':
         specifier: workspace:*
         version: link:../protobuf
@@ -739,7 +739,7 @@ importers:
         version: 3.0.1
       postcss:
         specifier: ^8.4.38
-        version: 7.0.39
+        version: 8.4.39
       react:
         specifier: ^18.3.1
         version: 18.3.1
@@ -2905,8 +2905,8 @@ packages:
     resolution: {integrity: sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==}
     engines: {node: '>= 10.0.0'}
 
-  '@penumbra-labs/registry@11.2.0':
-    resolution: {integrity: sha512-kGxu6KWjFl581eEYMk9/ODehrxJT9YB9TxNxJOYSKXt+8ni9kovovcj5jdP7MkP7bGHrNEssSziWvjCg2nfuug==}
+  '@penumbra-labs/registry@11.3.1':
+    resolution: {integrity: sha512-0hBfPZW4Y3my6RzYSBGI3cwutW+C7KJXn5OLXOhhXPsH+VlexrxvKIWc8nJeUwRCTtBkRR0lUSwuIhnaw0tsyQ==}
 
   '@pkgjs/parseargs@0.11.0':
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
@@ -14310,7 +14310,7 @@ snapshots:
       '@parcel/watcher-win32-ia32': 2.4.1
       '@parcel/watcher-win32-x64': 2.4.1
 
-  '@penumbra-labs/registry@11.2.0': {}
+  '@penumbra-labs/registry@11.3.1': {}
 
   '@pkgjs/parseargs@0.11.0':
     optional: true