Skip to content

Commit 103d6b6

Browse files
feat: 184 enable release transfer rights for assets (#185)
* feat: add routing for transfer rights * feat: update qubic static model to have fee field * feat: add transfer rights components * feat: generate translation for all languages for transfer right feature strings
1 parent 401cd9f commit 103d6b6

21 files changed

+1442
-11
lines changed

src/app/app-routing.module.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { VotingCreateComponent } from './voting/create/voting-create.component';
1010
import { IpoComponent } from './ipo/ipo.component';
1111
import { PlaceBidComponent } from './ipo/place-bid/place-bid.component';
1212
import { AssetsComponent } from './assets/assets.component';
13+
import { TransferRightsComponent } from './assets/transfer-rights/transfer-rights.component';
1314
import { NavigationComponent } from './navigation/navigation.component';
1415
import { WelcomeComponent } from './public/welcome/welcome.component';
1516
import { CreateVaultComponent } from './public/create-vault/create-vault.component';
@@ -92,6 +93,10 @@ const routes: Routes = [
9293
path : 'assets-area',
9394
component: AssetsComponent
9495
},
96+
{
97+
path : 'assets-area/transfer-rights',
98+
component: TransferRightsComponent
99+
},
95100
];
96101

97102
@NgModule({

src/app/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { BalanceHiddenComponent } from './core/balance-hidden/balance-hidden.com
9595
import { SeedDisplayPipe } from './pipes/seed-display.pipe';
9696
import { SeedFirstLinePipe, SeedSecondLinePipe } from './pipes/seed-display-line.pipe';
9797
import { DateTimePipe } from './pipes/date-time.pipe';
98+
import { TransferRightsComponent } from './assets/transfer-rights/transfer-rights.component';
9899

99100

100101
/** Http interceptor providers in outside-in order */
@@ -147,7 +148,8 @@ export const httpInterceptorProviders = [{ provide: HTTP_INTERCEPTORS, useClass:
147148
SeedDisplayPipe,
148149
SeedFirstLinePipe,
149150
DateTimePipe,
150-
SeedSecondLinePipe
151+
SeedSecondLinePipe,
152+
TransferRightsComponent
151153
],
152154
imports: [
153155
BrowserModule,

src/app/assets/assets.component.html

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,20 @@ <h3>
171171
</div>
172172

173173
<ng-template #actions let-group="group" let-issuerIdentity="issuerIdentity">
174-
<button *ngIf="canSendGroupedAsset(group)" mat-icon-button class="icon-color-link" (click)="openSendForm(getQxManagedAsset(group)!)"
175-
title='{{ t("assetsComponent.buttons.send") }}'>
176-
<mat-icon>send</mat-icon>
177-
</button>
178-
<button mat-icon-button class="icon-color-link" (click)="openIssuerIdentity(issuerIdentity)"
179-
title='{{ t("assetsComponent.table.issuerIdentity") }}'>
180-
<mat-icon>explore</mat-icon>
181-
</button>
174+
<div class="actions-container">
175+
<button *ngIf="canSendGroupedAsset(group)" mat-icon-button class="icon-color-link" (click)="openSendForm(getQxManagedAsset(group)!)"
176+
title='{{ t("assetsComponent.buttons.send") }}'>
177+
<mat-icon>send</mat-icon>
178+
</button>
179+
<button *ngIf="canTransferRights(group)" mat-icon-button class="icon-color-link" (click)="openTransferRightsForm(group)"
180+
title='{{ t("assetsComponent.buttons.transferRights") }}'>
181+
<mat-icon>swap_horiz</mat-icon>
182+
</button>
183+
<button mat-icon-button class="icon-color-link" (click)="openIssuerIdentity(issuerIdentity)"
184+
title='{{ t("assetsComponent.table.issuerIdentity") }}'>
185+
<mat-icon>explore</mat-icon>
186+
</button>
187+
</div>
182188
</ng-template>
183189
<!-- showSendForm -->
184190
<ng-container *ngIf="showSendForm">

src/app/assets/assets.component.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,11 @@ mat-card {
117117
.mat-mdc-cell{
118118
height: 70px;
119119
}
120+
121+
.actions-container {
122+
display: flex;
123+
flex-direction: row;
124+
align-items: center;
125+
white-space: nowrap;
126+
gap: 4px;
127+
}

src/app/assets/assets.component.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { shortenAddress } from '../utils/address.utils';
2323
import { ExplorerUrlHelper } from '../services/explorer-url.helper';
2424
import { QubicStaticService } from '../services/apis/static/qubic-static.service';
2525
import { StaticSmartContract } from '../services/apis/static/qubic-static.model';
26-
import { ASSET_TRANSFER_FEE } from '../constants/qubic.constants';
26+
import { ASSET_TRANSFER_FEE, TRANSFER_SHARE_MANAGEMENT_RIGHTS_PROCEDURE } from '../constants/qubic.constants';
2727

2828
// Interfaces for asset grouping
2929
interface GroupedAsset {
@@ -665,6 +665,49 @@ export class AssetsComponent implements OnInit, OnDestroy {
665665
return qxContract?.asset ?? null;
666666
}
667667

668+
/**
669+
* Check if a grouped asset supports transfer rights
670+
* Transfer rights are available for assets with balance > 0 in contracts that support it
671+
* Dynamically checks based on procedure name from backend
672+
*/
673+
canTransferRights(group: GroupedAsset): boolean {
674+
// Check if any managing contract in this group supports transfer rights
675+
return group.managingContracts.some((mc: ManagingContract) => {
676+
const contract = this.smartContractsMap.get(mc.contractIndex);
677+
if (!contract) return false;
678+
679+
// Dynamically check if contract has the transfer rights procedure
680+
const hasProcedure = contract.procedures.some(
681+
p => p.name === TRANSFER_SHARE_MANAGEMENT_RIGHTS_PROCEDURE
682+
);
683+
684+
// Must have procedure and positive balance
685+
return hasProcedure && mc.asset.ownedAmount > 0;
686+
});
687+
}
688+
689+
/**
690+
* Navigate to Transfer Rights form with first managing contract pre-selected
691+
*/
692+
openTransferRightsForm(group: GroupedAsset): void {
693+
// Get the first managing contract
694+
const firstContract = group.managingContracts[0];
695+
696+
if (firstContract) {
697+
this.router.navigate(['/assets-area/transfer-rights'], {
698+
queryParams: {
699+
publicId: group.publicId,
700+
assetName: group.assetName,
701+
issuerIdentity: group.issuerIdentity,
702+
contractIndex: firstContract.contractIndex
703+
}
704+
});
705+
} else {
706+
// Fallback if no managing contracts
707+
this.router.navigate(['/assets-area/transfer-rights']);
708+
}
709+
}
710+
668711
ngOnDestroy(): void {
669712
this.destroy$.next();
670713
this.destroy$.complete();
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<ng-container *transloco="let t">
2+
<div class="content_container">
3+
<h1>{{ t("assetsComponent.title") }}</h1>
4+
<mat-card class="transfer-rights-card">
5+
<mat-card-content>
6+
<!-- Loading State -->
7+
<div *ngIf="isLoading" class="loading-container">
8+
<mat-spinner diameter="40"></mat-spinner>
9+
<p>{{ t("transferRights.loading") }}</p>
10+
</div>
11+
12+
<!-- No Assets Message -->
13+
<div *ngIf="!isLoading && sourceContracts.length === 0" class="no-assets-message">
14+
<mat-icon class="large-icon">info</mat-icon>
15+
<h3>{{ t("transferRights.noAssets.title") }}</h3>
16+
<p>{{ t("transferRights.noAssets.message") }}</p>
17+
<button mat-raised-button color="primary" (click)="goBack()">
18+
{{ t("transferRights.noAssets.backButton") }}
19+
</button>
20+
</div>
21+
22+
<!-- Transfer Rights Form -->
23+
<form *ngIf="!isLoading && sourceContracts.length > 0" [formGroup]="transferRightsForm"
24+
(ngSubmit)="submitTransferRights()">
25+
26+
<h2>{{ t("transferRights.title") }}</h2>
27+
<!-- Fee Information at the top -->
28+
<div class="transaction-fee" *ngIf="selectedSourceContract">
29+
{{ t("transferRights.form.procedureFee") }}: {{ getTransactionFee() | number: '1.0-0' }} QUBIC
30+
</div>
31+
32+
<div class="row">
33+
<div class="col">
34+
<!-- Source Contract Selection -->
35+
<mat-form-field appearance="fill" class="full-width">
36+
<mat-label>{{ t("transferRights.form.sourceContract") }}</mat-label>
37+
<mat-select formControlName="sourceContract" [compareWith]="compareContracts">
38+
<mat-select-trigger *ngIf="selectedSourceContract">
39+
<div class="contract-trigger">
40+
<span class="contract-name">{{ selectedSourceContract.contractName }}</span>
41+
<span class="asset-info">
42+
{{ selectedSourceContract.availableBalance | number: '1.0-0' }}
43+
{{ selectedSourceContract.asset.assetName }}
44+
</span>
45+
<span class="owner-info">
46+
{{ getSeedAlias(selectedSourceContract.asset.publicId) }}
47+
({{ shortenAddress(selectedSourceContract.asset.publicId) }})
48+
</span>
49+
</div>
50+
</mat-select-trigger>
51+
<mat-option *ngFor="let contract of sourceContracts" [value]="contract">
52+
<div class="contract-option">
53+
<strong>{{ contract.contractName }}</strong>
54+
<br>
55+
<small>
56+
{{ contract.availableBalance | number: '1.0-0' }} {{ contract.asset.assetName }} |
57+
{{ getSeedAlias(contract.asset.publicId) }} ({{ shortenAddress(contract.asset.publicId) }})
58+
</small>
59+
</div>
60+
</mat-option>
61+
</mat-select>
62+
<mat-error *ngIf="transferRightsForm.get('sourceContract')?.hasError('required')">
63+
{{ t("transferRights.errors.sourceRequired") }}
64+
</mat-error>
65+
</mat-form-field>
66+
</div>
67+
</div>
68+
69+
<div class="row">
70+
<div class="col">
71+
<!-- Destination Contract Selection -->
72+
<mat-form-field appearance="fill" class="full-width">
73+
<mat-label>{{ t("transferRights.form.destinationContract") }}</mat-label>
74+
<mat-select formControlName="destinationContract" [compareWith]="compareContracts">
75+
<mat-option *ngFor="let contract of destinationContracts" [value]="contract">
76+
<div class="contract-option">
77+
<strong>{{ contract.contractName }}</strong>
78+
</div>
79+
</mat-option>
80+
</mat-select>
81+
<mat-error *ngIf="transferRightsForm.get('destinationContract')?.hasError('required')">
82+
{{ t("transferRights.errors.destinationRequired") }}
83+
</mat-error>
84+
<mat-error *ngIf="transferRightsForm.hasError('contractsEqual')">
85+
{{ t("transferRights.errors.contractsEqual") }}
86+
</mat-error>
87+
</mat-form-field>
88+
</div>
89+
</div>
90+
91+
<div class="row">
92+
<div class="col">
93+
<!-- Number of Shares (equivalent to Amount field) -->
94+
<mat-form-field appearance="fill" class="full-width">
95+
<mat-label>{{ t("transferRights.form.numberOfShares") }}</mat-label>
96+
<input matInput type="number" formControlName="numberOfShares" min="1">
97+
<button *ngIf="transferRightsForm.controls['numberOfShares'].value" matSuffix mat-icon-button
98+
aria-label="Clear" (click)="transferRightsForm.controls['numberOfShares'].setValue(null)" type="button">
99+
<mat-icon>close</mat-icon>
100+
</button>
101+
<button *ngIf="selectedSourceContract && selectedSourceContract.availableBalance > 0" matSuffix
102+
mat-icon-button matTooltip="{{ t('transferRights.form.maxButton') }}" (click)="setMaxShares()"
103+
type="button">
104+
<mat-icon>all_inclusive</mat-icon>
105+
</button>
106+
<mat-error *ngIf="transferRightsForm.get('numberOfShares')?.hasError('required')">
107+
{{ t("transferRights.errors.sharesRequired") }}
108+
</mat-error>
109+
<mat-error *ngIf="transferRightsForm.get('numberOfShares')?.hasError('min')">
110+
{{ t("transferRights.errors.sharesMin") }}
111+
</mat-error>
112+
<mat-error *ngIf="transferRightsForm.get('numberOfShares')?.hasError('max')">
113+
{{ t("transferRights.errors.sharesMax", { max: selectedSourceContract?.availableBalance }) }}
114+
</mat-error>
115+
<mat-hint *ngIf="selectedSourceContract" align="end">{{
116+
transferRightsForm.controls['numberOfShares'].value | number: '1.0-0' }} /
117+
{{ selectedSourceContract.availableBalance | number: '1.0-0' }} shares</mat-hint>
118+
</mat-form-field>
119+
</div>
120+
<div class="col">
121+
<!-- Tick (matching Send Assets layout) -->
122+
<mat-form-field appearance="fill" class="full-width">
123+
<mat-label>{{ t("transferRights.form.tick") }}</mat-label>
124+
<input matInput type="number" formControlName="tick" [readonly]="!tickOverwrite">
125+
<button matSuffix mat-icon-button matTooltip="{{ t('transferRights.form.tickOverwrite') }}"
126+
(click)="tickOverwrite = !tickOverwrite" type="button" [class]="{tickOverwrite: tickOverwrite}">
127+
<mat-icon>tune</mat-icon>
128+
</button>
129+
<mat-error *ngIf="transferRightsForm.get('tick')?.hasError('required')">
130+
{{ t("transferRights.errors.tickRequired") }}
131+
</mat-error>
132+
<mat-error *ngIf="transferRightsForm.get('tick')?.hasError('min')">
133+
{{ t("transferRights.errors.tickMin", { min: currentTick }) }}
134+
</mat-error>
135+
</mat-form-field>
136+
</div>
137+
</div>
138+
139+
<!-- Balance after fees warning -->
140+
<div *ngIf="selectedSourceContract && !canPayFees()" class="error-message">
141+
{{ t("transferRights.errors.insufficientBalance") }}
142+
</div>
143+
144+
<!-- Form Actions -->
145+
<mat-card-actions class="padding">
146+
<button mat-raised-button type="button" (click)="goBack()" [disabled]="isSubmitting">
147+
{{ t("assetsComponent.form.buttons.cancel") }}
148+
</button>
149+
<button *ngIf="!walletService.privateKey" mat-raised-button color="primary" type="button" (click)="loadKey()">
150+
{{ t("paymentComponent.buttons.loadPrivateKey") }}
151+
</button>
152+
<button *ngIf="walletService.privateKey" mat-raised-button color="primary" type="submit"
153+
[disabled]="!transferRightsForm.valid || isSubmitting || !canPayFees()">
154+
<span *ngIf="!isSubmitting">{{ t("transferRights.form.submit") }}</span>
155+
<mat-progress-spinner *ngIf="isSubmitting" mode="indeterminate" [diameter]="20">
156+
</mat-progress-spinner>
157+
</button>
158+
</mat-card-actions>
159+
</form>
160+
</mat-card-content>
161+
</mat-card>
162+
</div>
163+
</ng-container>

0 commit comments

Comments
 (0)