diff --git a/apps/webapp/src/app/pages/account/account.page.html b/apps/webapp/src/app/pages/account/account.page.html index ad7a58f..824b654 100644 --- a/apps/webapp/src/app/pages/account/account.page.html +++ b/apps/webapp/src/app/pages/account/account.page.html @@ -13,14 +13,14 @@

-
+
- account.info?.name || token
@@ -38,6 +38,64 @@

+ + + + + + + + + + + + + + + + + + + +
Transactions
+ Type + + Date +
+
+
+
+ {{ transaction.type }} +
+
+
+
+ {{ transaction.timestamp | date: 'short' }} +
+ +
+ + +

+ Please connect your wallet to view your account details. +

+
+ +
+
+ {{ error() }} +
+
diff --git a/apps/webapp/src/app/pages/account/account.page.ts b/apps/webapp/src/app/pages/account/account.page.ts index f127306..fa634e2 100644 --- a/apps/webapp/src/app/pages/account/account.page.ts +++ b/apps/webapp/src/app/pages/account/account.page.ts @@ -1,4 +1,4 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LayoutContainerComponent } from '../../containers/layout/layout-container.component'; @@ -12,8 +12,17 @@ import { WalletStore } from '../../store'; styleUrl: './account.page.css', providers: [WalletStore], }) -export class AccountPage { - +export class AccountPage implements OnInit { readonly walletStore = inject(WalletStore); - readonly account$ = this.walletStore.account() + readonly account$ = this.walletStore.account(); + readonly transactions = this.walletStore.transactions; + readonly error = this.walletStore.error; + + ngOnInit() { + this.loadTransactions(); + } + + loadTransactions() { + this.walletStore.loadTransactions(10); + } } diff --git a/apps/webapp/src/app/store/wallet/index.ts b/apps/webapp/src/app/store/wallet/index.ts index ac69ddf..e1557aa 100644 --- a/apps/webapp/src/app/store/wallet/index.ts +++ b/apps/webapp/src/app/store/wallet/index.ts @@ -1,3 +1,3 @@ export * from './store' -export * from './model' +export * from './models' export * from './service' diff --git a/apps/webapp/src/app/store/wallet/model.ts b/apps/webapp/src/app/store/wallet/model.ts deleted file mode 100644 index b5a5925..0000000 --- a/apps/webapp/src/app/store/wallet/model.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type TokenBalanceResponse = { - success: boolean; - message: string; - result: { - address: string; - balance: number; - associated_account: string; - info: { - name: string; - symbol: string; - image: string; - decimals: number; - }; - isFrozen: false; - }; -}; diff --git a/apps/webapp/src/app/store/wallet/models.ts b/apps/webapp/src/app/store/wallet/models.ts new file mode 100644 index 0000000..7f0db58 --- /dev/null +++ b/apps/webapp/src/app/store/wallet/models.ts @@ -0,0 +1,110 @@ +export type TokenBalanceResponse = { + success: boolean; + message: string; + result: { + address: string; + balance: number; + associated_account: string; + info: { + name: string; + symbol: string; + image: string; + decimals: number; + }; + isFrozen: false; + }; +}; + +export type TransactionType = 'SOL_TRANSFER' | 'TOKEN_TRANSFER'; + +export type TransactionProtocol = { + address: string; + name: string; +}; + +export type TransactionAction = { + info: { + sender: string; + receiver: string; + amount: number; + message?: string; + }; + source_protocol: string; + type: TransactionType; +}; + +export type TransactionRawMeta = { + computeUnitsConsumed: number; + err: null | Error; + fee: number; + innerInstructions: []; + logMessages: Array; + postBalances: Array; + postTokenBalances: []; + preBalances: Array; + preTokenBalances: []; + rewards: []; + status: { + Ok: null; + }; +}; + +export type AccountKey = { + pubkey: string; + signer: boolean; + source: string; + writable: boolean; +}; + +export type TransactionInstruction = { + parsed: { + info: { + destination: string; + lamports: number; + source: string; + }; + type: string; + }; + program: string; + programId: string; +}; + +export type Transaction = { + timestamp: string; + fee: number; + fee_payer: string; + signers: Array; + signatures: Array; + protocol: TransactionProtocol; + type: TransactionType; + actions: Array; + raw: { + blockTime: number; + meta: TransactionRawMeta; + slot: number; + transaction: { + message: { + accountKeys: Array; + addressTableLookups: null; + instructions: Array; + recentBlockhash: string; + }; + signatures: Array; + }; + version: string; + }; +} + +export type TransactionHistoryResponse = { + success: boolean; + message: string; + result: Array; +}; + +export type Transactions = Array<{ + timestamp: Date; + type: 'transfer' | 'unknown'; + memo?: string + amount?: number, + sign?: -1 | 1, +}>; \ No newline at end of file diff --git a/apps/webapp/src/app/store/wallet/service.ts b/apps/webapp/src/app/store/wallet/service.ts index e238eeb..f27aba4 100644 --- a/apps/webapp/src/app/store/wallet/service.ts +++ b/apps/webapp/src/app/store/wallet/service.ts @@ -1,9 +1,22 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { map, of } from 'rxjs'; +import { getAssociatedTokenAddressSync } from '@solana/spl-token'; +import { map, of, timeout } from 'rxjs'; +import { PublicKey } from '@solana/web3.js'; import { environment } from '../../../environments/environment'; -import { TokenBalanceResponse } from './model'; +import { + TokenBalanceResponse, + Transaction, + TransactionHistoryResponse, + Transactions, +} from './models'; + +const isInvalidTransaction = (transaction: Transaction) => { + return ( + transaction.type !== 'TOKEN_TRANSFER' || transaction.actions.length !== 2 + ); +}; @Injectable({ providedIn: 'root' }) export class ShyftApiService { @@ -28,6 +41,47 @@ export class ShyftApiService { 'x-api-key': environment.shyftApiKey, }, }) - .pipe(map((res) => res.result)); + .pipe( + timeout(5000), + map((res) => res.result)); + } + + getTransactions(publicKey: PublicKey, limit = 100) { + const account = getAssociatedTokenAddressSync( + new PublicKey(environment.mintUSDC), + publicKey + ); + const url = new URL('/sol/v1/transaction/history', environment.shyftApiUrl); + url.searchParams.append('network', environment.walletNetwork); + url.searchParams.append('account', account.toBase58()); + url.searchParams.append('tx_num', limit.toString()); + + const wallet = publicKey.toBase58(); + + return this.http + .get(url.toString(), { + headers: { + 'x-api-key': environment.shyftApiKey, + }, + }) + .pipe( + timeout(5000), + map((res) => + res.result.map((transaction) => ({ + ...(isInvalidTransaction(transaction) + ? { + timestamp: new Date(transaction.timestamp), + type: 'unknown', + } + : { + timestamp: new Date(transaction.timestamp), + memo: transaction.actions[1].info.message, + amount: transaction.actions[1].info.amount, + sign: transaction.actions[0].info.sender === wallet ? -1 : 1, + type: 'transfer', + }), + })) + ) + ); } } diff --git a/apps/webapp/src/app/store/wallet/store.ts b/apps/webapp/src/app/store/wallet/store.ts index b6555b6..bcff010 100644 --- a/apps/webapp/src/app/store/wallet/store.ts +++ b/apps/webapp/src/app/store/wallet/store.ts @@ -1,13 +1,23 @@ import { computed, inject } from '@angular/core'; -import { signalStore, withComputed, withState } from '@ngrx/signals'; +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { WalletStore as WalletAdapterStore } from '@heavy-duty/wallet-adapter'; +import { combineLatest, of, pipe, switchMap, tap } from 'rxjs'; import { ShyftApiService } from './service'; -import { switchMap } from 'rxjs'; +import { Transactions } from './models'; type WalletState = { - isLoading: boolean; wallet: WalletAdapterStore; + transactions: Transactions; + isLoading?: boolean; error?: string; }; @@ -16,6 +26,8 @@ export const WalletStore = signalStore( () => { wallet: inject(WalletAdapterStore), + transactions: [], + error: '', } ), withComputed((store, walletService = inject(ShyftApiService)) => ({ @@ -28,5 +40,29 @@ export const WalletStore = signalStore( ) ); }), + })), + withMethods((store, walletService = inject(ShyftApiService)) => ({ + loadTransactions: rxMethod( + pipe( + tap(() => patchState(store, { isLoading: true })), + switchMap((limit) => + combineLatest([store.wallet().publicKey$, of(limit)]) + ), + switchMap(([publicKey, limit]) => { + if (!publicKey) return []; + return walletService.getTransactions(publicKey, limit).pipe( + tapResponse({ + next: (transactions) => patchState(store, { transactions }), + error: (error: Error) => { + console.error('error', error); + patchState(store, { error: error.message }); + }, + complete: () => + patchState(store, { isLoading: false, error: '' }), + }) + ); + }) + ) + ), })) ); diff --git a/package-lock.json b/package-lock.json index 9041ebd..630de5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@ng-icons/ionicons": "^26.4.0", "@ngrx/signals": "^17.1.0", "@nx/angular": "18.0.3", + "@solana/spl-token": "^0.4.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", @@ -9813,6 +9814,20 @@ "node": ">=5.10" } }, + "node_modules/@solana/buffer-layout-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz", + "integrity": "sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/web3.js": "^1.32.0", + "bigint-buffer": "^1.1.5", + "bignumber.js": "^9.0.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/@solana/buffer-layout/node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -9836,6 +9851,142 @@ "ieee754": "^1.2.1" } }, + "node_modules/@solana/codecs-core": { + "version": "2.0.0-experimental.8618508", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.0.0-experimental.8618508.tgz", + "integrity": "sha512-JCz7mKjVKtfZxkuDtwMAUgA7YvJcA2BwpZaA1NOLcted4OMC4Prwa3DUe3f3181ixPYaRyptbF0Ikq2MbDkYEA==" + }, + "node_modules/@solana/codecs-data-structures": { + "version": "2.0.0-experimental.8618508", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-experimental.8618508.tgz", + "integrity": "sha512-sLpjL9sqzaDdkloBPV61Rht1tgaKq98BCtIKRuyscIrmVPu3wu0Bavk2n/QekmUzaTsj7K1pVSniM0YqCdnEBw==", + "dependencies": { + "@solana/codecs-core": "2.0.0-experimental.8618508", + "@solana/codecs-numbers": "2.0.0-experimental.8618508" + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "2.0.0-experimental.8618508", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-experimental.8618508.tgz", + "integrity": "sha512-EXQKfzFr3CkKKNzKSZPOOOzchXsFe90TVONWsSnVkonO9z+nGKALE0/L9uBmIFGgdzhhU9QQVFvxBMclIDJo2Q==", + "dependencies": { + "@solana/codecs-core": "2.0.0-experimental.8618508" + } + }, + "node_modules/@solana/codecs-strings": { + "version": "2.0.0-experimental.8618508", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-2.0.0-experimental.8618508.tgz", + "integrity": "sha512-b2yhinr1+oe+JDmnnsV0641KQqqDG8AQ16Z/x7GVWO+AWHMpRlHWVXOq8U1yhPMA4VXxl7i+D+C6ql0VGFp0GA==", + "dependencies": { + "@solana/codecs-core": "2.0.0-experimental.8618508", + "@solana/codecs-numbers": "2.0.0-experimental.8618508" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22" + } + }, + "node_modules/@solana/options": { + "version": "2.0.0-experimental.8618508", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-2.0.0-experimental.8618508.tgz", + "integrity": "sha512-fy/nIRAMC3QHvnKi63KEd86Xr/zFBVxNW4nEpVEU2OT0gCEKwHY4Z55YHf7XujhyuM3PNpiBKg/YYw5QlRU4vg==", + "dependencies": { + "@solana/codecs-core": "2.0.0-experimental.8618508", + "@solana/codecs-numbers": "2.0.0-experimental.8618508" + } + }, + "node_modules/@solana/spl-token": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.4.0.tgz", + "integrity": "sha512-jjBIBG9IsclqQVl5Y82npGE6utdCh7Z9VFcF5qgJa5EUq2XgspW3Dt1wujWjH/vQDRnkp9zGO+BqQU/HhX/3wg==", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/buffer-layout-utils": "^0.2.0", + "@solana/spl-token-metadata": "^0.1.2", + "buffer": "^6.0.3" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.89.1" + } + }, + "node_modules/@solana/spl-token-metadata": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@solana/spl-token-metadata/-/spl-token-metadata-0.1.2.tgz", + "integrity": "sha512-hJYnAJNkDrtkE2Q41YZhCpeOGU/0JgRFXbtrtOuGGeKc3pkEUHB9DDoxZAxx+XRno13GozUleyBi0qypz4c3bw==", + "dependencies": { + "@solana/codecs-core": "2.0.0-experimental.8618508", + "@solana/codecs-data-structures": "2.0.0-experimental.8618508", + "@solana/codecs-numbers": "2.0.0-experimental.8618508", + "@solana/codecs-strings": "2.0.0-experimental.8618508", + "@solana/options": "2.0.0-experimental.8618508", + "@solana/spl-type-length-value": "0.1.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.87.6" + } + }, + "node_modules/@solana/spl-token/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@solana/spl-type-length-value": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@solana/spl-type-length-value/-/spl-type-length-value-0.1.0.tgz", + "integrity": "sha512-JBMGB0oR4lPttOZ5XiUGyvylwLQjt1CPJa6qQ5oM+MBCndfjz2TKKkw0eATlLLcYmq1jBVsNlJ2cD6ns2GR7lA==", + "dependencies": { + "buffer": "^6.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@solana/spl-type-length-value/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/@solana/wallet-adapter-base": { "version": "0.9.23", "resolved": "https://registry.npmjs.org/@solana/wallet-adapter-base/-/wallet-adapter-base-0.9.23.tgz", @@ -16097,6 +16248,14 @@ "node": ">= 10.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -19564,6 +19723,12 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastestsmallesttextencoderdecoder": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "peer": true + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", diff --git a/package.json b/package.json index 89db095..c3ae72d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@ng-icons/ionicons": "^26.4.0", "@ngrx/signals": "^17.1.0", "@nx/angular": "18.0.3", + "@solana/spl-token": "^0.4.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10",